From c952266092980bca2fb01e111a94a156bc445a01 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 3 Jun 2026 16:18:28 +0100 Subject: [PATCH 01/72] feat(napari): add registration panel --- NAPARI_REGISTRATION_PLAN.md | 113 +++ .../_napari/_registration/__init__.py | 5 + src/confusius/_napari/_registration/_panel.py | 761 ++++++++++++++++++ src/confusius/_napari/_widget.py | 3 + src/confusius/_napari/assets/images.svg | 1 + .../test_napari/test_registration_panel.py | 171 ++++ 6 files changed, 1054 insertions(+) create mode 100644 NAPARI_REGISTRATION_PLAN.md create mode 100644 src/confusius/_napari/_registration/__init__.py create mode 100644 src/confusius/_napari/_registration/_panel.py create mode 100644 src/confusius/_napari/assets/images.svg create mode 100644 tests/unit/test_napari/test_registration_panel.py diff --git a/NAPARI_REGISTRATION_PLAN.md b/NAPARI_REGISTRATION_PLAN.md new file mode 100644 index 00000000..de200877 --- /dev/null +++ b/NAPARI_REGISTRATION_PLAN.md @@ -0,0 +1,113 @@ +# Napari registration panel plan + +## Goal + +Add a first **Registration** tab to the ConfUSIus napari plugin so users can run the main registration workflows directly from the viewer. + +## Phase 1 scope + +Deliver a thin but usable panel focused on running registrations and adding the resampled output back to napari. + +### Included + +- New **Registration** accordion tab in the napari plugin. +- Use the same Lucide icon as the docs registration page: `images`. +- Support: + - `register_volume` + - `register_volumewise` +- Always resample in the GUI. + - No `resample=True/False` toggle. + - The resampled result is always added as a **new layer**. +- Minimal parameter surface: + - operation + - moving layer + - fixed layer for `register_volume` + - reference time for `register_volumewise` + - transform model + - metric + - resampling interpolation + - optional multi-resolution toggle + - learning rate + - number of iterations +- Run work in a background thread so the napari UI stays responsive. +- Attach the resulting `xarray.DataArray` to layer metadata, plus transform/diagnostic provenance. + +### Not yet included + +- Manual initialization transforms from direct napari interaction. +- Save/load/apply transform UI. +- In-napari live registration progress plots. +- Per-frame progress callbacks for `register_volumewise` or resampling utilities. +- Standalone `resample_like` / `resample_volume` actions. +- Registration masks. +- Cancellation. + +## UX decisions + +### `register_volume` + +- Requires a moving layer and a fixed layer. +- Both must be spatial-only volumes. +- Result layer name should clearly indicate the fixed target. +- Keep the estimated transform and diagnostics in metadata for later reuse. + +### `register_volumewise` + +- Operates on one time-series layer. +- Uses a selected `reference_time`. +- Adds the registered time series as a new layer. +- Preserve motion metadata already returned by `register_volumewise`. + +## Implementation notes + +### Layer → DataArray conversion + +The panel should prefer `layer.metadata["xarray"]` when available. +For generic napari layers without ConfUSIus metadata, reconstruct a simple `xarray.DataArray` from: + +- `layer.data` +- `layer.scale` +- `layer.translate` +- `layer.axis_labels` +- `layer.units` + +This keeps manual/foreign napari layers usable in the panel. + +### Provenance + +Store a small provenance payload on the result layer metadata, including: + +- operation name +- moving layer name +- fixed layer name when applicable +- transform model +- metric +- interpolation +- transform object for `register_volume` +- diagnostics + +## Follow-up phases + +### Phase 2 + +Transform management: + +- save/load/apply affine transforms +- stable serialized transform payload owned by ConfUSIus +- better provenance for manual vs optimized transforms + +### Phase 3 + +Manual initialization: + +- capture napari layer transforms as initialization affines +- apply saved affines back onto layers +- reset/apply current transform actions + +### Phase 4 + +Progress integration: + +- custom progress hooks for `register_volume` +- napari-native metric/composite viewer +- per-frame progress callback for `register_volumewise` diff --git a/src/confusius/_napari/_registration/__init__.py b/src/confusius/_napari/_registration/__init__.py new file mode 100644 index 00000000..c34f67fa --- /dev/null +++ b/src/confusius/_napari/_registration/__init__.py @@ -0,0 +1,5 @@ +"""Registration panel for the ConfUSIus napari plugin.""" + +from confusius._napari._registration._panel import RegistrationPanel + +__all__ = ["RegistrationPanel"] diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py new file mode 100644 index 00000000..75ef0633 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel.py @@ -0,0 +1,761 @@ +"""Registration panel for the ConfUSIus napari plugin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, cast + +import numpy as np +import xarray as xr +from napari.layers.utils.layer_utils import calc_data_range +from napari.qt.threading import thread_worker +from napari.utils.notifications import show_error, show_info +from qtpy.QtWidgets import ( + QApplication, + QButtonGroup, + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QProgressBar, + QPushButton, + QRadioButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from confusius._dims import SPATIAL_DIMS, TIME_DIM +from confusius.plotting.napari import plot_napari +from confusius.registration import register_volume, register_volumewise + +if TYPE_CHECKING: + import napari + import numpy.typing as npt + from napari.layers import Layer + + from confusius.registration import RegistrationDiagnostics + + +def _default_dims_for_ndim(ndim: int) -> tuple[str, ...]: + """Return fallback dimension names for a raw napari layer. + + Parameters + ---------- + ndim : int + Number of array dimensions. + + Returns + ------- + tuple of str + Default dimension names compatible with ConfUSIus conventions when + possible. + """ + defaults: dict[int, tuple[str, ...]] = { + 1: SPATIAL_DIMS[-1:], + 2: SPATIAL_DIMS[-2:], + 3: SPATIAL_DIMS, + 4: (TIME_DIM, *SPATIAL_DIMS), + } + return defaults.get(ndim, tuple(f"dim{i}" for i in range(ndim))) + + +def _normalize_layer_sequence(values: Any, ndim: int, fill: Any) -> list[Any]: + """Return a layer property as a list with length `ndim`. + + Parameters + ---------- + values : Any + Layer property such as `scale`, `translate`, `units`, or + `axis_labels`. + ndim : int + Number of dimensions expected on the layer data. + fill : Any + Value used to pad missing entries. + + Returns + ------- + list of Any + Normalized sequence with exactly `ndim` elements. + """ + if values is None: + return [fill] * ndim + seq = list(values) + if len(seq) < ndim: + return ([fill] * (ndim - len(seq))) + seq + if len(seq) > ndim: + return seq[-ndim:] + return seq + + +def _layer_to_dataarray(layer: "Layer") -> xr.DataArray: + """Return an `xarray.DataArray` view of a napari layer. + + Parameters + ---------- + layer : napari.layers.Layer + Napari layer to convert. + + Returns + ------- + xarray.DataArray + Original ConfUSIus DataArray when present in `layer.metadata`, + otherwise a reconstructed DataArray derived from the layer state. + """ + existing = layer.metadata.get("xarray") + if existing is not None: + return cast("xr.DataArray", existing) + + data = np.asarray(layer.data) + ndim = data.ndim + + raw_labels = _normalize_layer_sequence( + getattr(layer, "axis_labels", None), ndim, None + ) + axis_labels = tuple( + str(label) if label not in (None, "") else default + for label, default in zip( + raw_labels, _default_dims_for_ndim(ndim), strict=False + ) + ) + + scale = [ + float(v) + for v in _normalize_layer_sequence(getattr(layer, "scale", None), ndim, 1.0) + ] + translate = [ + float(v) + for v in _normalize_layer_sequence(getattr(layer, "translate", None), ndim, 0.0) + ] + raw_units = _normalize_layer_sequence(getattr(layer, "units", None), ndim, None) + units = [None if u is None or str(u) == "pixel" else str(u) for u in raw_units] + + coords: dict[str, xr.DataArray] = {} + for dim, n, spacing, origin, unit in zip( + axis_labels, data.shape, scale, translate, units, strict=False + ): + attrs: dict[str, Any] = {"voxdim": abs(spacing)} + if unit is not None: + attrs["units"] = unit + coords[dim] = xr.DataArray( + origin + np.arange(n) * spacing, dims=[dim], attrs=attrs + ) + + return xr.DataArray(data, dims=axis_labels, coords=coords) + + +def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: + """Return image-display kwargs copied from an existing napari layer. + + Parameters + ---------- + layer : napari.layers.Layer + Source layer whose visual settings should be reused when possible. + + Returns + ------- + dict[str, Any] + Keyword arguments suitable for [`plot_napari`][confusius.plotting.plot_napari]. + """ + kwargs: dict[str, Any] = {} + for attr in ("colormap", "gamma", "opacity"): + if hasattr(layer, attr): + kwargs[attr] = getattr(layer, attr) + return kwargs + + +def _run_register_volume( + moving: xr.DataArray, + fixed: xr.DataArray, + *, + transform_type: Literal["translation", "rigid", "affine", "bspline"], + metric: Literal["correlation", "mattes_mi"], + learning_rate: float | Literal["auto"], + number_of_iterations: int, + use_multi_resolution: bool, + resample_interpolation: Literal["linear", "bspline"], +) -> tuple[ + xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics +]: + """Run `register_volume` with GUI-friendly defaults. + + Parameters + ---------- + moving : xarray.DataArray + Moving volume. + fixed : xarray.DataArray + Fixed reference volume. + transform_type : {"translation", "rigid", "affine", "bspline"} + Registration model. + metric : {"correlation", "mattes_mi"} + Similarity metric. + learning_rate : float or {"auto"} + Optimizer learning rate. + number_of_iterations : int + Maximum number of optimizer iterations. + use_multi_resolution : bool + Whether to enable the registration pyramid. + resample_interpolation : {"linear", "bspline"} + Interpolator for the resampled output. + + Returns + ------- + registered : xarray.DataArray + Resampled registered volume. + transform : numpy.ndarray or xarray.DataArray + Estimated transform. + diagnostics : confusius.registration.RegistrationDiagnostics + Optimizer diagnostics. + """ + return register_volume( + moving, + fixed, + transform_type=transform_type, + metric=metric, + learning_rate=learning_rate, + number_of_iterations=number_of_iterations, + use_multi_resolution=use_multi_resolution, + resample=True, + resample_interpolation=resample_interpolation, + show_progress=False, + ) + + +def _run_register_volumewise( + data: xr.DataArray, + *, + reference_time: int, + n_jobs: int, + transform: Literal["translation", "rigid", "affine"], + metric: Literal["correlation", "mattes_mi"], + learning_rate: float | Literal["auto"], + number_of_iterations: int, + use_multi_resolution: bool, + resample_interpolation: Literal["linear", "bspline"], +) -> xr.DataArray: + """Run `register_volumewise` with GUI-friendly defaults. + + Parameters + ---------- + data : xarray.DataArray + Time-series data to motion-correct. + reference_time : int + Reference frame index. + n_jobs : int + Number of joblib workers to use. + transform : {"translation", "rigid", "affine"} + Registration model. + metric : {"correlation", "mattes_mi"} + Similarity metric. + learning_rate : float or {"auto"} + Optimizer learning rate. + number_of_iterations : int + Maximum number of optimizer iterations per frame. + use_multi_resolution : bool + Whether to enable the registration pyramid. + resample_interpolation : {"linear", "bspline"} + Interpolator for the resampled output. + + Returns + ------- + xarray.DataArray + Registered time series. + """ + return register_volumewise( + data, + reference_time=reference_time, + n_jobs=n_jobs, + transform=transform, + metric=metric, + learning_rate=learning_rate, + number_of_iterations=number_of_iterations, + use_multi_resolution=use_multi_resolution, + resample_interpolation=resample_interpolation, + show_progress=False, + ) + + +class RegistrationPanel(QWidget): + """Right-side panel for running registration from napari. + + Parameters + ---------- + viewer : napari.Viewer + The active napari viewer instance. + """ + + def __init__(self, viewer: napari.Viewer) -> None: + super().__init__() + self.viewer = viewer + self._worker = None + self._setup_ui() + self.viewer.layers.events.inserted.connect(self._refresh_layers) + self.viewer.layers.events.removed.connect(self._refresh_layers) + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(8) + + operation_group = QGroupBox("Registration") + operation_layout = QFormLayout(operation_group) + operation_layout.setSpacing(6) + operation_layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) + operation_layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow + ) + + self._mode_group = QButtonGroup(self) + mode_row = QHBoxLayout() + self._single_volume_radio = QRadioButton("Between scans") + self._time_series_radio = QRadioButton("Within scan") + self._single_volume_radio.setChecked(True) + self._mode_group.addButton(self._single_volume_radio) + self._mode_group.addButton(self._time_series_radio) + mode_row.addWidget(self._single_volume_radio) + mode_row.addWidget(self._time_series_radio) + operation_layout.addRow("Mode", mode_row) + + self._moving_combo = QComboBox() + self._moving_combo.setMinimumContentsLength(18) + self._moving_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._moving_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._moving_combo.currentTextChanged.connect(self._on_moving_layer_changed) + operation_layout.addRow("Moving layer", self._moving_combo) + + self._fixed_label = QLabel("Fixed layer") + self._fixed_combo = QComboBox() + self._fixed_combo.setMinimumContentsLength(18) + self._fixed_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._fixed_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + operation_layout.addRow(self._fixed_label, self._fixed_combo) + + self._reference_time_label = QLabel("Ref. time") + self._reference_time_spin = QSpinBox() + self._reference_time_spin.setMinimum(0) + operation_layout.addRow(self._reference_time_label, self._reference_time_spin) + + self._n_jobs_label = QLabel("Jobs") + self._n_jobs_spin = QSpinBox() + self._n_jobs_spin.setRange(-128, 128) + self._n_jobs_spin.setSpecialValueText("auto") + self._n_jobs_spin.setValue(-1) + self._n_jobs_spin.setToolTip( + "Number of workers for time-series registration. -1 uses all CPUs." + ) + operation_layout.addRow(self._n_jobs_label, self._n_jobs_spin) + + layout.addWidget(operation_group) + + params_group = QGroupBox("Parameters") + params_layout = QFormLayout(params_group) + params_layout.setSpacing(6) + params_layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) + params_layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow + ) + + self._transform_combo = QComboBox() + self._transform_combo.setMinimumContentsLength(14) + self._transform_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._transform_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + params_layout.addRow("Transform", self._transform_combo) + + self._metric_combo = QComboBox() + self._metric_combo.setMinimumContentsLength(14) + self._metric_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._metric_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._metric_combo.addItems(["correlation", "mattes_mi"]) + params_layout.addRow("Metric", self._metric_combo) + + self._interpolation_combo = QComboBox() + self._interpolation_combo.setMinimumContentsLength(14) + self._interpolation_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._interpolation_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._interpolation_combo.addItems(["linear", "bspline"]) + params_layout.addRow("Interpolation", self._interpolation_combo) + self._interpolation_combo.setToolTip( + "Interpolator used for the resampled output." + ) + + learning_rate_row = QHBoxLayout() + self._learning_rate_auto_check = QCheckBox("Auto") + self._learning_rate_auto_check.setChecked(True) + self._learning_rate_spin = QDoubleSpinBox() + self._learning_rate_spin.setRange(1e-6, 1e3) + self._learning_rate_spin.setDecimals(4) + self._learning_rate_spin.setSingleStep(0.01) + self._learning_rate_spin.setValue(0.1) + self._learning_rate_spin.setEnabled(False) + learning_rate_row.addWidget(self._learning_rate_auto_check) + learning_rate_row.addWidget(self._learning_rate_spin, stretch=1) + params_layout.addRow("Learning rate", learning_rate_row) + + self._iterations_spin = QSpinBox() + self._iterations_spin.setRange(1, 100_000) + self._iterations_spin.setValue(100) + params_layout.addRow("Iterations", self._iterations_spin) + + self._multi_resolution_check = QCheckBox("Use multi-resolution") + self._multi_resolution_check.setToolTip( + "Run registration from coarse to fine resolution levels." + ) + self._multi_resolution_check.setChecked(False) + params_layout.addRow(self._multi_resolution_check) + + layout.addWidget(params_group) + + self._run_btn = QPushButton("Run registration") + self._run_btn.setObjectName("primary_btn") + self._run_btn.clicked.connect(self._run_registration) + layout.addWidget(self._run_btn) + + self._status = QLabel("") + self._status.setWordWrap(True) + self._status.setObjectName("status_err") + self._status.hide() + layout.addWidget(self._status) + + self._progress = QProgressBar() + self._progress.setRange(0, 0) + self._progress.setMaximumHeight(4) + self._progress.hide() + layout.addWidget(self._progress) + + layout.addStretch() + + self._single_volume_radio.toggled.connect(self._on_mode_changed) + self._time_series_radio.toggled.connect(self._on_mode_changed) + self._learning_rate_auto_check.toggled.connect( + self._learning_rate_spin.setDisabled + ) + + self._refresh_layers() + self._on_mode_changed() + + def _refresh_layers(self) -> None: + """Repopulate the layer selectors from the viewer.""" + moving_name = self._moving_combo.currentText() + fixed_name = self._fixed_combo.currentText() + + layer_names = [layer.name for layer in self.viewer.layers] + + self._moving_combo.blockSignals(True) + self._fixed_combo.blockSignals(True) + self._moving_combo.clear() + self._fixed_combo.clear() + self._moving_combo.addItems(layer_names) + self._fixed_combo.addItems(layer_names) + self._moving_combo.blockSignals(False) + self._fixed_combo.blockSignals(False) + + moving_index = self._moving_combo.findText(moving_name) + if moving_index >= 0: + self._moving_combo.setCurrentIndex(moving_index) + + fixed_index = self._fixed_combo.findText(fixed_name) + if fixed_index >= 0: + self._fixed_combo.setCurrentIndex(fixed_index) + elif ( + self._fixed_combo.count() > 1 + and self._fixed_combo.currentText() == self._moving_combo.currentText() + ): + self._fixed_combo.setCurrentIndex(1) + + self._update_reference_time_bounds() + + def _selected_layer(self, combo: QComboBox) -> Layer | None: + """Return the currently selected viewer layer for a combo box. + + Parameters + ---------- + combo : QComboBox + Combo box containing layer names. + + Returns + ------- + napari.layers.Layer or None + Selected layer, or `None` when no valid selection exists. + """ + name = combo.currentText() + if not name: + return None + try: + return cast("Layer", self.viewer.layers[name]) + except KeyError: + return None + + def _update_reference_time_bounds(self) -> None: + """Clamp the volumewise reference-time widget to the moving layer.""" + moving_layer = self._selected_layer(self._moving_combo) + if moving_layer is None: + self._reference_time_spin.setMaximum(0) + self._reference_time_spin.setValue(0) + return + + data = _layer_to_dataarray(moving_layer) + if TIME_DIM not in data.dims: + self._reference_time_spin.setMaximum(0) + self._reference_time_spin.setValue(0) + return + + self._reference_time_spin.setMaximum(max(0, data.sizes[TIME_DIM] - 1)) + + def _on_moving_layer_changed(self, _name: str) -> None: + """Update dependent widgets when the moving layer changes.""" + self._update_reference_time_bounds() + + def _operation(self) -> Literal["register_volume", "register_volumewise"]: + """Return the currently selected registration workflow.""" + if self._time_series_radio.isChecked(): + return "register_volumewise" + return "register_volume" + + def _on_mode_changed(self) -> None: + """Update the panel when the registration mode changes.""" + is_volumewise = self._operation() == "register_volumewise" + + self._fixed_label.setVisible(not is_volumewise) + self._fixed_combo.setVisible(not is_volumewise) + self._fixed_combo.setEnabled(not is_volumewise) + self._reference_time_label.setVisible(is_volumewise) + self._reference_time_spin.setVisible(is_volumewise) + self._n_jobs_label.setVisible(is_volumewise) + self._n_jobs_spin.setVisible(is_volumewise) + + self._transform_combo.clear() + if is_volumewise: + self._transform_combo.addItems(["translation", "rigid", "affine"]) + else: + self._transform_combo.addItems( + ["translation", "rigid", "affine", "bspline"] + ) + rigid_index = self._transform_combo.findText("rigid") + if rigid_index >= 0: + self._transform_combo.setCurrentIndex(rigid_index) + + self._update_reference_time_bounds() + + def _begin_work(self) -> None: + """Put the panel into its busy state.""" + self._run_btn.setEnabled(False) + self._run_btn.setText("Registering…") + self._status.hide() + self._progress.show() + QApplication.processEvents() + + def _end_work(self) -> None: + """Restore the idle UI state after background work.""" + self._run_btn.setEnabled(True) + self._run_btn.setText("Run registration") + self._progress.hide() + + def _set_error(self, message: str) -> None: + """Show a validation or execution error in the panel. + + Parameters + ---------- + message : str + Error message to display. + """ + self._status.setText(message) + self._status.show() + + def _run_registration(self) -> None: + """Validate inputs and start the selected registration workflow.""" + operation = self._operation() + moving_layer = self._selected_layer(self._moving_combo) + fixed_layer = self._selected_layer(self._fixed_combo) + + if moving_layer is None: + self._set_error("Select a moving layer.") + return + + try: + learning_rate: float | Literal["auto"] + if self._learning_rate_auto_check.isChecked(): + learning_rate = "auto" + else: + learning_rate = float(self._learning_rate_spin.value()) + moving = _layer_to_dataarray(moving_layer) + except Exception as exc: # noqa: BLE001 + self._set_error(str(exc)) + return + + payload: dict[str, Any] = { + "operation": operation, + "moving_layer_name": moving_layer.name, + "transform": self._transform_combo.currentText(), + "metric": self._metric_combo.currentText(), + "learning_rate": learning_rate, + "number_of_iterations": self._iterations_spin.value(), + "use_multi_resolution": self._multi_resolution_check.isChecked(), + "resample_interpolation": self._interpolation_combo.currentText(), + } + + if operation == "register_volume": + if fixed_layer is None: + self._set_error("Select a fixed layer.") + return + if fixed_layer is moving_layer: + self._set_error("Moving and fixed layers must be different.") + return + + try: + fixed = _layer_to_dataarray(fixed_layer) + except Exception as exc: # noqa: BLE001 + self._set_error(str(exc)) + return + + if TIME_DIM in moving.dims or TIME_DIM in fixed.dims: + self._set_error("register_volume requires spatial-only layers.") + return + + payload["fixed_layer_name"] = fixed_layer.name + + worker = thread_worker(_run_register_volume)( + moving, + fixed, + transform_type=cast( + "Literal['translation', 'rigid', 'affine', 'bspline']", + payload["transform"], + ), + metric=cast("Literal['correlation', 'mattes_mi']", payload["metric"]), + learning_rate=learning_rate, + number_of_iterations=payload["number_of_iterations"], + use_multi_resolution=payload["use_multi_resolution"], + resample_interpolation=cast( + "Literal['linear', 'bspline']", payload["resample_interpolation"] + ), + ) + else: + if TIME_DIM not in moving.dims: + self._set_error( + "register_volumewise requires a layer with a time dimension." + ) + return + + payload["reference_time"] = self._reference_time_spin.value() + payload["n_jobs"] = self._n_jobs_spin.value() + + worker = thread_worker(_run_register_volumewise)( + moving, + reference_time=payload["reference_time"], + n_jobs=payload["n_jobs"], + transform=cast( + "Literal['translation', 'rigid', 'affine']", payload["transform"] + ), + metric=cast("Literal['correlation', 'mattes_mi']", payload["metric"]), + learning_rate=learning_rate, + number_of_iterations=payload["number_of_iterations"], + use_multi_resolution=payload["use_multi_resolution"], + resample_interpolation=cast( + "Literal['linear', 'bspline']", payload["resample_interpolation"] + ), + ) + + self._worker = worker + self._begin_work() + worker.returned.connect( + lambda result: self._on_registration_finished(payload, result) + ) + worker.errored.connect(self._on_registration_failed) + worker.finished.connect(self._end_work) + worker.start() + + def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> None: + """Add a successful registration result back to the viewer. + + Parameters + ---------- + payload : dict[str, Any] + UI parameter snapshot captured before the worker started. + result : Any + Worker return value. + """ + operation = cast(str, payload["operation"]) + + if operation == "register_volume": + registered, transform, diagnostics = cast( + "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics]", + result, + ) + registered = registered.copy(deep=False) + registered.attrs = registered.attrs.copy() + registered.attrs["registration_transform"] = transform + registered.attrs["registration_diagnostics"] = diagnostics + registered.attrs["registration_operation"] = operation + layer_name = ( + f"{payload['moving_layer_name']} → {payload['fixed_layer_name']}" + ) + metadata: dict[str, Any] = { + "registration_transform": transform, + "registration_diagnostics": diagnostics, + } + else: + registered = cast("xr.DataArray", result).copy(deep=False) + registered.attrs = registered.attrs.copy() + registered.attrs["registration_operation"] = operation + layer_name = f"{payload['moving_layer_name']} registered" + metadata = { + "motion_params": registered.attrs.get("motion_params"), + "reference_time": payload["reference_time"], + } + + metadata["registration_operation"] = operation + metadata["registration_parameters"] = payload.copy() + + source_layer_name = cast(str, payload["moving_layer_name"]) + try: + source_layer = self.viewer.layers[source_layer_name] + except KeyError: + display_kwargs: dict[str, Any] = {} + else: + display_kwargs = _image_display_kwargs_from_layer(source_layer) + display_kwargs["contrast_limits"] = calc_data_range(registered.data) + + _, layer = plot_napari( + registered, + viewer=self.viewer, + name=layer_name, + show_colorbar=False, + **display_kwargs, + ) + layer.metadata.update(metadata) + layer.metadata["xarray"] = registered + self.viewer.layers.selection.active = layer + show_info(f"Added registered layer: {layer.name}") + + def _on_registration_failed(self, exc: BaseException) -> None: + """Handle a failed worker execution. + + Parameters + ---------- + exc : BaseException + Exception raised by the worker. + """ + self._set_error(str(exc)) + show_error(str(exc)) diff --git a/src/confusius/_napari/_widget.py b/src/confusius/_napari/_widget.py index 466f451a..be198bfc 100644 --- a/src/confusius/_napari/_widget.py +++ b/src/confusius/_napari/_widget.py @@ -445,6 +445,7 @@ def _make_accordion(self) -> QWidget: from confusius._napari._data._load_panel import DataPanel from confusius._napari._data._save_panel import SavePanel from confusius._napari._qc._panel import QCPanel + from confusius._napari._registration._panel import RegistrationPanel from confusius._napari._signals._panel import SignalPanel from confusius._napari._video._video_panel import VideoPanel @@ -470,12 +471,14 @@ def _make_accordion(self) -> QWidget: ("Data I/O", "file-input"), ("Video", "video"), ("Signals", "chart-line"), + ("Registration", "images"), ("Quality Control", "clipboard-check"), ] panels = [ data_panel, video_panel, SignalPanel(self.viewer), + RegistrationPanel(self.viewer), QCPanel(self.viewer), ] btns: list[QPushButton] = [] diff --git a/src/confusius/_napari/assets/images.svg b/src/confusius/_napari/assets/images.svg new file mode 100644 index 00000000..c6af1a80 --- /dev/null +++ b/src/confusius/_napari/assets/images.svg @@ -0,0 +1 @@ + diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py new file mode 100644 index 00000000..5de9ace9 --- /dev/null +++ b/tests/unit/test_napari/test_registration_panel.py @@ -0,0 +1,171 @@ +"""Unit tests for the napari registration panel.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import pytest +import xarray as xr + + +@pytest.fixture +def viewer(make_napari_viewer): + return make_napari_viewer() + + +@pytest.fixture +def registration_panel(viewer): + from confusius._napari._registration._panel import RegistrationPanel + + return RegistrationPanel(viewer) + + +@dataclass(frozen=True) +class _FakeDiagnostics: + metric: str = "correlation" + metric_values: np.ndarray = field(default_factory=lambda: np.array([-1.0])) + final_metric_value: float = -1.0 + n_iterations: int = 1 + stop_condition: str = "done" + + +class TestRefreshLayers: + def test_combo_populated_on_layer_add(self, viewer, registration_panel): + assert registration_panel._moving_combo.count() == 0 + viewer.add_image(np.zeros((4, 6, 8)), name="vol") + assert registration_panel._moving_combo.count() == 1 + assert registration_panel._moving_combo.itemText(0) == "vol" + + +class TestOperationMode: + def test_volumewise_hides_fixed_selector(self, registration_panel): + registration_panel._time_series_radio.setChecked(True) + assert registration_panel._fixed_combo.isHidden() + assert not registration_panel._reference_time_spin.isHidden() + assert not registration_panel._n_jobs_spin.isHidden() + + def test_volume_shows_fixed_selector(self, registration_panel): + registration_panel._time_series_radio.setChecked(True) + registration_panel._single_volume_radio.setChecked(True) + assert not registration_panel._fixed_combo.isHidden() + assert registration_panel._reference_time_spin.isHidden() + assert registration_panel._n_jobs_spin.isHidden() + + def test_defaults_transform_to_rigid(self, registration_panel): + assert registration_panel._transform_combo.currentText() == "rigid" + registration_panel._time_series_radio.setChecked(True) + assert registration_panel._transform_combo.currentText() == "rigid" + + def test_learning_rate_auto_disables_spinbox(self, registration_panel): + assert registration_panel._learning_rate_auto_check.isChecked() + assert not registration_panel._learning_rate_spin.isEnabled() + registration_panel._learning_rate_auto_check.setChecked(False) + assert registration_panel._learning_rate_spin.isEnabled() + + +class TestLayerToDataArray: + def test_reconstructs_dataarray_from_generic_layer(self, viewer): + from confusius._napari._registration._panel import _layer_to_dataarray + + layer = viewer.add_image( + np.zeros((3, 5, 7), dtype=np.float32), + name="plain", + scale=(0.3, 0.2, 0.1), + translate=(1.0, 2.0, 3.0), + ) + layer.axis_labels = ("z", "y", "x") + layer.units = ("mm", "mm", "mm") + + da = _layer_to_dataarray(layer) + + assert da.dims == ("z", "y", "x") + assert da.coords["z"][0] == pytest.approx(1.0) + assert da.coords["y"][1] == pytest.approx(2.2) + assert da.coords["x"][2] == pytest.approx(3.2) + assert da.coords["x"].attrs["units"] in {"mm", "millimeter"} + + +class TestPluginWidget: + def test_registration_panel_is_present_in_main_widget(self, viewer): + from confusius._napari._widget import ConfUSIusWidget + + widget = ConfUSIusWidget(viewer) + + assert "Registration" in widget._accordion_panels + + +class TestFinishedCallbacks: + def test_volume_result_adds_new_layer_with_transform_metadata( + self, viewer, registration_panel + ): + fixed = xr.DataArray( + np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + registered = fixed.copy() + transform = np.eye(3) + diagnostics = _FakeDiagnostics() + + payload = { + "operation": "register_volume", + "moving_layer_name": "moving", + "fixed_layer_name": "fixed", + "transform": "rigid", + "metric": "correlation", + "learning_rate": "auto", + "number_of_iterations": 100, + "use_multi_resolution": False, + "resample_interpolation": "linear", + } + + registration_panel._on_registration_finished( + payload, + (registered, transform, diagnostics), + ) + + layer = viewer.layers["moving → fixed"] + assert layer.metadata["registration_transform"] is transform + assert layer.metadata["registration_diagnostics"] is diagnostics + assert ( + layer.metadata["xarray"].attrs["registration_operation"] + == "register_volume" + ) + + def test_volumewise_result_adds_registered_layer(self, viewer, registration_panel): + registered = xr.DataArray( + np.ones((3, 4, 6), dtype=np.float32), + dims=["time", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(3), dims=["time"]), + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + attrs={"motion_params": object()}, + ) + + payload = { + "operation": "register_volumewise", + "moving_layer_name": "series", + "transform": "rigid", + "metric": "correlation", + "learning_rate": "auto", + "number_of_iterations": 100, + "use_multi_resolution": False, + "resample_interpolation": "linear", + "reference_time": 1, + } + + registration_panel._on_registration_finished(payload, registered) + + layer = viewer.layers["series registered"] + assert layer.metadata["reference_time"] == 1 + assert layer.metadata["registration_operation"] == "register_volumewise" + assert ( + layer.metadata["xarray"].attrs["registration_operation"] + == "register_volumewise" + ) From 473114f5788c82a32a2ffbe5cae9feb93c57785f Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 3 Jun 2026 17:21:07 +0100 Subject: [PATCH 02/72] feat(napari): add transform management --- NAPARI_REGISTRATION_PLAN.md | 19 +- src/confusius/_napari/_registration/_panel.py | 456 +++++++++++++++++- .../_napari/_registration/_transforms.py | 261 ++++++++++ .../test_napari/test_registration_panel.py | 82 ++++ 4 files changed, 807 insertions(+), 11 deletions(-) create mode 100644 src/confusius/_napari/_registration/_transforms.py diff --git a/NAPARI_REGISTRATION_PLAN.md b/NAPARI_REGISTRATION_PLAN.md index de200877..61cc9b0e 100644 --- a/NAPARI_REGISTRATION_PLAN.md +++ b/NAPARI_REGISTRATION_PLAN.md @@ -90,11 +90,22 @@ Store a small provenance payload on the result layer metadata, including: ### Phase 2 -Transform management: +Transform management. -- save/load/apply affine transforms -- stable serialized transform payload owned by ConfUSIus -- better provenance for manual vs optimized transforms +#### Implemented + +- Save/load/apply affine transforms from the registration panel. +- Stable ConfUSIus-owned JSON payload for affine transforms. +- Human-friendly transform names in the payload. +- Output-grid metadata stored with the transform so a saved transform can be + reapplied later without reloading the original fixed/reference layer. +- Affine registration results store a reusable transform payload in layer metadata. + +#### Remaining polish + +- Better internal layout for the registration tab as it grows. +- Unified payload support for manual napari-created transforms. +- Optional support for non-affine transform payloads in the future. ### Phase 3 diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 75ef0633..1402e19c 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast import numpy as np @@ -15,6 +16,7 @@ QCheckBox, QComboBox, QDoubleSpinBox, + QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, @@ -29,8 +31,16 @@ ) from confusius._dims import SPATIAL_DIMS, TIME_DIM +from confusius._napari._registration._transforms import ( + AffineTransformPayload, + affine_transform_from_payload, + load_affine_transform_payload, + make_affine_transform_payload, + output_grid_from_payload, + save_affine_transform_payload, +) from confusius.plotting.napari import plot_napari -from confusius.registration import register_volume, register_volumewise +from confusius.registration import register_volume, register_volumewise, resample_volume if TYPE_CHECKING: import napari @@ -224,6 +234,26 @@ def _run_register_volume( ) +def _affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: + """Return the stored affine transform payload for a napari layer. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose metadata should be inspected. + + Returns + ------- + AffineTransformPayload or None + Stored payload when present and affine, otherwise `None`. + """ + payload = layer.metadata.get("confusius_transform") + if not isinstance(payload, dict) or payload.get("kind") != "affine": + return None + affine_transform_from_payload(payload) + return cast("AffineTransformPayload", payload) + + def _run_register_volumewise( data: xr.DataArray, *, @@ -291,6 +321,7 @@ def __init__(self, viewer: napari.Viewer) -> None: super().__init__() self.viewer = viewer self._worker = None + self._loaded_transform_payload: AffineTransformPayload | None = None self._setup_ui() self.viewer.layers.events.inserted.connect(self._refresh_layers) self.viewer.layers.events.removed.connect(self._refresh_layers) @@ -300,6 +331,44 @@ def _setup_ui(self) -> None: layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(8) + self._panel_group = QButtonGroup(self) + panel_row = QHBoxLayout() + panel_row.setSpacing(0) + self._register_panel_radio = QPushButton("Register") + self._register_panel_radio.setCheckable(True) + self._transforms_panel_radio = QPushButton("Transforms") + self._transforms_panel_radio.setCheckable(True) + self._register_panel_radio.setChecked(True) + self._panel_group.addButton(self._register_panel_radio) + self._panel_group.addButton(self._transforms_panel_radio) + self._register_panel_radio.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._transforms_panel_radio.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + _segment_btn_style = """ + QPushButton { + border-radius: 0; + } + QPushButton:checked { + background: #e94b5f; + color: white; + font-weight: bold; + } + """ + self._register_panel_radio.setStyleSheet( + _segment_btn_style + + "border-top-right-radius: 0; border-bottom-right-radius: 0;" + ) + self._transforms_panel_radio.setStyleSheet( + _segment_btn_style + + "border-top-left-radius: 0; border-bottom-left-radius: 0;" + ) + panel_row.addWidget(self._register_panel_radio) + panel_row.addWidget(self._transforms_panel_radio) + layout.addLayout(panel_row) + operation_group = QGroupBox("Registration") operation_layout = QFormLayout(operation_group) operation_layout.setSpacing(6) @@ -311,7 +380,7 @@ def _setup_ui(self) -> None: self._mode_group = QButtonGroup(self) mode_row = QHBoxLayout() self._single_volume_radio = QRadioButton("Between scans") - self._time_series_radio = QRadioButton("Within scan") + self._time_series_radio = QRadioButton("Within-scan") self._single_volume_radio.setChecked(True) self._mode_group.addButton(self._single_volume_radio) self._mode_group.addButton(self._time_series_radio) @@ -319,6 +388,7 @@ def _setup_ui(self) -> None: mode_row.addWidget(self._time_series_radio) operation_layout.addRow("Mode", mode_row) + self._moving_label = QLabel("Moving layer") self._moving_combo = QComboBox() self._moving_combo.setMinimumContentsLength(18) self._moving_combo.setSizeAdjustPolicy( @@ -328,7 +398,7 @@ def _setup_ui(self) -> None: QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self._moving_combo.currentTextChanged.connect(self._on_moving_layer_changed) - operation_layout.addRow("Moving layer", self._moving_combo) + operation_layout.addRow(self._moving_label, self._moving_combo) self._fixed_label = QLabel("Fixed layer") self._fixed_combo = QComboBox() @@ -356,7 +426,11 @@ def _setup_ui(self) -> None: ) operation_layout.addRow(self._n_jobs_label, self._n_jobs_spin) - layout.addWidget(operation_group) + self._layer_validation = QLabel("") + self._layer_validation.setWordWrap(True) + self._layer_validation.setObjectName("status_err") + self._layer_validation.hide() + operation_layout.addRow(self._layer_validation) params_group = QGroupBox("Parameters") params_layout = QFormLayout(params_group) @@ -426,12 +500,65 @@ def _setup_ui(self) -> None: self._multi_resolution_check.setChecked(False) params_layout.addRow(self._multi_resolution_check) - layout.addWidget(params_group) + self._register_panel = QWidget() + register_layout = QVBoxLayout(self._register_panel) + register_layout.setContentsMargins(0, 0, 0, 0) + register_layout.setSpacing(8) + register_layout.addWidget(operation_group) + register_layout.addWidget(params_group) self._run_btn = QPushButton("Run registration") self._run_btn.setObjectName("primary_btn") self._run_btn.clicked.connect(self._run_registration) - layout.addWidget(self._run_btn) + register_layout.addWidget(self._run_btn) + + layout.addWidget(self._register_panel) + + transforms_group = QGroupBox("Transforms") + transforms_group.setToolTip( + "Save, load, and reapply affine transforms estimated from between-scan registration." + ) + transforms_layout = QFormLayout(transforms_group) + transforms_layout.setSpacing(6) + transforms_layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) + transforms_layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow + ) + + self._transform_source_combo = QComboBox() + self._transform_source_combo.setMinimumContentsLength(18) + self._transform_source_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._transform_source_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + transforms_layout.addRow("Transform", self._transform_source_combo) + + self._transform_target_combo = QComboBox() + self._transform_target_combo.setMinimumContentsLength(18) + self._transform_target_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._transform_target_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + transforms_layout.addRow("Input layer", self._transform_target_combo) + + transform_buttons = QHBoxLayout() + self._save_transform_btn = QPushButton("Save") + self._save_transform_btn.clicked.connect(self._save_transform) + self._load_transform_btn = QPushButton("Load") + self._load_transform_btn.clicked.connect(self._load_transform) + self._apply_transform_btn = QPushButton("Apply") + self._apply_transform_btn.clicked.connect(self._apply_transform) + transform_buttons.addWidget(self._save_transform_btn) + transform_buttons.addWidget(self._load_transform_btn) + transform_buttons.addWidget(self._apply_transform_btn) + transforms_layout.addRow(transform_buttons) + + self._transforms_panel = transforms_group + layout.addWidget(self._transforms_panel) self._status = QLabel("") self._status.setWordWrap(True) @@ -447,13 +574,19 @@ def _setup_ui(self) -> None: layout.addStretch() + self._register_panel_radio.toggled.connect(self._on_panel_changed) + self._transforms_panel_radio.toggled.connect(self._on_panel_changed) self._single_volume_radio.toggled.connect(self._on_mode_changed) self._time_series_radio.toggled.connect(self._on_mode_changed) + self._fixed_combo.currentTextChanged.connect( + self._validate_registration_selection + ) self._learning_rate_auto_check.toggled.connect( self._learning_rate_spin.setDisabled ) self._refresh_layers() + self._on_panel_changed() self._on_mode_changed() def _refresh_layers(self) -> None: @@ -486,6 +619,8 @@ def _refresh_layers(self) -> None: self._fixed_combo.setCurrentIndex(1) self._update_reference_time_bounds() + self._refresh_transform_controls() + self._validate_registration_selection() def _selected_layer(self, combo: QComboBox) -> Layer | None: """Return the currently selected viewer layer for a combo box. @@ -508,6 +643,74 @@ def _selected_layer(self, combo: QComboBox) -> Layer | None: except KeyError: return None + def _transform_source_label( + self, payload: AffineTransformPayload, *, suffix: str | None = None + ) -> str: + """Return a user-facing label for a transform payload.""" + label = payload["name"] + if suffix: + label = f"{label} — {suffix}" + return label + + def _refresh_transform_controls(self) -> None: + """Refresh transform-related layer selectors.""" + source_data = self._transform_source_combo.currentData() + target_name = self._transform_target_combo.currentText() + + self._transform_source_combo.blockSignals(True) + self._transform_source_combo.clear() + if self._loaded_transform_payload is not None: + self._transform_source_combo.addItem( + self._transform_source_label( + self._loaded_transform_payload, + suffix="loaded", + ), + ("loaded", ""), + ) + for layer in self.viewer.layers: + payload = _affine_payload_from_layer(layer) + if payload is None: + continue + self._transform_source_combo.addItem( + self._transform_source_label(payload, suffix=layer.name), + ("layer", layer.name), + ) + self._transform_source_combo.blockSignals(False) + + self._transform_target_combo.blockSignals(True) + self._transform_target_combo.clear() + self._transform_target_combo.addItems( + [layer.name for layer in self.viewer.layers] + ) + self._transform_target_combo.blockSignals(False) + + if source_data is not None: + for i in range(self._transform_source_combo.count()): + if self._transform_source_combo.itemData(i) == source_data: + self._transform_source_combo.setCurrentIndex(i) + break + + target_index = self._transform_target_combo.findText(target_name) + if target_index >= 0: + self._transform_target_combo.setCurrentIndex(target_index) + + def _selected_transform_payload(self) -> AffineTransformPayload | None: + """Return the currently selected affine transform payload.""" + source_data = self._transform_source_combo.currentData() + if not isinstance(source_data, tuple) or len(source_data) != 2: + return None + + source_kind, source_name = source_data + if source_kind == "loaded": + return self._loaded_transform_payload + if source_kind != "layer" or not source_name: + return None + try: + layer = cast("Layer", self.viewer.layers[source_name]) + except KeyError: + return None + return _affine_payload_from_layer(layer) + def _update_reference_time_bounds(self) -> None: """Clamp the volumewise reference-time widget to the moving layer.""" moving_layer = self._selected_layer(self._moving_combo) @@ -524,9 +727,101 @@ def _update_reference_time_bounds(self) -> None: self._reference_time_spin.setMaximum(max(0, data.sizes[TIME_DIM] - 1)) + def _set_layer_validation_style( + self, + *, + moving_invalid: bool = False, + fixed_invalid: bool = False, + message: str | None = None, + ) -> None: + """Update inline validation state for the layer selectors.""" + error_style = "border: 1px solid #e05555;" + normal_style = "" + self._moving_combo.setStyleSheet( + error_style if moving_invalid else normal_style + ) + self._fixed_combo.setStyleSheet(error_style if fixed_invalid else normal_style) + self._moving_label.setStyleSheet("color: #e05555;" if moving_invalid else "") + self._fixed_label.setStyleSheet("color: #e05555;" if fixed_invalid else "") + self._reference_time_label.setStyleSheet("") + self._n_jobs_label.setStyleSheet("") + if message: + self._layer_validation.setText(message) + self._layer_validation.show() + else: + self._layer_validation.hide() + self._layer_validation.clear() + + def _validate_registration_selection(self) -> bool: + """Validate the current registration-layer selection and show inline feedback.""" + moving_layer = self._selected_layer(self._moving_combo) + fixed_layer = self._selected_layer(self._fixed_combo) + operation = self._operation() + + if moving_layer is None: + self._set_layer_validation_style() + return True + + try: + moving = _layer_to_dataarray(moving_layer) + except Exception: + self._set_layer_validation_style( + moving_invalid=True, + message="Could not read the selected moving layer.", + ) + return False + + if operation == "register_volumewise": + if TIME_DIM not in moving.dims: + self._set_layer_validation_style( + moving_invalid=True, + message="Within-scan registration requires a layer with a time dimension.", + ) + return False + self._set_layer_validation_style() + return True + + moving_invalid = TIME_DIM in moving.dims + fixed_invalid = False + message: str | None = None + + if fixed_layer is None: + self._set_layer_validation_style( + moving_invalid=moving_invalid, + fixed_invalid=True, + message="Between-scans registration requires different moving and fixed layers.", + ) + return False + + try: + fixed = _layer_to_dataarray(fixed_layer) + except Exception: + self._set_layer_validation_style( + fixed_invalid=True, + message="Could not read the selected fixed layer.", + ) + return False + + if fixed_layer is moving_layer: + moving_invalid = True + fixed_invalid = True + message = "Moving and fixed layers must be different." + elif TIME_DIM in moving.dims or TIME_DIM in fixed.dims: + moving_invalid = TIME_DIM in moving.dims + fixed_invalid = TIME_DIM in fixed.dims + message = "Between-scans registration requires spatial-only layers." + + self._set_layer_validation_style( + moving_invalid=moving_invalid, + fixed_invalid=fixed_invalid, + message=message, + ) + return not (moving_invalid or fixed_invalid) + def _on_moving_layer_changed(self, _name: str) -> None: """Update dependent widgets when the moving layer changes.""" self._update_reference_time_bounds() + self._validate_registration_selection() def _operation(self) -> Literal["register_volume", "register_volumewise"]: """Return the currently selected registration workflow.""" @@ -534,6 +829,12 @@ def _operation(self) -> Literal["register_volume", "register_volumewise"]: return "register_volumewise" return "register_volume" + def _on_panel_changed(self) -> None: + """Switch between the register and transforms subpanels.""" + show_register = self._register_panel_radio.isChecked() + self._register_panel.setVisible(show_register) + self._transforms_panel.setVisible(not show_register) + def _on_mode_changed(self) -> None: """Update the panel when the registration mode changes.""" is_volumewise = self._operation() == "register_volumewise" @@ -558,6 +859,7 @@ def _on_mode_changed(self) -> None: self._transform_combo.setCurrentIndex(rigid_index) self._update_reference_time_bounds() + self._validate_registration_selection() def _begin_work(self) -> None: """Put the panel into its busy state.""" @@ -584,6 +886,98 @@ def _set_error(self, message: str) -> None: self._status.setText(message) self._status.show() + def _save_transform(self) -> None: + """Save the selected affine transform payload to JSON.""" + payload = self._selected_transform_payload() + if payload is None: + self._set_error("Select an affine transform to save.") + return + + default_name = payload["name"].replace("/", "-") + start = str(Path.home() / f"{default_name}.json") + path_str, _ = QFileDialog.getSaveFileName( + self, + "Save transform", + start, + "JSON files (*.json)", + ) + if not path_str: + return + + save_affine_transform_payload(path_str, payload) + show_info(f"Saved transform: {path_str}") + + def _load_transform(self) -> None: + """Load an affine transform payload from JSON.""" + start = str(Path.home()) + path_str, _ = QFileDialog.getOpenFileName( + self, + "Load transform", + start, + "JSON files (*.json)", + ) + if not path_str: + return + + try: + self._loaded_transform_payload = load_affine_transform_payload(path_str) + except Exception as exc: # noqa: BLE001 + self._set_error(str(exc)) + show_error(str(exc)) + return + + self._refresh_transform_controls() + for i in range(self._transform_source_combo.count()): + if self._transform_source_combo.itemData(i) == ("loaded", ""): + self._transform_source_combo.setCurrentIndex(i) + break + show_info(f"Loaded transform: {self._loaded_transform_payload['name']}") + + def _apply_transform(self) -> None: + """Apply the selected affine transform to a layer.""" + payload = self._selected_transform_payload() + if payload is None: + self._set_error("Select an affine transform to apply.") + return + + moving_layer = self._selected_layer(self._transform_target_combo) + if moving_layer is None: + self._set_error("Select an input layer to transform.") + return + + try: + moving = _layer_to_dataarray(moving_layer) + affine = affine_transform_from_payload(payload) + output_grid = output_grid_from_payload(payload) + except Exception as exc: # noqa: BLE001 + self._set_error(str(exc)) + return + + worker = thread_worker(resample_volume)( + moving, + affine, + shape=output_grid["shape"], + spacing=output_grid["spacing"], + origin=output_grid["origin"], + dims=output_grid["dims"], + interpolation=cast( + "Literal['linear', 'bspline']", self._interpolation_combo.currentText() + ), + ) + apply_payload = { + "moving_layer_name": moving_layer.name, + "target_layer_name": payload["target_layer_name"], + "transform_source": payload["name"], + } + self._worker = worker + self._begin_work() + worker.returned.connect( + lambda result: self._on_apply_transform_finished(apply_payload, result) + ) + worker.errored.connect(self._on_registration_failed) + worker.finished.connect(self._end_work) + worker.start() + def _run_registration(self) -> None: """Validate inputs and start the selected registration workflow.""" operation = self._operation() @@ -593,6 +987,8 @@ def _run_registration(self) -> None: if moving_layer is None: self._set_error("Select a moving layer.") return + if not self._validate_registration_selection(): + return try: learning_rate: float | Literal["auto"] @@ -715,6 +1111,18 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non "registration_transform": transform, "registration_diagnostics": diagnostics, } + if isinstance(transform, np.ndarray): + affine_transform = np.asarray(transform, dtype=float) + metadata["confusius_transform"] = make_affine_transform_payload( + affine_transform, + reference=registered, + source_layer_name=cast(str, payload["moving_layer_name"]), + target_layer_name=cast(str, payload["fixed_layer_name"]), + operation=operation, + transform_model=cast(str, payload["transform"]), + metric=cast(str, payload["metric"]), + diagnostics=diagnostics, + ) else: registered = cast("xr.DataArray", result).copy(deep=False) registered.attrs = registered.attrs.copy() @@ -735,20 +1143,54 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non display_kwargs: dict[str, Any] = {} else: display_kwargs = _image_display_kwargs_from_layer(source_layer) - display_kwargs["contrast_limits"] = calc_data_range(registered.data) + contrast_limits = tuple(calc_data_range(registered.data)) _, layer = plot_napari( registered, viewer=self.viewer, name=layer_name, show_colorbar=False, + contrast_limits=contrast_limits, **display_kwargs, ) layer.metadata.update(metadata) layer.metadata["xarray"] = registered self.viewer.layers.selection.active = layer + self._refresh_transform_controls() show_info(f"Added registered layer: {layer.name}") + def _on_apply_transform_finished( + self, payload: dict[str, str], result: xr.DataArray + ) -> None: + """Add a resampled layer produced from an existing affine transform. + + Parameters + ---------- + payload : dict[str, str] + UI snapshot captured before the worker started. + result : xarray.DataArray + Resampled output. + """ + registered = result.copy(deep=False) + registered.attrs = registered.attrs.copy() + registered.attrs["registration_operation"] = "apply_transform" + + layer_name = f"{payload['moving_layer_name']} → {payload['target_layer_name']}" + contrast_limits = tuple(calc_data_range(registered.data)) + + _, layer = plot_napari( + registered, + viewer=self.viewer, + name=layer_name, + show_colorbar=False, + contrast_limits=contrast_limits, + ) + layer.metadata["xarray"] = registered + layer.metadata["registration_operation"] = "apply_transform" + layer.metadata["registration_parameters"] = payload.copy() + self.viewer.layers.selection.active = layer + show_info(f"Added transformed layer: {layer.name}") + def _on_registration_failed(self, exc: BaseException) -> None: """Handle a failed worker execution. diff --git a/src/confusius/_napari/_registration/_transforms.py b/src/confusius/_napari/_registration/_transforms.py new file mode 100644 index 00000000..aa6e30ce --- /dev/null +++ b/src/confusius/_napari/_registration/_transforms.py @@ -0,0 +1,261 @@ +"""Affine transform helpers for the napari registration panel.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Literal, SupportsFloat, SupportsIndex, TypedDict, cast + +import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from collections.abc import Mapping + + import xarray as xr + + from confusius.registration import RegistrationDiagnostics + + +class TransformDiagnosticsPayload(TypedDict): + """JSON-serializable registration diagnostics summary.""" + + metric: str + final_metric_value: float + n_iterations: int + stop_condition: str + + +class OutputGridPayload(TypedDict): + """JSON-serializable resampling grid description.""" + + dims: list[str] + shape: list[int] + spacing: list[float] + origin: list[float] + units: list[str | None] + + +class AffineTransformPayload(TypedDict): + """JSON-serializable affine transform payload used by the napari plugin.""" + + kind: Literal["affine"] + name: str + affine: list[list[float]] + source_layer_name: str + target_layer_name: str + operation: str + transform_model: str + metric: str + output_grid: OutputGridPayload + diagnostics: TransformDiagnosticsPayload + + +def make_output_grid_payload(reference: "xr.DataArray") -> OutputGridPayload: + """Return the resampling grid defined by a reference DataArray. + + Parameters + ---------- + reference : xarray.DataArray + Spatial DataArray defining the output grid. + + Returns + ------- + OutputGridPayload + JSON-serializable output-grid description. + """ + dims = [str(dim) for dim in reference.dims] + return { + "dims": dims, + "shape": [int(reference.sizes[dim]) for dim in dims], + "spacing": [float(reference.fusi.spacing[dim]) for dim in dims], + "origin": [float(reference.fusi.origin[dim]) for dim in dims], + "units": [ + cast("str | None", reference.coords[dim].attrs.get("units")) + if dim in reference.coords + else None + for dim in dims + ], + } + + +def make_affine_transform_payload( + affine: npt.NDArray[np.floating], + *, + reference: "xr.DataArray", + source_layer_name: str, + target_layer_name: str, + operation: str, + transform_model: str, + metric: str, + diagnostics: "RegistrationDiagnostics", + name: str | None = None, +) -> AffineTransformPayload: + """Build a JSON-serializable payload for a registered affine transform. + + Parameters + ---------- + affine : (N+1, N+1) numpy.ndarray + Affine transform in homogeneous coordinates. + reference : xarray.DataArray + Fixed/reference DataArray defining the output resampling grid. + source_layer_name : str + Name of the moving/source layer used when estimating the transform. + target_layer_name : str + Name of the fixed/target layer used when estimating the transform. + operation : str + Registration operation that produced the transform. + transform_model : str + Transform model used during registration. + metric : str + Similarity metric used during registration. + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics. + name : str, optional + Human-friendly transform name. If not provided, a default name is generated. + + Returns + ------- + AffineTransformPayload + JSON-serializable transform payload. + """ + affine = np.asarray(affine, dtype=float) + payload_name = ( + name or f"{source_layer_name} → {target_layer_name} ({transform_model})" + ) + return { + "kind": "affine", + "name": payload_name, + "affine": affine.tolist(), + "source_layer_name": source_layer_name, + "target_layer_name": target_layer_name, + "operation": operation, + "transform_model": transform_model, + "metric": metric, + "output_grid": make_output_grid_payload(reference), + "diagnostics": { + "metric": diagnostics.metric, + "final_metric_value": float(diagnostics.final_metric_value), + "n_iterations": int(diagnostics.n_iterations), + "stop_condition": diagnostics.stop_condition, + }, + } + + +def affine_transform_from_payload( + payload: "Mapping[str, object]", +) -> npt.NDArray[np.float64]: + """Return the affine matrix stored in a payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or JSON. + + Returns + ------- + (N+1, N+1) numpy.ndarray + Affine matrix. + + Raises + ------ + ValueError + If the payload is not an affine transform payload. + """ + if payload.get("kind") != "affine": + raise ValueError("Transform payload is not an affine transform.") + + affine = np.asarray(payload.get("affine"), dtype=float) + if affine.ndim != 2 or affine.shape[0] != affine.shape[1] or affine.shape[0] < 3: + raise ValueError( + "Affine payload must contain a square homogeneous matrix of shape " + "(N+1, N+1)." + ) + return affine + + +def output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: + """Return the output grid stored in a transform payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or JSON. + + Returns + ------- + OutputGridPayload + Output-grid description stored in the payload. + + Raises + ------ + ValueError + If the payload does not carry a valid output grid. + """ + grid = payload.get("output_grid") + if not isinstance(grid, dict): + raise ValueError("Transform payload does not contain an output grid.") + + grid_dict = cast("dict[str, object]", grid) + dims = grid_dict.get("dims") + shape = grid_dict.get("shape") + spacing = grid_dict.get("spacing") + origin = grid_dict.get("origin") + units = grid_dict.get("units") + if not all(isinstance(v, list) for v in (dims, shape, spacing, origin, units)): + raise ValueError("Transform payload output grid is malformed.") + + dims_list = cast("list[object]", dims) + shape_list = cast("list[SupportsIndex]", shape) + spacing_list = cast("list[SupportsFloat]", spacing) + origin_list = cast("list[SupportsFloat]", origin) + units_list = cast("list[object]", units) + + return { + "dims": [str(v) for v in dims_list], + "shape": [int(v) for v in shape_list], + "spacing": [float(v) for v in spacing_list], + "origin": [float(v) for v in origin_list], + "units": [None if v is None else str(v) for v in units_list], + } + + +def save_affine_transform_payload( + path: str | Path, payload: AffineTransformPayload +) -> None: + """Save an affine transform payload as JSON. + + Parameters + ---------- + path : str or pathlib.Path + Output JSON path. + payload : AffineTransformPayload + Transform payload to save. + """ + Path(path).write_text(json.dumps(payload, indent=2) + "\n") + + +def load_affine_transform_payload(path: str | Path) -> AffineTransformPayload: + """Load an affine transform payload from JSON. + + Parameters + ---------- + path : str or pathlib.Path + Input JSON path. + + Returns + ------- + AffineTransformPayload + Loaded payload. + + Raises + ------ + ValueError + If the file does not contain an affine transform payload. + """ + payload = json.loads(Path(path).read_text()) + if not isinstance(payload, dict): + raise ValueError("Transform file must contain a JSON object.") + affine_transform_from_payload(payload) + output_grid_from_payload(payload) + return cast("AffineTransformPayload", payload) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 5de9ace9..a89cdb99 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -8,6 +8,14 @@ import pytest import xarray as xr +from confusius._napari._registration._transforms import ( + affine_transform_from_payload, + load_affine_transform_payload, + make_affine_transform_payload, + output_grid_from_payload, + save_affine_transform_payload, +) + @pytest.fixture def viewer(make_napari_viewer): @@ -39,6 +47,16 @@ def test_combo_populated_on_layer_add(self, viewer, registration_panel): class TestOperationMode: + def test_panel_switch_shows_one_subpanel(self, registration_panel): + assert registration_panel._register_panel_radio.isCheckable() + assert registration_panel._transforms_panel_radio.isCheckable() + assert not registration_panel._register_panel.isHidden() + assert registration_panel._transforms_panel.isHidden() + registration_panel._transforms_panel_radio.setChecked(True) + registration_panel._on_panel_changed() + assert registration_panel._register_panel.isHidden() + assert not registration_panel._transforms_panel.isHidden() + def test_volumewise_hides_fixed_selector(self, registration_panel): registration_panel._time_series_radio.setChecked(True) assert registration_panel._fixed_combo.isHidden() @@ -64,6 +82,35 @@ def test_learning_rate_auto_disables_spinbox(self, registration_panel): assert registration_panel._learning_rate_spin.isEnabled() +class TestValidation: + def test_same_moving_and_fixed_is_flagged(self, viewer, registration_panel): + viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="same") + registration_panel._refresh_layers() + registration_panel._moving_combo.setCurrentText("same") + registration_panel._fixed_combo.setCurrentText("same") + + assert not registration_panel._validate_registration_selection() + assert not registration_panel._layer_validation.isHidden() + assert "must be different" in registration_panel._layer_validation.text() + + def test_between_scans_with_single_layer_flags_fixed(self, viewer, registration_panel): + viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="only") + registration_panel._refresh_layers() + registration_panel._moving_combo.setCurrentText("only") + + assert not registration_panel._validate_registration_selection() + assert "must be different" in registration_panel._layer_validation.text() + + def test_within_scan_requires_time_dimension(self, viewer, registration_panel): + viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="vol") + registration_panel._refresh_layers() + registration_panel._time_series_radio.setChecked(True) + registration_panel._moving_combo.setCurrentText("vol") + + assert not registration_panel._validate_registration_selection() + assert "Within-scan registration requires" in registration_panel._layer_validation.text() + + class TestLayerToDataArray: def test_reconstructs_dataarray_from_generic_layer(self, viewer): from confusius._napari._registration._panel import _layer_to_dataarray @@ -86,6 +133,37 @@ def test_reconstructs_dataarray_from_generic_layer(self, viewer): assert da.coords["x"].attrs["units"] in {"mm", "millimeter"} +class TestTransforms: + def test_affine_payload_roundtrip(self, tmp_path): + reference = xr.DataArray( + np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + payload = make_affine_transform_payload( + np.eye(3), + reference=reference, + source_layer_name="moving", + target_layer_name="fixed", + operation="register_volume", + transform_model="rigid", + metric="correlation", + diagnostics=_FakeDiagnostics(), + ) + + path = tmp_path / "transform.json" + save_affine_transform_payload(path, payload) + loaded = load_affine_transform_payload(path) + + assert loaded["source_layer_name"] == "moving" + assert loaded["name"] == "moving → fixed (rigid)" + assert output_grid_from_payload(loaded)["shape"] == [4, 6] + np.testing.assert_array_equal(affine_transform_from_payload(loaded), np.eye(3)) + + class TestPluginWidget: def test_registration_panel_is_present_in_main_widget(self, viewer): from confusius._napari._widget import ConfUSIusWidget @@ -131,6 +209,10 @@ def test_volume_result_adds_new_layer_with_transform_metadata( layer = viewer.layers["moving → fixed"] assert layer.metadata["registration_transform"] is transform assert layer.metadata["registration_diagnostics"] is diagnostics + np.testing.assert_array_equal( + affine_transform_from_payload(layer.metadata["confusius_transform"]), + transform, + ) assert ( layer.metadata["xarray"].attrs["registration_operation"] == "register_volume" From 9737d4746ac73c0e5b000d51a4bb670d1778ca1f Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 25 Jun 2026 14:08:11 +0100 Subject: [PATCH 03/72] feat(registration): napari progress overlay for register_volume Add a napari-native progress reporter that streams the resampled moving image into a live Image layer, complementing the existing matplotlib plotter. - register_volume gains a progress_plotter factory argument; when show_progress=True, the factory is used in place of the matplotlib default. The matplotlib path is unchanged. - New NapariVolumeProgress plotter + NapariProgressBridge (Qt signal bridge) live in confusius._napari._registration._progress. The bridge routes per-iteration resampled arrays from the worker thread to the GUI thread via a queued connection, so the layer can be mutated safely. - RegistrationPanel._setup_volume_progress wires the panel: the fixed layer is tinted red, the moving layer is tinted cyan and hidden, and the preview is seeded with the moving image resampled onto the fixed grid (identity transform) so the first frame is a meaningful unaligned view. Iterations overwrite the preview in place. On teardown, the moving layer stays hidden and the preview is removed so the final result layer replaces it without leaving duplicates. --- src/confusius/_napari/_registration/_panel.py | 217 +++++++++++++++- .../_napari/_registration/_progress.py | 231 ++++++++++++++++++ src/confusius/registration/__init__.py | 6 +- src/confusius/registration/_progress.py | 142 ++++++++--- src/confusius/registration/volume.py | 27 +- .../test_napari/test_registration_panel.py | 146 ++++++++++- .../test_napari/test_registration_progress.py | 191 +++++++++++++++ 7 files changed, 921 insertions(+), 39 deletions(-) create mode 100644 src/confusius/_napari/_registration/_progress.py create mode 100644 tests/unit/test_napari/test_registration_progress.py diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 1402e19c..820ec824 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast @@ -31,6 +32,10 @@ ) from confusius._dims import SPATIAL_DIMS, TIME_DIM +from confusius._napari._registration._progress import ( + NapariProgressBridge, + make_napari_progress_factory, +) from confusius._napari._registration._transforms import ( AffineTransformPayload, affine_transform_from_payload, @@ -40,14 +45,19 @@ save_affine_transform_payload, ) from confusius.plotting.napari import plot_napari -from confusius.registration import register_volume, register_volumewise, resample_volume +from confusius.registration import ( + register_volume, + register_volumewise, + resample_like, + resample_volume, +) if TYPE_CHECKING: import napari import numpy.typing as npt - from napari.layers import Layer + from napari.layers import Image, Layer - from confusius.registration import RegistrationDiagnostics + from confusius.registration import RegistrationDiagnostics, RegistrationProgress def _default_dims_for_ndim(ndim: int) -> tuple[str, ...]: @@ -187,6 +197,7 @@ def _run_register_volume( number_of_iterations: int, use_multi_resolution: bool, resample_interpolation: Literal["linear", "bspline"], + progress_plotter: Callable[..., RegistrationProgress] | None = None, ) -> tuple[ xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics ]: @@ -210,6 +221,9 @@ def _run_register_volume( Whether to enable the registration pyramid. resample_interpolation : {"linear", "bspline"} Interpolator for the resampled output. + progress_plotter : callable, optional + Optional progress-plotter factory forwarded to `register_volume`. When + not provided, no live progress is shown. Returns ------- @@ -230,7 +244,8 @@ def _run_register_volume( use_multi_resolution=use_multi_resolution, resample=True, resample_interpolation=resample_interpolation, - show_progress=False, + show_progress=progress_plotter is not None, + progress_plotter=progress_plotter, ) @@ -322,6 +337,13 @@ def __init__(self, viewer: napari.Viewer) -> None: self.viewer = viewer self._worker = None self._loaded_transform_payload: AffineTransformPayload | None = None + # Per-run progress state. Set on the GUI thread before the worker starts. + self._progress_bridge: NapariProgressBridge | None = None + self._progress_layer: Image | None = None + # Moving layer hidden during the run so the resampled preview can + # overlay the fixed layer without a duplicate moving/final-image + # overlap. Visibility is not restored on teardown. + self._progress_hidden_layer: Image | None = None self._setup_ui() self.viewer.layers.events.inserted.connect(self._refresh_layers) self.viewer.layers.events.removed.connect(self._refresh_layers) @@ -869,6 +891,170 @@ def _begin_work(self) -> None: self._progress.show() QApplication.processEvents() + def _setup_volume_progress( + self, + *, + moving_layer: "Image", + fixed_layer: "Image", + fixed: xr.DataArray, + layer_name: str, + ) -> "Callable[..., RegistrationProgress] | None": + """Build a napari progress bridge and preview layer for register_volume. + + Creates an empty image layer on the fixed grid (with the final target + name) and wires a + [`NapariProgressBridge`][confusius._napari._registration._progress.NapariProgressBridge] + so that every iteration of SimpleITK's optimizer streams the resampled + array into that layer. The returned factory is forwarded to + `register_volume` via its `progress_plotter` argument. + + Parameters + ---------- + moving_layer : napari.layers.Layer + Moving source layer. Used for display defaults (colormap, + contrast limits) on the preview layer, since the resampled output + lives in the moved intensity space. + fixed_layer : napari.layers.Layer + Fixed reference layer. Defines the shape, scale, translate, and + coordinate system of the preview/output layer. + fixed : xarray.DataArray + DataArray view of `fixed_layer`, used to build the empty preview + grid. + layer_name : str + Name for the preview (and later final) layer. + + Returns + ------- + callable or None + Factory suited for `register_volume`'s `progress_plotter` + argument, or `None` when the preview layer could not be created + (in which case `register_volume` runs without live progress). + """ + self._teardown_volume_progress() + + # Tint the fixed layer red so the resampled preview can overlay it via + # the classic red/cyan alignment view. The tint persists after the + # run; the user can reset it via the layer controls. + fixed_layer.colormap = "red" + + # Re-tint the moving layer cyan and switch it to additive blending so + # the registered overlay keeps the red/cyan alignment look if the + # user re-enables it after the run. + moving_layer.colormap = "cyan" + moving_layer.blending = "additive" + + display_kwargs = _image_display_kwargs_from_layer(moving_layer) + # Seed contrast limits with the moving layer so the preview is shown in + # the same intensity space as the final resampled volume. + moving_contrast = getattr(moving_layer, "contrast_limits", None) + if moving_contrast is not None: + display_kwargs.setdefault("contrast_limits", tuple(moving_contrast)) + + # Render the preview in cyan with additive blending. napari sums the + # RGB channels of the two layers, so red+cyan highlights + # misregistered regions as a pure colour. `_image_display_kwargs_from_layer` + # copies the moving layer's colormap, so we override it explicitly + # rather than rely on `setdefault`. + display_kwargs["colormap"] = "cyan" + display_kwargs["blending"] = "additive" + + # Seed the preview with the moving image resampled onto the fixed + # grid using an identity transform. This makes the first frame a + # meaningful "unaligned moving on fixed grid" view that the user can + # compare against the red fixed, instead of a zero-valued blank that + # would flash a full-FOV tint. The SimpleITK iteration events then + # overwrite the data in place as the registration progresses. + try: + identity = np.eye(fixed.ndim + 1, dtype=float) + moving_da = _layer_to_dataarray(moving_layer) + preview = resample_like( + moving_da, + fixed, + identity, + interpolation=cast( + "Literal['linear', 'bspline']", + "linear", + ), + ) + except Exception as exc: # noqa: BLE001 + # Fall back to a zero-valued seed if the initial resample fails + # for any reason. The first iteration will populate the preview. + self._set_error(f"Could not seed progress layer: {exc}") + preview = xr.DataArray( + np.zeros(fixed.shape, dtype=np.float32), + coords=fixed.coords, + dims=fixed.dims, + attrs=fixed.attrs.copy(), + ) + + try: + _, layer = plot_napari( + preview, + viewer=self.viewer, + name=layer_name, + show_colorbar=False, + **display_kwargs, + ) + except Exception as exc: # noqa: BLE001 + self._set_error(f"Could not create progress layer: {exc}") + return None + + bridge = NapariProgressBridge() + bridge.iterated.connect(self._update_progress_layer) + # `finished` is informational: we tear the preview down on + # `_on_registration_finished` / `_on_registration_failed` instead, so + # no extra slot is required here. + self._progress_bridge = bridge + self._progress_layer = cast("Image", layer) + # Hide the moving layer *before* the worker starts so the resampled + # preview never overlaps with the original input. The hidden state + # persists past teardown. + self._progress_hidden_layer = moving_layer + self._progress_hidden_layer.visible = False + return make_napari_progress_factory(bridge) + + def _update_progress_layer(self, arr: object) -> None: + """Write an intermediate resampled array into the preview layer. + + Invoked on the GUI thread via `NapariProgressBridge.iterated`. The + payload is a numpy array in numpy axis order matching the fixed grid + shape. Shape/coordinate mismatches are silently ignored: they + indicate that another run's stale signal slipped through or that the + preview layer has already been torn down. + + Parameters + ---------- + arr : numpy.ndarray + Resampled moving image for the current iteration. + """ + layer = self._progress_layer + if layer is None: + return + if not isinstance(arr, np.ndarray): + return + if arr.shape != layer.data.shape: + return + layer.data = arr # type: ignore[invalid-assignment] + + def _teardown_volume_progress(self) -> None: + """Remove the progress preview layer and bridge references, if any. + + Called by `_on_registration_finished` and `_on_registration_failed` + so the newly added result layer replaces the preview without leaving + duplicates behind. The moving layer's hidden state is not restored. + """ + if self._progress_layer is not None: + try: + self.viewer.layers.remove(self._progress_layer) + except (KeyError, ValueError): + pass + self._progress_layer = None + self._progress_bridge = None + # Drop the reference without restoring visibility: the moving layer + # stays hidden so the resampled output remains the visible moving + # stand-in after the run. + self._progress_hidden_layer = None + def _end_work(self) -> None: """Restore the idle UI state after background work.""" self._run_btn.setEnabled(True) @@ -1032,6 +1218,15 @@ def _run_registration(self) -> None: payload["fixed_layer_name"] = fixed_layer.name + progress_plotter = self._setup_volume_progress( + moving_layer=cast("Image", moving_layer), + fixed_layer=cast("Image", fixed_layer), + fixed=fixed, + layer_name=( + f"{payload['moving_layer_name']} → {payload['fixed_layer_name']}" + ), + ) + worker = thread_worker(_run_register_volume)( moving, fixed, @@ -1046,6 +1241,7 @@ def _run_registration(self) -> None: resample_interpolation=cast( "Literal['linear', 'bspline']", payload["resample_interpolation"] ), + progress_plotter=progress_plotter, ) else: if TIME_DIM not in moving.dims: @@ -1094,6 +1290,12 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non """ operation = cast(str, payload["operation"]) + # Remove the live preview layer if one was created. The freshly-added + # result layer is the authoritative output; the preview is just visual + # scaffolding during optimisation. + if operation == "register_volume": + self._teardown_volume_progress() + if operation == "register_volume": registered, transform, diagnostics = cast( "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics]", @@ -1143,6 +1345,12 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non display_kwargs: dict[str, Any] = {} else: display_kwargs = _image_display_kwargs_from_layer(source_layer) + # The result layer is the registered stand-in for the moving layer: + # it must use the same cyan + additive styling so the red/cyan + # overlay persists after the run. + if operation == "register_volume": + display_kwargs["colormap"] = "cyan" + display_kwargs["blending"] = "additive" contrast_limits = tuple(calc_data_range(registered.data)) _, layer = plot_napari( @@ -1199,5 +1407,6 @@ def _on_registration_failed(self, exc: BaseException) -> None: exc : BaseException Exception raised by the worker. """ + self._teardown_volume_progress() self._set_error(str(exc)) show_error(str(exc)) diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py new file mode 100644 index 00000000..1b59d7b3 --- /dev/null +++ b/src/confusius/_napari/_registration/_progress.py @@ -0,0 +1,231 @@ +"""Napari-layer-backed progress reporting for ``register_volume``. + +This module provides a progress reporter that mirrors the matplotlib-based +[`RegistrationProgressPlotter`][confusius.registration.RegistrationProgressPlotter] +but streams the intermediate resampled volume into a napari Image layer instead +of a matplotlib figure. + +The reporter is intentionally split into two pieces so that napari layers can be +constructed and signal slots connected on the GUI thread before the +registration worker thread starts: + +- [`NapariProgressBridge`][confusius._napari._registration._progress.NapariProgressBridge] + is a lightweight `QObject` that lives on the GUI thread and exposes Qt + signals. The worker thread calls `emit` on it; Qt marshals the slot + invocations back to the GUI thread via an automatically-detected queued + connection. +- [`NapariVolumeProgress`][confusius._napari._registration._progress.NapariVolumeProgress] + implements the + [`RegistrationProgress`][confusius.registration.RegistrationProgress] + protocol. It is constructed inside `register_volume` (i.e. on the worker + thread) and resamples the moving image at every iteration using the current + tentative transform, forwarding the resulting array to the bridge. + +Connection lifecycle: + +1. The panel constructs a `NapariProgressBridge` on the GUI thread and connects + its `iterated` signal to a slot that writes the array into the resampled + napari layer. +2. The panel builds a factory (via + [`make_napari_progress_factory`][confusius._napari._registration._progress.make_napari_progress_factory]) + that closes over the bridge and returns a `NapariVolumeProgress` instance + when called by `register_volume`. +3. `register_volume` instantiates the progress inside the worker thread and + wires it to SimpleITK's iteration and end events as usual. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable + +import numpy as np +from qtpy.QtCore import QObject, Signal + +from confusius.registration._progress import _resample_intermediate + +if TYPE_CHECKING: + import SimpleITK as sitk + + from confusius.registration import RegistrationProgress + + +class NapariProgressBridge(QObject): + """Thread-boundary signal bridge for napari registration progress. + + Construct this on the GUI thread before starting the registration worker. + Connect `iterated` to a slot that mutates a napari layer (e.g. writes + `layer.data = arr`); the slot will be invoked on the GUI thread thanks to + Qt's automatic cross-thread connection. The bridge itself never touches the + napari layer, keeping a clean separation between the worker's data path and + the GUI update path. + + See Also + -------- + NapariVolumeProgress : Worker-side reporter that emits via this bridge. + """ + + iterated = Signal(object) + """:pyqtSignal: Emitted at every optimizer iteration with the resampled + moving image as a numpy array in numpy axis order (matching `fixed`).""" + + finished = Signal() + """:pyqtSignal: Emitted once when the registration end event fires.""" + + +class NapariVolumeProgress: + """Napari-layer progress reporter for `register_volume`. + + Implements the + [`RegistrationProgress`][confusius.registration.RegistrationProgress] + protocol. Stores the registration method and SimpleITK images it needs to + resample the moving image at each iteration. The resampled array is + forwarded to the bridge via a Qt signal, so this object is safe to call + from the SimpleITK command callback running on the worker thread. + + Parameters + ---------- + bridge : NapariProgressBridge + GUI-thread signal bridge. Stored by reference; never accessed for GUI + APIs from this object. + registration_method : SimpleITK.ImageRegistrationMethod + Active registration method whose `GetInitialTransform` is used to + resample the moving image at each iteration. + fixed_img : SimpleITK.Image + Fixed image defining the resample grid. + moving_img : SimpleITK.Image + Moving image to resample. + plot_metric : bool, default: True + Currently unused by the napari path; kept for signature compatibility + with the matplotlib plotter factory. + plot_composite : bool, default: True + Currently unused by the napari path (the resampled layer *is* the + composite view); kept for signature compatibility. + resample_kwargs : dict, optional + Extra keyword arguments forwarded to + [`_resample_intermediate`][confusius.registration._progress._resample_intermediate]. + Must include `"default_value"`; `interpolation` defaults to `"linear"`. + """ + + def __init__( + self, + bridge: NapariProgressBridge, + registration_method: "sitk.ImageRegistrationMethod", + fixed_img: "sitk.Image", + moving_img: "sitk.Image", + *, + plot_metric: bool = True, + plot_composite: bool = True, + resample_kwargs: dict[str, Any] | None = None, + ) -> None: + self._bridge = bridge + self._method = registration_method + self._fixed_img = fixed_img + self._moving_img = moving_img + self._resample_kwargs = dict(resample_kwargs or {}) + # The resampled layer acts as the composite view; the matplotlib-style + # composite overlay is always implied, regardless of plot_composite. + self._plot_metric = plot_metric + self._plot_composite = plot_composite + + def update(self) -> None: + """Resample the moving image with the current transform and emit it. + + Called at every SimpleITK iteration event from the worker thread. The + resampled array is sent to the GUI thread via `bridge.iterated`; the + emit is thread-safe and does not require this object to live on the + GUI thread. + """ + import SimpleITK as sitk + + resampled = _resample_intermediate( + self._method, + self._moving_img, + self._fixed_img, + self._resample_kwargs, + ) + # .T restores numpy axis order (inverse of the .T used when building + # the SITK image), matching what `register_volume` produces. + arr = np.asarray(sitk.GetArrayFromImage(resampled).T) + self._bridge.iterated.emit(arr) + + def close(self) -> None: + """Signal that the registration run has ended. + + Called at the SimpleITK end event. The final resampled state is + available on the bridge via the last `iterated` payload; the panel is + responsible for retrieving/refreshing the layer from `register_volume`'s + returned DataArray, so this signal is informational (e.g. to stop a + spinner or mark the layer as finalised). + """ + self._bridge.finished.emit() + + +def make_napari_progress_factory( + bridge: NapariProgressBridge, +) -> "Callable[..., RegistrationProgress]": + """Return a progress-plotter factory bound to a bridge. + + The returned callable has the signature expected by `register_volume`'s + `progress_plotter` argument — it accepts + `(registration_method, fixed_img, moving_img, *, plot_metric, + plot_composite, resample_kwargs)` and returns a + [`NapariVolumeProgress`][confusius._napari._registration._progress.NapariVolumeProgress] + instance wrapping `bridge`. + + Parameters + ---------- + bridge : NapariProgressBridge + GUI-thread bridge the constructed reporter will emit through. + + Returns + ------- + callable + Factory suitable as the `progress_plotter` argument of + [`register_volume`][confusius.registration.register_volume]. + """ + + def factory( + registration_method: "sitk.ImageRegistrationMethod", + fixed_img: "sitk.Image", + moving_img: "sitk.Image", + *, + plot_metric: bool = True, + plot_composite: bool = True, + resample_kwargs: dict[str, Any] | None = None, + ) -> "RegistrationProgress": + """Build a NapariVolumeProgress wrapping the captured bridge. + + Parameters + ---------- + registration_method : SimpleITK.ImageRegistrationMethod + Active registration method whose transform is sampled at every + iteration. + fixed_img : SimpleITK.Image + Fixed reference image defining the resample grid. + moving_img : SimpleITK.Image + Moving image to resample. + plot_metric : bool, default: True + Unused by the napari path; kept for signature compatibility. + plot_composite : bool, default: True + Unused by the napari path; kept for signature compatibility. + resample_kwargs : dict, optional + Extra keyword arguments forwarded to + [`_resample_intermediate`][confusius.registration._progress._resample_intermediate]. + + Returns + ------- + RegistrationProgress + Progress reporter ready to be wired to SimpleITK's iteration and + end events by `register_volume`. + """ + return NapariVolumeProgress( + bridge, + registration_method, + fixed_img, + moving_img, + plot_metric=plot_metric, + plot_composite=plot_composite, + resample_kwargs=resample_kwargs, + ) + + return factory diff --git a/src/confusius/registration/__init__.py b/src/confusius/registration/__init__.py index af02e9ee..92f990f2 100644 --- a/src/confusius/registration/__init__.py +++ b/src/confusius/registration/__init__.py @@ -1,6 +1,9 @@ """Registration module for fUSI data.""" -from confusius.registration._progress import RegistrationProgressPlotter +from confusius.registration._progress import ( + RegistrationProgress, + RegistrationProgressPlotter, +) from confusius.registration.affines import ( compose_affine, decompose_affine, @@ -20,6 +23,7 @@ __all__ = [ "RegistrationDiagnostics", + "RegistrationProgress", "RegistrationProgressPlotter", "compose_affine", "decompose_affine", diff --git a/src/confusius/registration/_progress.py b/src/confusius/registration/_progress.py index 868146f7..2054178b 100644 --- a/src/confusius/registration/_progress.py +++ b/src/confusius/registration/_progress.py @@ -1,7 +1,9 @@ """Registration progress visualization.""" +from __future__ import annotations + import warnings -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol import numpy as np @@ -12,6 +14,7 @@ import SimpleITK as sitk from matplotlib.figure import Figure + _INTERPOLATION_MAP = { "linear": "sitkLinear", "nearest": "sitkNearestNeighbor", @@ -19,6 +22,106 @@ } +def _resolve_sitk_interpolation(interpolation: str | None) -> Any: + """Return the SimpleITK interpolator enum for a named interpolation. + + Parameters + ---------- + interpolation : str + One of `"linear"`, `"nearest"`, `"bspline"`. + + Returns + ------- + SimpleITK interpolator enum + The matching `sitk.sitk*` interpolator constant. + + Raises + ------ + ValueError + If `interpolation` is not one of the supported names. + """ + import SimpleITK as sitk + + if interpolation is None: + interpolation = "linear" + interp_name = _INTERPOLATION_MAP.get(interpolation) + if interp_name is None: + supported = ", ".join(sorted(_INTERPOLATION_MAP)) + msg = ( + f"Invalid `interpolation`: {interpolation!r}. Expected one of: {supported}." + ) + raise ValueError(msg) + return getattr(sitk, interp_name) + + +def _resample_intermediate( + registration_method: "sitk.ImageRegistrationMethod", + moving_img: "sitk.Image", + fixed_img: "sitk.Image", + resample_kwargs: dict[str, Any], +) -> "sitk.Image": + """Resample the moving image onto the fixed grid using the current transform. + + Shared by the matplotlib and napari progress plotters so the per-iteration + resample logic stays in one place. + + Parameters + ---------- + registration_method : SimpleITK.ImageRegistrationMethod + The active registration method whose initial transform is used to + resample. + moving_img : SimpleITK.Image + Moving image to resample. + fixed_img : SimpleITK.Image + Reference image defining the output grid. + resample_kwargs : dict[str, Any] + Keyword arguments forwarded to `sitk.Resample`. Must contain + `"interpolation"` and `"default_value"`. May contain + `"sitk_threads"`. + + Returns + ------- + SimpleITK.Image + Resampled image on the fixed grid. + """ + import SimpleITK as sitk + + from confusius.registration._utils import set_sitk_thread_count + + interpolation = resample_kwargs.get("interpolation", "linear") + sitk_interp = _resolve_sitk_interpolation(interpolation) + fill_value = resample_kwargs.get("default_value", 0.0) + sitk_threads = resample_kwargs.get("sitk_threads", -1) + + transform = registration_method.GetInitialTransform() + with set_sitk_thread_count(sitk_threads): + return sitk.Resample( + moving_img, + fixed_img, + transform, + sitk_interp, + fill_value, + moving_img.GetPixelID(), + ) + + +class RegistrationProgress(Protocol): + """Duck-typed contract for an iteration progress reporter. + + Implementations are called from the registration thread (SimpleITK's + iteration/end callbacks). They must be safe to call from a non-GUI thread; + any GUI side effects must be marshalled via Qt signals or similar. + """ + + def update(self) -> None: + """Called at every optimizer iteration event.""" + ... + + def close(self) -> None: + """Called once at the registration end event.""" + ... + + class RegistrationProgressPlotter: """Plot registration progress in real time. @@ -153,36 +256,17 @@ def update(self) -> None: self._metric_ax.autoscale_view() if self._plot_composite: - import SimpleITK as sitk - - from confusius.registration._utils import set_sitk_thread_count + resampled = _resample_intermediate( + self._method, + self._moving_img, + self._fixed_img, + self._resample_kwargs, + ) - interpolation = self._resample_kwargs.get("interpolation", "linear") - interp_name = _INTERPOLATION_MAP.get(interpolation) - if interp_name is None: - supported = ", ".join(sorted(_INTERPOLATION_MAP)) - msg = ( - "Invalid `interpolation` in `resample_kwargs`: " - f"{interpolation!r}. Expected one of: {supported}." - ) - raise ValueError(msg) - sitk_interp = getattr(sitk, interp_name) - fill_value = self._resample_kwargs["default_value"] - sitk_threads = self._resample_kwargs.get("sitk_threads", -1) - - transform = self._method.GetInitialTransform() - with set_sitk_thread_count(sitk_threads): - resampled = sitk.Resample( - self._moving_img, - self._fixed_img, - transform, - sitk_interp, - fill_value, - self._moving_img.GetPixelID(), - ) + import SimpleITK as sitk - fixed_arr = sitk.GetArrayFromImage(self._fixed_img).T - moving_arr = sitk.GetArrayFromImage(resampled).T + fixed_arr = np.asarray(sitk.GetArrayFromImage(self._fixed_img).T) + moving_arr = np.asarray(sitk.GetArrayFromImage(resampled).T) if fixed_arr.ndim == 3: rgb = make_mosaic( diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index 9beac42e..73200e84 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -1,6 +1,6 @@ """Volume-to-volume registration for fUSI data.""" -from collections.abc import Sequence +from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Literal, overload import numpy as np @@ -21,6 +21,8 @@ if TYPE_CHECKING: import SimpleITK as sitk + from confusius.registration._progress import RegistrationProgress + def _validate_register_volume_inputs( moving: xr.DataArray, @@ -247,6 +249,7 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 resample_interpolation: Literal["linear", "bspline"] = ..., sitk_threads: int = ..., show_progress: bool = ..., + progress_plotter: "Callable[..., RegistrationProgress] | None" = None, plot_metric: bool = ..., plot_composite: bool = ..., fill_value: float | None = ..., @@ -280,6 +283,7 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 resample_interpolation: Literal["linear", "bspline"] = ..., sitk_threads: int = ..., show_progress: bool = ..., + progress_plotter: "Callable[..., RegistrationProgress] | None" = None, plot_metric: bool = ..., plot_composite: bool = ..., fill_value: float | None = ..., @@ -312,6 +316,7 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 resample_interpolation: Literal["linear", "bspline"] = ..., sitk_threads: int = ..., show_progress: bool = ..., + progress_plotter: "Callable[..., RegistrationProgress] | None" = None, plot_metric: bool = ..., plot_composite: bool = ..., fill_value: float | None = ..., @@ -344,6 +349,7 @@ def register_volume( resample_interpolation: Literal["linear", "bspline"] = "linear", sitk_threads: int = -1, show_progress: bool = False, + progress_plotter: "Callable[..., RegistrationProgress] | None" = None, plot_metric: bool = True, plot_composite: bool = True, fill_value: float | None = None, @@ -456,6 +462,17 @@ def register_volume( Whether to display a live progress plot during registration. The plot is shown in a Jupyter notebook or in an interactive matplotlib window depending on the active backend. + progress_plotter : callable, optional + Factory that builds the progress reporter, called inside `register_volume` + as `progress_plotter(registration_method, fixed_img, moving_img, *, + plot_metric, plot_composite, resample_kwargs)`. The returned object must + implement the + [`RegistrationProgress`][confusius.registration.RegistrationProgress] + protocol (`update()` / `close()`). If not provided, defaults to + [`RegistrationProgressPlotter`][confusius.registration.RegistrationProgressPlotter] + (matplotlib). Ignored when `show_progress=False`. Custom factories are + expected to be safe to call from a non-GUI thread; GUI side effects must + be marshalled via thread-safe primitives such as Qt signals. plot_metric : bool, default: True Whether to include the optimizer metric curve in the progress plot. Ignored when `show_progress=False`. @@ -687,7 +704,10 @@ def register_volume( ) if show_progress: - from confusius.registration._progress import RegistrationProgressPlotter + from confusius.registration._progress import ( + RegistrationProgress, + RegistrationProgressPlotter, + ) resample_kwargs: dict[str, object] = { "interpolation": resample_interpolation, @@ -696,7 +716,8 @@ def register_volume( if _fill_value is not None: resample_kwargs["default_value"] = _fill_value - plotter = RegistrationProgressPlotter( + plotter_factory = progress_plotter or RegistrationProgressPlotter + plotter: RegistrationProgress = plotter_factory( registration, fixed_sitk, moving_sitk, diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index a89cdb99..6cb51290 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -93,7 +93,9 @@ def test_same_moving_and_fixed_is_flagged(self, viewer, registration_panel): assert not registration_panel._layer_validation.isHidden() assert "must be different" in registration_panel._layer_validation.text() - def test_between_scans_with_single_layer_flags_fixed(self, viewer, registration_panel): + def test_between_scans_with_single_layer_flags_fixed( + self, viewer, registration_panel + ): viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="only") registration_panel._refresh_layers() registration_panel._moving_combo.setCurrentText("only") @@ -108,7 +110,10 @@ def test_within_scan_requires_time_dimension(self, viewer, registration_panel): registration_panel._moving_combo.setCurrentText("vol") assert not registration_panel._validate_registration_selection() - assert "Within-scan registration requires" in registration_panel._layer_validation.text() + assert ( + "Within-scan registration requires" + in registration_panel._layer_validation.text() + ) class TestLayerToDataArray: @@ -218,6 +223,143 @@ def test_volume_result_adds_new_layer_with_transform_metadata( == "register_volume" ) + def test_volume_result_replaces_preview_layer( + self, viewer, registration_panel, qtbot + ): + """A preview layer created by `_setup_volume_progress` is removed + after `_on_registration_finished` so the final result is the only + layer with that name.""" + moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") + fixed = xr.DataArray( + np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + fixed_layer = viewer.add_image(np.ones((4, 6), dtype=np.float32), name="fixed") + + factory = registration_panel._setup_volume_progress( + moving_layer=moving, + fixed_layer=fixed_layer, + fixed=fixed, + layer_name="moving → fixed", + ) + assert factory is not None + assert "moving → fixed" in {layer.name for layer in viewer.layers} + assert registration_panel._progress_layer is not None + assert registration_panel._progress_bridge is not None + # The fixed layer is tinted red so the cyan overlay reads as the + # classic red/cyan alignment view. + assert fixed_layer.colormap.name == "red" + # The moving layer is re-tinted cyan + additive, then hidden before + # the worker starts so the resampled preview never overlaps it. + assert moving.colormap.name == "cyan" + assert moving.blending == "additive" + assert registration_panel._progress_hidden_layer is moving + assert not moving.visible + # The preview is rendered in cyan with additive blending and seeded + # with the moving image resampled onto the fixed grid, so the first + # frame is a meaningful "unaligned moving on fixed" view rather than + # a zero-valued blank. + preview_layer = viewer.layers["moving → fixed"] + assert preview_layer.colormap.name == "cyan" + assert preview_layer.blending == "additive" + assert preview_layer.visible + np.testing.assert_array_equal( + np.asarray(preview_layer.data), + np.asarray(moving.data), + ) + + registered = fixed.copy() + transform = np.eye(3) + diagnostics = _FakeDiagnostics() + + payload = { + "operation": "register_volume", + "moving_layer_name": "moving", + "fixed_layer_name": "fixed", + "transform": "rigid", + "metric": "correlation", + "learning_rate": "auto", + "number_of_iterations": 100, + "use_multi_resolution": False, + "resample_interpolation": "linear", + } + registration_panel._on_registration_finished( + payload, + (registered, transform, diagnostics), + ) + + # Preview has been torn down; the result layer is the only match. + assert registration_panel._progress_layer is None + assert registration_panel._progress_bridge is None + # The result layer picks up the same cyan + additive styling so the + # red/cyan overlay survives past teardown. + result_layer = viewer.layers["moving → fixed"] + assert result_layer.colormap.name == "cyan" + assert result_layer.blending == "additive" + # The moving layer stays hidden, with its cyan + additive tint, so + # the registered output remains the visible stand-in. + assert not moving.visible + assert moving.colormap.name == "cyan" + assert moving.blending == "additive" + assert np.array_equal( + np.asarray(result_layer.data), + np.asarray(registered.values), + ) + + def test_progress_layer_data_updates_on_iteration( + self, viewer, registration_panel, qtbot + ): + """`_update_progress_layer` writes the iterated array into the preview + layer's data, refreshing the canvas.""" + moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") + fixed = xr.DataArray( + np.zeros((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + fixed_layer = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="fixed") + + registration_panel._setup_volume_progress( + moving_layer=moving, + fixed_layer=fixed_layer, + fixed=fixed, + layer_name="moving → fixed", + ) + # The preview is seeded with the moving image resampled onto the + # fixed grid, so it's visible and meaningful from the start. + preview_layer = viewer.layers["moving → fixed"] + assert preview_layer.visible + + next_arr = np.full((4, 6), 0.5, dtype=np.float32) + registration_panel._update_progress_layer(next_arr) + + np.testing.assert_array_equal( + np.asarray(viewer.layers["moving → fixed"].data), next_arr + ) + + # Shape mismatch is silently ignored. + registration_panel._update_progress_layer(np.zeros((3, 6), dtype=np.float32)) + np.testing.assert_array_equal( + np.asarray(viewer.layers["moving → fixed"].data), next_arr + ) + + # Teardown removes the preview; the moving layer stays hidden with + # its cyan + additive styling. + registration_panel._teardown_volume_progress() + assert registration_panel._progress_layer is None + assert registration_panel._progress_bridge is None + assert "moving → fixed" not in {layer.name for layer in viewer.layers} + assert not moving.visible + assert moving.colormap.name == "cyan" + assert moving.blending == "additive" + def test_volumewise_result_adds_registered_layer(self, viewer, registration_panel): registered = xr.DataArray( np.ones((3, 4, 6), dtype=np.float32), diff --git a/tests/unit/test_napari/test_registration_progress.py b/tests/unit/test_napari/test_registration_progress.py new file mode 100644 index 00000000..97323ce9 --- /dev/null +++ b/tests/unit/test_napari/test_registration_progress.py @@ -0,0 +1,191 @@ +"""Unit tests for the napari-backed registration progress reporter.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest +import SimpleITK as sitk + +from confusius._napari._registration._progress import ( + NapariProgressBridge, + NapariVolumeProgress, + make_napari_progress_factory, +) + + +@pytest.fixture +def fixed_img_2d(): + """Small 2D SimpleITK image with a bright square.""" + arr = np.zeros((16, 16), dtype=np.float32) + arr[6:10, 6:10] = 1.0 + img = sitk.GetImageFromArray(arr.T) + img.SetSpacing((1.0, 1.0)) + return img + + +@pytest.fixture +def moving_img_2d(fixed_img_2d): + """Same image shifted by one pixel.""" + arr = sitk.GetArrayFromImage(fixed_img_2d).T + shifted = np.roll(arr, 1, axis=0).astype(np.float32) + img = sitk.GetImageFromArray(shifted.T) + img.SetSpacing(fixed_img_2d.GetSpacing()) + return img + + +def _make_registration_method(ndim: int = 2) -> sitk.ImageRegistrationMethod: + """Return a minimally configured ImageRegistrationMethod.""" + reg = sitk.ImageRegistrationMethod() + reg.SetMetricAsCorrelation() + reg.SetInterpolator(sitk.sitkLinear) + reg.SetOptimizerAsGradientDescent( + learningRate=0.5, + numberOfIterations=5, + convergenceMinimumValue=1e-7, + convergenceWindowSize=3, + ) + reg.SetShrinkFactorsPerLevel(shrinkFactors=[1]) + reg.SetSmoothingSigmasPerLevel(smoothingSigmas=[0]) + reg.SmoothingSigmasAreSpecifiedInPhysicalUnitsOff() + reg.SetInitialTransform(sitk.TranslationTransform(ndim), inPlace=True) + return reg + + +class _SignalSpy: + """Collect emitted payloads from a Qt signal.""" + + def __init__(self) -> None: + self.payloads: list[Any] = [] + + def __call__(self, payload: Any) -> None: + self.payloads.append(payload) + + +class TestNapariProgressBridge: + """Signal bridge behaviour.""" + + def test_iterated_signal_is_emitted(self, qtbot): + bridge = NapariProgressBridge() + spy = _SignalSpy() + bridge.iterated.connect(spy) + with qtbot.waitSignal(bridge.iterated, timeout=1000): + bridge.iterated.emit(np.zeros((2, 2), dtype=np.float32)) + assert len(spy.payloads) == 1 + np.testing.assert_array_equal(spy.payloads[0], np.zeros((2, 2))) + + def test_finished_signal_is_emitted(self, qtbot): + bridge = NapariProgressBridge() + with qtbot.waitSignal(bridge.finished, timeout=1000): + bridge.finished.emit() + + +class TestNapariVolumeProgress: + """Per-iteration reporter behaviour.""" + + def test_update_resamples_and_emits_array(self, qtbot, fixed_img_2d, moving_img_2d): + reg = _make_registration_method(ndim=2) + bridge = NapariProgressBridge() + spy = _SignalSpy() + bridge.iterated.connect(spy) + + reporter = NapariVolumeProgress( + bridge, + reg, + fixed_img_2d, + moving_img_2d, + # default_value is required by `_resample_intermediate`. + resample_kwargs={"interpolation": "linear", "default_value": 0.0}, + ) + + with qtbot.waitSignal(bridge.iterated, timeout=2000): + reporter.update() + + assert len(spy.payloads) == 1 + arr = spy.payloads[0] + # `.T` restores numpy axis order, matching `register_volume`. + assert arr.shape == (16, 16) + assert arr.dtype == np.float32 + + def test_close_emits_finished_signal(self, qtbot, fixed_img_2d, moving_img_2d): + reg = _make_registration_method(ndim=2) + bridge = NapariProgressBridge() + reporter = NapariVolumeProgress( + bridge, + reg, + fixed_img_2d, + moving_img_2d, + resample_kwargs={"default_value": 0.0}, + ) + with qtbot.waitSignal(bridge.finished, timeout=1000): + reporter.close() + + +class TestMakeNapariProgressFactory: + """Factory closure behaviour.""" + + def test_factory_returns_napari_volume_progress( + self, qtbot, fixed_img_2d, moving_img_2d + ): + bridge = NapariProgressBridge() + factory = make_napari_progress_factory(bridge) + reg = _make_registration_method(ndim=2) + + plotter = factory( + reg, + fixed_img_2d, + moving_img_2d, + plot_metric=True, + plot_composite=True, + resample_kwargs={"default_value": 0.0}, + ) + + assert isinstance(plotter, NapariVolumeProgress) + assert plotter._bridge is bridge + assert plotter._method is reg + assert plotter._fixed_img is fixed_img_2d + assert plotter._moving_img is moving_img_2d + + +class TestRegisterVolumeWithNapariFactory: + """End-to-end: register_volume calls the injected napari factory.""" + + def test_factory_is_invoked_and_iterated_signal_fires(self, qtbot): + import xarray as xr + + from confusius.registration.volume import register_volume + + arr = np.zeros((16, 16), dtype=np.float32) + arr[6:10, 6:10] = 1.0 + da = xr.DataArray( + arr, + dims=("y", "x"), + coords={ + "y": np.arange(16) * 0.1, + "x": np.arange(16) * 0.1, + }, + ) + + bridge = NapariProgressBridge() + spy = _SignalSpy() + bridge.iterated.connect(spy) + factory = make_napari_progress_factory(bridge) + + with qtbot.waitSignal(bridge.finished, timeout=5000): + result, _transform, _diagnostics = register_volume( + da, + da, + transform_type="translation", + show_progress=True, + progress_plotter=factory, + plot_metric=True, + plot_composite=False, + ) + + # The translator iterates at least once, so we should have received + # at least one intermediate resampled array. + assert len(spy.payloads) >= 1 + for payload in spy.payloads: + assert payload.shape == da.shape + assert result.shape == da.shape From e14349afb069691badeb9480222bdc2af7e679e5 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 25 Jun 2026 14:25:46 +0100 Subject: [PATCH 04/72] feat(registration): bottom-dock metric plotter for the progress overlay Add a per-iteration optimizer-metric curve to the registration progress overlay so users can watch convergence alongside the red/cyan resampled preview. - NapariProgressBridge gains a metric_updated(float) signal; the worker-side NapariVolumeProgress.update() emits the current metric value on every iteration (gated on plot_metric so the matplotlib path stays unchanged). - New RegistrationMetricPlotter widget in confusius._napari._registration._metric_plotter renders the curve in a bottom-docked matplotlib canvas with a navigation toolbar. Rapid iteration events are coalesced through a 16 ms QTimer so the canvas redraws at most once per frame. - RegistrationPanel._ensure_metric_plotter lazily creates and docks the widget (mirroring SignalPanel's lazy-dock pattern, including the HiDPI click-offset workaround). The plotter is reused across runs and reset per run; the dock is kept after teardown so the user can inspect the final convergence trace. - Tests cover the buffer, the 16 ms redraw throttling, the plot_metric=False suppression, and the panel-level dock creation + bridge wiring. --- .../_napari/_registration/_metric_plotter.py | 167 ++++++++++++++++++ src/confusius/_napari/_registration/_panel.py | 92 ++++++++++ .../_napari/_registration/_progress.py | 11 +- .../test_registration_metric_plotter.py | 114 ++++++++++++ .../test_napari/test_registration_panel.py | 44 +++++ .../test_napari/test_registration_progress.py | 51 ++++++ 6 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 src/confusius/_napari/_registration/_metric_plotter.py create mode 100644 tests/unit/test_napari/test_registration_metric_plotter.py diff --git a/src/confusius/_napari/_registration/_metric_plotter.py b/src/confusius/_napari/_registration/_metric_plotter.py new file mode 100644 index 00000000..c39a0ea9 --- /dev/null +++ b/src/confusius/_napari/_registration/_metric_plotter.py @@ -0,0 +1,167 @@ +"""Bottom-dock widget that plots the registration optimizer metric. + +Mirrors the [`SignalPlotter`][confusius._napari._signals._plotter.SignalPlotter] +layout — a small matplotlib figure in the bottom dock — but stays deliberately +simple: a single line chart of the per-iteration metric value. The widget is +created lazily by `RegistrationPanel` when a registration starts, and torn down +on completion so the dock returns to its pre-run layout. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from matplotlib.backends.backend_qtagg import ( + FigureCanvasQTAgg as FigureCanvas, + NavigationToolbar2QT as NavigationToolbar, +) +from matplotlib.figure import Figure +from qtpy.QtCore import QSize, QTimer +from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget + +from confusius._napari._theme import get_napari_colors, style_plot_toolbar + +if TYPE_CHECKING: + from napari import Viewer + + +class RegistrationMetricPlotter(QWidget): + """Bottom-dock widget that plots the per-iteration optimizer metric. + + The widget is intentionally minimal: a single matplotlib axes, a + navigation toolbar, and a thin status footer. Layout decisions (e.g. + y-axis limits, line width) follow the same conventions as + [`SignalPlotter`][confusius._napari._signals._plotter.SignalPlotter] for + visual consistency between the two bottom-dock tabs. + + Parameters + ---------- + viewer : napari.Viewer + Active napari viewer, used to detect theme changes. + """ + + def __init__(self, viewer: "Viewer") -> None: + super().__init__() + self._viewer = viewer + self._metric_values: list[float] = [] + self._metric_line = None + # Throttle redraws to ~60 fps so rapid iteration events (and the + # arrival of their queued Qt signals) don't flood the GUI thread. + self._redraw_timer = QTimer(self) + self._redraw_timer.setSingleShot(True) + self._redraw_timer.setInterval(16) + self._redraw_timer.timeout.connect(self._render) + self.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding, + ) + self.setMinimumHeight(160) + self._setup_ui() + self._apply_theme() + self._viewer.events.theme.connect(self._on_theme_changed) + + def sizeHint(self) -> QSize: + """Return the preferred initial size of the widget. + + Returns + ------- + QSize + Preferred initial size of 800 x 240 pixels. + """ + return QSize(800, 240) + + def _setup_ui(self) -> None: + """Build the matplotlib canvas and toolbar.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(0) + + self._figure = Figure(tight_layout=True) + self._canvas = FigureCanvas(self._figure) + self._canvas.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding, + ) + self._toolbar = NavigationToolbar(self._canvas, self) + layout.addWidget(self._toolbar) + layout.addWidget(self._canvas) + + self._axes = self._figure.add_subplot(111) + self._metric_line = self._axes.plot([], [], color="#e94b5f", linewidth=1.4)[0] + self._axes.set_xlabel("Iteration") + self._axes.set_ylabel("Metric value") + self._axes.set_title("Registration metric") + self._axes.grid(True, alpha=0.3) + + def _apply_theme(self) -> None: + """Re-style the axes and toolbar to match the current napari theme.""" + colors = get_napari_colors(self._viewer.theme) + self._figure.patch.set_facecolor(colors["bg"]) + self._axes.set_facecolor(colors["bg"]) + for spine in self._axes.spines.values(): + spine.set_edgecolor(colors["fg"]) + self._axes.tick_params(colors=colors["fg"]) + self._axes.xaxis.label.set_color(colors["fg"]) + self._axes.yaxis.label.set_color(colors["fg"]) + self._axes.title.set_color(colors["fg"]) + if self._metric_line is not None: + self._metric_line.set_color(colors["accent"]) + style_plot_toolbar(self._toolbar, colors) + self._canvas.draw_idle() + + def _on_theme_changed(self) -> None: + """Handle napari theme change by re-applying the matplotlib style.""" + self._apply_theme() + + def add_metric(self, value: float) -> None: + """Append a metric value and schedule a redraw. + + Called from the GUI thread via the `NapariProgressBridge.metric_updated` + signal. Rapid iteration events are coalesced through a single-shot + timer so the canvas is redrawn at most once per ~16 ms regardless of + the worker-side event rate. + + Parameters + ---------- + value : float + Optimizer metric value at the current iteration. + """ + self._metric_values.append(float(value)) + if not self._redraw_timer.isActive(): + self._redraw_timer.start() + + def reset(self) -> None: + """Clear the metric buffer and redraw an empty plot. + + Called before each new registration run so the curve starts from + scratch instead of overlaying the previous run's data. + """ + self._redraw_timer.stop() + self._metric_values.clear() + if self._metric_line is not None: + self._metric_line.set_data([], []) + self._axes.relim() + self._axes.autoscale_view() + self._canvas.draw_idle() + + def _render(self) -> None: + """Redraw the metric line with the buffered values.""" + if self._metric_line is None: + return + n = len(self._metric_values) + self._metric_line.set_data(np.arange(1, n + 1), self._metric_values) + self._axes.relim() + self._axes.autoscale_view() + self._canvas.draw_idle() + + @property + def metric_values(self) -> list[float]: + """Copy of the metric value buffer. + + Returns + ------- + list of float + Optimizer metric value recorded at each iteration. + """ + return list(self._metric_values) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 820ec824..b6d86620 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -11,17 +11,20 @@ from napari.layers.utils.layer_utils import calc_data_range from napari.qt.threading import thread_worker from napari.utils.notifications import show_error, show_info +from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( QApplication, QButtonGroup, QCheckBox, QComboBox, + QDockWidget, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, + QMainWindow, QProgressBar, QPushButton, QRadioButton, @@ -32,6 +35,9 @@ ) from confusius._dims import SPATIAL_DIMS, TIME_DIM +from confusius._napari._registration._metric_plotter import ( + RegistrationMetricPlotter, +) from confusius._napari._registration._progress import ( NapariProgressBridge, make_napari_progress_factory, @@ -344,6 +350,10 @@ def __init__(self, viewer: napari.Viewer) -> None: # overlay the fixed layer without a duplicate moving/final-image # overlap. Visibility is not restored on teardown. self._progress_hidden_layer: Image | None = None + # Bottom-dock metric curve. Created lazily on the first run, reused + # across subsequent runs, and torn down with the progress state. + self._metric_plotter: RegistrationMetricPlotter | None = None + self._metric_dock: QDockWidget | None = None self._setup_ui() self.viewer.layers.events.inserted.connect(self._refresh_layers) self.viewer.layers.events.removed.connect(self._refresh_layers) @@ -1011,6 +1021,14 @@ def _setup_volume_progress( # persists past teardown. self._progress_hidden_layer = moving_layer self._progress_hidden_layer.visible = False + + # Lazily build the bottom-dock metric plotter. The widget is reused + # across runs; only the data buffer is reset. + self._ensure_metric_plotter() + plotter = self._metric_plotter + if plotter is not None: + plotter.reset() + bridge.metric_updated.connect(plotter.add_metric) return make_napari_progress_factory(bridge) def _update_progress_layer(self, arr: object) -> None: @@ -1042,6 +1060,8 @@ def _teardown_volume_progress(self) -> None: Called by `_on_registration_finished` and `_on_registration_failed` so the newly added result layer replaces the preview without leaving duplicates behind. The moving layer's hidden state is not restored. + The metric plotter is kept (docked, with its final trace) so the + user can inspect the convergence curve after the run. """ if self._progress_layer is not None: try: @@ -1049,12 +1069,84 @@ def _teardown_volume_progress(self) -> None: except (KeyError, ValueError): pass self._progress_layer = None + # Drop the bridge reference; the plotter connection becomes inert + # when the bridge is garbage-collected. self._progress_bridge = None # Drop the reference without restoring visibility: the moving layer # stays hidden so the resampled output remains the visible moving # stand-in after the run. self._progress_hidden_layer = None + def _ensure_metric_plotter(self) -> RegistrationMetricPlotter | None: + """Return the bottom-dock metric plotter, creating and docking it on first use. + + Mirrors the lazy-dock pattern used by `SignalPanel`. The plotter widget is + reused across runs; `_setup_volume_progress` resets its data buffer + before each run. Returns `None` only when the dock could not be created + (in which case the registration still runs, just without a live metric + view). + """ + if self._metric_plotter is None: + self._metric_plotter = RegistrationMetricPlotter(self.viewer) + + if self._metric_dock is None or self._metric_plotter.parent() is None: + dock = self.viewer.window.add_dock_widget( + self._metric_plotter, + name="Registration Metric", + area="bottom", + ) + self._metric_dock = cast("QDockWidget", dock) + + # Mirror the HiDPI click-offset fix from the SignalPanel so the + # canvas paints at the right device-pixel ratio the first time. + def _settle_layout() -> None: + main_win = self._find_main_window(dock) + if main_win is None: + return + from qtpy.QtCore import QSize + + central = main_win.centralWidget() + if central is None: + return + central.setMinimumSize(QSize(0, 0)) + for w in central.findChildren(QWidget): + w.setMinimumSize(QSize(0, 0)) + for side_dock in main_win.findChildren(QDockWidget): + if side_dock is dock: + continue + side_dock.setMinimumHeight(0) + widget = side_dock.widget() + if widget is not None: + widget.setMinimumSize(QSize(0, 0)) + current = main_win.size() + if current.height() < 800: + main_win.resize(current.width(), 800) + main_win.resizeDocks([dock], [220], Qt.Orientation.Vertical) + + QTimer.singleShot(200, _settle_layout) + + return self._metric_plotter + + def _find_main_window(self, widget: QWidget) -> QMainWindow | None: + """Traverse up the widget hierarchy to find the QMainWindow. + + Parameters + ---------- + widget : QWidget + Starting widget to search from. + + Returns + ------- + QMainWindow or None + The main window if found, otherwise None. + """ + parent = widget.parent() + while parent is not None: + if isinstance(parent, QMainWindow): + return parent + parent = parent.parent() + return None + def _end_work(self) -> None: """Restore the idle UI state after background work.""" self._run_btn.setEnabled(True) diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py index 1b59d7b3..1d01a859 100644 --- a/src/confusius/_napari/_registration/_progress.py +++ b/src/confusius/_napari/_registration/_progress.py @@ -68,6 +68,10 @@ class NapariProgressBridge(QObject): """:pyqtSignal: Emitted at every optimizer iteration with the resampled moving image as a numpy array in numpy axis order (matching `fixed`).""" + metric_updated = Signal(float) + """:pyqtSignal: Emitted at every optimizer iteration with the current + optimizer metric value (a float).""" + finished = Signal() """:pyqtSignal: Emitted once when the registration end event fires.""" @@ -133,10 +137,15 @@ def update(self) -> None: Called at every SimpleITK iteration event from the worker thread. The resampled array is sent to the GUI thread via `bridge.iterated`; the emit is thread-safe and does not require this object to live on the - GUI thread. + GUI thread. The current optimizer metric value is also forwarded via + `bridge.metric_updated` so a metric-curve plotter can track + convergence. """ import SimpleITK as sitk + if self._plot_metric: + self._bridge.metric_updated.emit(float(self._method.GetMetricValue())) + resampled = _resample_intermediate( self._method, self._moving_img, diff --git a/tests/unit/test_napari/test_registration_metric_plotter.py b/tests/unit/test_napari/test_registration_metric_plotter.py new file mode 100644 index 00000000..dab3f9d0 --- /dev/null +++ b/tests/unit/test_napari/test_registration_metric_plotter.py @@ -0,0 +1,114 @@ +"""Unit tests for the bottom-dock registration metric plotter.""" + +from __future__ import annotations + +import pytest +from qtpy.QtCore import Qt + + +@pytest.fixture +def registration_metric_plotter(make_napari_viewer): + from confusius._napari._registration._metric_plotter import ( + RegistrationMetricPlotter, + ) + + viewer = make_napari_viewer() + return RegistrationMetricPlotter(viewer) + + +class TestRegistrationMetricPlotterBuffer: + """Pure-logic: add_metric / reset / metric_values.""" + + def test_empty_after_construction(self, registration_metric_plotter) -> None: + assert registration_metric_plotter.metric_values == [] + + def test_add_metric_appends_value(self, registration_metric_plotter) -> None: + registration_metric_plotter.add_metric(0.5) + registration_metric_plotter.add_metric(0.25) + registration_metric_plotter.add_metric(0.1) + # The QTimer is single-shot at 16ms; force a render so the line + # state is finalised before we read it. + registration_metric_plotter._render() # type: ignore[attr-defined] + assert registration_metric_plotter.metric_values == [0.5, 0.25, 0.1] + + def test_metric_values_returns_a_copy( + self, registration_metric_plotter + ) -> None: + registration_metric_plotter.add_metric(1.0) + snapshot = registration_metric_plotter.metric_values + snapshot.append(99.0) + # Mutating the snapshot must not affect the internal buffer. + assert registration_metric_plotter.metric_values == [1.0] + + def test_reset_clears_buffer(self, registration_metric_plotter) -> None: + registration_metric_plotter.add_metric(0.5) + registration_metric_plotter.add_metric(0.25) + registration_metric_plotter.reset() + assert registration_metric_plotter.metric_values == [] + + def test_reset_after_data_keeps_axes_valid( + self, registration_metric_plotter + ) -> None: + registration_metric_plotter.add_metric(0.5) + registration_metric_plotter.reset() + registration_metric_plotter._render() # type: ignore[attr-defined] + # After reset + render, the line data is empty but the axes are + # still configured. + line = registration_metric_plotter._metric_line # type: ignore[attr-defined] + assert list(line.get_xdata()) == [] + assert list(line.get_ydata()) == [] + + +class TestRegistrationMetricPlotterThrottling: + """The redraw timer coalesces rapid `add_metric` calls.""" + + def test_single_timer_per_burst( + self, registration_metric_plotter, qtbot + ) -> None: + # Burst a series of values without yielding to the event loop; the + # timer should be active but only one render should fire when the + # loop runs. + for v in [0.1, 0.2, 0.3, 0.4, 0.5]: + registration_metric_plotter.add_metric(v) + # The buffer holds every value; the canvas will be redrawn once the + # timer fires. + assert registration_metric_plotter.metric_values == [0.1, 0.2, 0.3, 0.4, 0.5] + timer = registration_metric_plotter._redraw_timer # type: ignore[attr-defined] + assert timer.isSingleShot() + assert timer.interval() == 16 + + def test_render_after_timer_fire( + self, registration_metric_plotter, qtbot + ) -> None: + registration_metric_plotter.add_metric(0.5) + # Wait for the throttled redraw to fire. + with qtbot.waitSignal( + registration_metric_plotter._redraw_timer.timeout, # type: ignore[attr-defined] + timeout=2000, + ): + pass + line = registration_metric_plotter._metric_line # type: ignore[attr-defined] + npt_import = pytest.importorskip("numpy") + npt_import.testing.assert_array_equal( + npt_import.asarray(line.get_xdata()), npt_import.asarray([1]) + ) + npt_import.testing.assert_array_equal( + npt_import.asarray(line.get_ydata()), npt_import.asarray([0.5]) + ) + + +class TestRegistrationMetricPlotterLayout: + """Construction and theme integration.""" + + def test_widget_has_minimum_height(self, registration_metric_plotter) -> None: + assert registration_metric_plotter.minimumHeight() >= 100 + + def test_size_hint(self, registration_metric_plotter) -> None: + hint = registration_metric_plotter.sizeHint() + assert hint.width() >= 400 + assert hint.height() >= 150 + + def test_metric_line_created(self, registration_metric_plotter) -> None: + assert registration_metric_plotter._metric_line is not None # type: ignore[attr-defined] + assert registration_metric_plotter._axes.get_xlabel() == "Iteration" # type: ignore[attr-defined] + assert registration_metric_plotter._axes.get_ylabel() == "Metric value" # type: ignore[attr-defined] \ No newline at end of file diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 6cb51290..5d28c084 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -360,6 +360,50 @@ def test_progress_layer_data_updates_on_iteration( assert moving.colormap.name == "cyan" assert moving.blending == "additive" + def test_setup_creates_metric_plotter_dock( + self, viewer, registration_panel + ): + """`_setup_volume_progress` lazily creates and docks the metric plotter.""" + moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") + fixed = xr.DataArray( + np.zeros((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + fixed_layer = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="fixed") + + assert registration_panel._metric_plotter is None + assert registration_panel._metric_dock is None + + registration_panel._setup_volume_progress( + moving_layer=moving, + fixed_layer=fixed_layer, + fixed=fixed, + layer_name="moving → fixed", + ) + + assert registration_panel._metric_plotter is not None + assert registration_panel._metric_dock is not None + # The plotter is parented to a dock (i.e. it has been re-parented + # away from its original parent). + assert registration_panel._metric_plotter.parent() is not None + + # Feeding a value through the bridge populates the plotter's buffer. + bridge = registration_panel._progress_bridge + assert bridge is not None + bridge.metric_updated.emit(0.5) + # Force a render so the throttled redraw is observed synchronously. + registration_panel._metric_plotter._render() # type: ignore[attr-defined] + assert registration_panel._metric_plotter.metric_values == [0.5] # type: ignore[attr-defined] + + # Tearing down keeps the plotter (so the user can inspect the trace). + registration_panel._teardown_volume_progress() + assert registration_panel._metric_plotter is not None + assert registration_panel._metric_plotter.metric_values == [0.5] # type: ignore[attr-defined] + def test_volumewise_result_adds_registered_layer(self, viewer, registration_panel): registered = xr.DataArray( np.ones((3, 4, 6), dtype=np.float32), diff --git a/tests/unit/test_napari/test_registration_progress.py b/tests/unit/test_napari/test_registration_progress.py index 97323ce9..f73e341b 100644 --- a/tests/unit/test_napari/test_registration_progress.py +++ b/tests/unit/test_napari/test_registration_progress.py @@ -80,6 +80,11 @@ def test_finished_signal_is_emitted(self, qtbot): with qtbot.waitSignal(bridge.finished, timeout=1000): bridge.finished.emit() + def test_metric_updated_signal_is_emitted(self, qtbot): + bridge = NapariProgressBridge() + with qtbot.waitSignal(bridge.metric_updated, timeout=1000): + bridge.metric_updated.emit(0.42) + class TestNapariVolumeProgress: """Per-iteration reporter behaviour.""" @@ -108,6 +113,52 @@ def test_update_resamples_and_emits_array(self, qtbot, fixed_img_2d, moving_img_ assert arr.shape == (16, 16) assert arr.dtype == np.float32 + def test_update_emits_metric_value(self, qtbot, fixed_img_2d, moving_img_2d): + """`update()` also forwards the current optimizer metric value.""" + reg = _make_registration_method(ndim=2) + bridge = NapariProgressBridge() + metric_spy = _SignalSpy() + bridge.metric_updated.connect(metric_spy) + + reporter = NapariVolumeProgress( + bridge, + reg, + fixed_img_2d, + moving_img_2d, + resample_kwargs={"default_value": 0.0}, + ) + + with qtbot.waitSignal(bridge.metric_updated, timeout=2000): + reporter.update() + + assert len(metric_spy.payloads) == 1 + assert isinstance(metric_spy.payloads[0], float) + assert np.isfinite(metric_spy.payloads[0]) + + def test_update_skips_metric_when_plot_metric_false( + self, qtbot, fixed_img_2d, moving_img_2d + ): + """`plot_metric=False` suppresses the metric_updated emission.""" + reg = _make_registration_method(ndim=2) + bridge = NapariProgressBridge() + metric_spy = _SignalSpy() + bridge.metric_updated.connect(metric_spy) + + reporter = NapariVolumeProgress( + bridge, + reg, + fixed_img_2d, + moving_img_2d, + plot_metric=False, + resample_kwargs={"default_value": 0.0}, + ) + # Iterate and confirm the metric signal never fires. We trigger the + # iterated signal first to give the metric a chance to emit, then + # check the spy. + with qtbot.waitSignal(bridge.iterated, timeout=2000): + reporter.update() + assert metric_spy.payloads == [] + def test_close_emits_finished_signal(self, qtbot, fixed_img_2d, moving_img_2d): reg = _make_registration_method(ndim=2) bridge = NapariProgressBridge() From 28d4a7c50e8af84fcb5c730111210dc2e900fd64 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 10:41:08 +0100 Subject: [PATCH 05/72] feat(registration): add napari progress and aborts --- NAPARI_REGISTRATION_PLAN.md | 115 ++- src/confusius/_napari/_registration/_panel.py | 927 ++++++++++++++++-- .../_napari/_registration/_progress.py | 108 +- .../_napari/_registration/_transforms.py | 2 + src/confusius/_napari/_widget.py | 2 +- src/confusius/registration/__init__.py | 4 + src/confusius/registration/_utils.py | 61 ++ src/confusius/registration/diagnostics.py | 10 +- src/confusius/registration/exceptions.py | 5 + src/confusius/registration/volume.py | 121 ++- src/confusius/registration/volumewise.py | 142 ++- .../registration/volumewise_progress.py | 61 ++ src/confusius/xarray/registration.py | 16 +- .../test_napari/test_registration_panel.py | 168 +++- tests/unit/test_registration/test_volume.py | 36 +- .../unit/test_registration/test_volumewise.py | 84 ++ 16 files changed, 1676 insertions(+), 186 deletions(-) create mode 100644 src/confusius/registration/exceptions.py create mode 100644 src/confusius/registration/volumewise_progress.py diff --git a/NAPARI_REGISTRATION_PLAN.md b/NAPARI_REGISTRATION_PLAN.md index 61cc9b0e..9b660a37 100644 --- a/NAPARI_REGISTRATION_PLAN.md +++ b/NAPARI_REGISTRATION_PLAN.md @@ -18,29 +18,22 @@ Deliver a thin but usable panel focused on running registrations and adding the - Always resample in the GUI. - No `resample=True/False` toggle. - The resampled result is always added as a **new layer**. -- Minimal parameter surface: - - operation - - moving layer - - fixed layer for `register_volume` - - reference time for `register_volumewise` - - transform model - - metric - - resampling interpolation - - optional multi-resolution toggle - - learning rate - - number of iterations -- Run work in a background thread so the napari UI stays responsive. -- Attach the resulting `xarray.DataArray` to layer metadata, plus transform/diagnostic provenance. +- Background-thread execution so the napari UI stays responsive. +- Result-layer metadata carries the original `xarray.DataArray`, parameters, diagnostics, and transform provenance. +- Save / load / apply affine transform UI. +- Cancellation / abort support with partial-result return semantics. +- In-napari live progress for both workflows. + - `register_volume`: live red/cyan overlay + metric plot. + - `register_volumewise`: determinate progress bar + progressively filled output layer. +- Per-frame / per-iteration progress callbacks for `register_volumewise`. ### Not yet included - Manual initialization transforms from direct napari interaction. -- Save/load/apply transform UI. -- In-napari live registration progress plots. -- Per-frame progress callbacks for `register_volumewise` or resampling utilities. -- Standalone `resample_like` / `resample_volume` actions. -- Registration masks. -- Cancellation. +- Standalone `resample_like` / `resample_volume` actions in the panel. +- Registration masks in the panel. +- Unified payload support for arbitrary manual napari-created transforms. +- Non-affine transform payload support. ## UX decisions @@ -106,6 +99,8 @@ Transform management. - Better internal layout for the registration tab as it grows. - Unified payload support for manual napari-created transforms. - Optional support for non-affine transform payloads in the future. +- Decide whether volumewise should also hide / retint the source layer after + completion, mirroring the single-volume workflow more closely. ### Phase 3 @@ -117,8 +112,82 @@ Manual initialization: ### Phase 4 -Progress integration: +Progress integration. -- custom progress hooks for `register_volume` -- napari-native metric/composite viewer -- per-frame progress callback for `register_volumewise` +#### Implemented + +- `progress_plotter` factory argument on `register_volume`; defaults to the + matplotlib plotter, the napari plugin injects a Qt-signal bridge. +- Napari-side bridge + `NapariVolumeProgress` reporter resamples the moving + image at every SimpleITK iteration and streams the array into a live + `Image` layer (the "resampled" overlay). +- The fixed layer is tinted red, the moving layer is tinted cyan + additive + and hidden during the run; the preview is seeded with the moving image + resampled onto the fixed grid (identity transform) so the first frame is + a meaningful "unaligned moving on fixed" view. +- Bottom-dock `RegistrationMetricPlotter` widget renders the per-iteration + optimizer metric curve. Coalesces redraws through a 16 ms `QTimer` so + rapid iteration events don't flood the GUI thread. +- `register_volumewise` exposes a public progress-reporter hook. +- Napari volumewise registration uses a determinate progress bar. +- Napari volumewise registration pre-creates the output layer, fills it with + the moving-layer minimum value, then writes frames in as they finish. +- During volumewise progress, the original layer is tinted red and the + in-progress output layer is tinted cyan + additive for visual comparison. + +### Phase 5 + +Panel polish. + +#### Implemented + +- Sidebar widened so the "Moving layer" label and dropdown align with the + rest of the form rows. +- Run button is disabled (greyed out, visibly non-clickable) when the + current layer selection is invalid (no moving layer, missing fixed + layer, moving == fixed, time-dim mismatch, etc.). Re-evaluated on every + selection / mode / param change. +- Learning rate spinbox lower bound lowered below `1e-6` so bspline and + fine-scale transforms can use the small rates they need. +- Missing `register_volume` / `register_volumewise` parameters exposed in + the panel: `number_of_histogram_bins` (mattes MI), convergence + (`convergence_minimum_value`, `convergence_window_size`), + `centering_initialization`, `shrink_factors` / `smoothing_sigmas`, + `fill_value`, `keep_diagnostics`, and `n_jobs` / **Parallel jobs**. + Grouped into a basic section plus a foldable in-panel "Advanced" section. +- Advanced-row visibility is context-sensitive: + - histogram bins only show for `mattes_mi` + - shrink factors / smoothing sigmas only show when multi-resolution is on + - parallel jobs only show for within-scan registration +- The whole "Advanced" header is clickable, not just the disclosure triangle. +- Volumewise mode defaults to a fixed learning rate of `0.01` with `Auto` + unticked; between-scan mode keeps `Auto` on. +- Mode-specific parameter state is preserved while the panel stays open: + switching between between-scan and within-scan restores the last values + used in that mode instead of resetting them. +- Thicker determinate progress bar so the percentage text remains visible. +- **Abort button** for both `register_volume` and `register_volumewise`. + Aborting stops at the next cooperative checkpoint and returns the current + partial result instead of failing the worker. + +#### Remaining polish + +- Better internal layout for the registration tab as it grows. +- Unified payload support for manual napari-created transforms. +- Optional support for non-affine transform payloads in the future. + +### Phase 6 + +CLI / Python UX polish. + +#### Implemented + +- `register_volume` is now Ctrl+C-aware in Python usage: on the main thread, + the first Ctrl+C is converted into cooperative cancellation via the shared + abort event, and the current partial result is returned with + `diagnostics.status="aborted"`. + +#### Remaining polish + +- Consider extending the same Ctrl+C wrapper to higher-level workflows beyond + direct `register_volume` calls if needed. diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index b6d86620..9b6b3589 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -4,7 +4,8 @@ from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, cast +from threading import Event +from typing import TYPE_CHECKING, Any, Literal, Sequence, cast import numpy as np import xarray as xr @@ -12,6 +13,8 @@ from napari.qt.threading import thread_worker from napari.utils.notifications import show_error, show_info from qtpy.QtCore import Qt, QTimer +from qtpy.QtCore import QRegularExpression +from qtpy.QtGui import QValidator from qtpy.QtWidgets import ( QApplication, QButtonGroup, @@ -24,12 +27,14 @@ QGroupBox, QHBoxLayout, QLabel, + QLineEdit, QMainWindow, QProgressBar, QPushButton, QRadioButton, QSizePolicy, QSpinBox, + QToolButton, QVBoxLayout, QWidget, ) @@ -40,6 +45,8 @@ ) from confusius._napari._registration._progress import ( NapariProgressBridge, + NapariVolumewiseProgress, + NapariVolumewiseProgressBridge, make_napari_progress_factory, ) from confusius._napari._registration._transforms import ( @@ -193,7 +200,115 @@ def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: return kwargs -def _run_register_volume( +def _parse_sequence(text: str, expected_len: int = 3) -> tuple[int, ...]: + """Parse comma-separated integers from a text field.""" + parts = [p.strip() for p in text.split(",") if p.strip()] + if not parts: + return tuple() + try: + values = tuple(int(float(p)) for p in parts) + except ValueError: + return tuple() + if len(values) != expected_len: + return tuple() + return values + + +class ScientificDoubleSpinBox(QDoubleSpinBox): + """`QDoubleSpinBox` variant that accepts scientific notation. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + """ + + _ACCEPTABLE_RE = QRegularExpression( + r"^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?$" + ) + _INTERMEDIATE_RE = QRegularExpression( + r"^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))?(?:[eE][+-]?\d*)?$" + ) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setDecimals(10) + self.setKeyboardTracking(False) + self.setAccelerated(True) + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + + def validate( # ty: ignore[invalid-method-override] + self, text: str | None, pos: int + ) -> tuple[QValidator.State, str, int]: + """Validate decimals and scientific notation while the user types. + + Parameters + ---------- + text : str, optional + Current text being edited. + pos : int + Cursor position. + + Returns + ------- + state : QValidator.State + Validation state. + text : str + Normalized text. + pos : int + Cursor position. + """ + normalized = text or "" + if normalized in {"", "+", "-", ".", "+.", "-."}: + return (QValidator.State.Intermediate, normalized, pos) + if self._ACCEPTABLE_RE.match(normalized).hasMatch(): + return (QValidator.State.Acceptable, normalized, pos) + if self._INTERMEDIATE_RE.match(normalized).hasMatch(): + return (QValidator.State.Intermediate, normalized, pos) + return (QValidator.State.Invalid, normalized, pos) + + def valueFromText(self, text): + """Parse the current text into a float value. + + Parameters + ---------- + text : str, optional + Text to parse. + + Returns + ------- + float + Parsed numeric value. + """ + return float(text or 0.0) + + def textFromValue(self, value: float) -> str: # ty: ignore[invalid-method-override] + """Format values compactly, using scientific notation when helpful. + + Parameters + ---------- + value : float + Value to format. + + Returns + ------- + str + Formatted text. + """ + return f"{value:.12g}" + + def stepBy(self, steps: int) -> None: + """Apply additive stepping using the configured single-step size. + + Parameters + ---------- + steps : int + Number of steps to apply. + """ + self.setValue(self.value() + (steps * self.singleStep())) + + +def _run_register_volume_registration_volume( moving: xr.DataArray, fixed: xr.DataArray, *, @@ -203,7 +318,15 @@ def _run_register_volume( number_of_iterations: int, use_multi_resolution: bool, resample_interpolation: Literal["linear", "bspline"], + number_of_histogram_bins: int = 50, + convergence_minimum_value: float = 1e-6, + convergence_window_size: int = 10, + initialization: Literal["geometry", "moments", "none"] = "geometry", + shrink_factors: Sequence[int] = (6, 2, 1), + smoothing_sigmas: Sequence[int] = (6, 2, 1), + fill_value: float | None = None, progress_plotter: Callable[..., RegistrationProgress] | None = None, + abort_event: Event | None = None, ) -> tuple[ xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics ]: @@ -227,9 +350,24 @@ def _run_register_volume( Whether to enable the registration pyramid. resample_interpolation : {"linear", "bspline"} Interpolator for the resampled output. + number_of_histogram_bins : int + Histogram bins for Mattes MI metric. + convergence_minimum_value : float + Convergence threshold. + convergence_window_size : int + Window size for convergence estimation. + initialization : {"geometry", "moments", "none"} + Transform initializer. + shrink_factors : sequence of int + Shrink factors per resolution level. + smoothing_sigmas : sequence of int + Smoothing sigmas per resolution level. + fill_value : float or None + Fill value for resampled output outside input domain. progress_plotter : callable, optional - Optional progress-plotter factory forwarded to `register_volume`. When - not provided, no live progress is shown. + Optional progress-plotter factory forwarded to `register_volume`. + abort_event : threading.Event, optional + Cooperative cancellation flag forwarded to `register_volume`. Returns ------- @@ -250,8 +388,16 @@ def _run_register_volume( use_multi_resolution=use_multi_resolution, resample=True, resample_interpolation=resample_interpolation, + number_of_histogram_bins=number_of_histogram_bins, + convergence_minimum_value=convergence_minimum_value, + convergence_window_size=convergence_window_size, + centering_initialization=initialization, + shrink_factors=shrink_factors, + smoothing_sigmas=smoothing_sigmas, + fill_value=fill_value, show_progress=progress_plotter is not None, progress_plotter=progress_plotter, + abort_event=abort_event, ) @@ -282,10 +428,19 @@ def _run_register_volumewise( n_jobs: int, transform: Literal["translation", "rigid", "affine"], metric: Literal["correlation", "mattes_mi"], - learning_rate: float | Literal["auto"], - number_of_iterations: int, + learning_rate: float | Literal["auto"] = 0.01, + number_of_iterations: int = 100, use_multi_resolution: bool, resample_interpolation: Literal["linear", "bspline"], + number_of_histogram_bins: int = 50, + convergence_minimum_value: float = 1e-6, + convergence_window_size: int = 10, + initialization: Literal["geometry", "moments", "none"] = "geometry", + shrink_factors: Sequence[int] = (6, 2, 1), + smoothing_sigmas: Sequence[int] = (6, 2, 1), + keep_diagnostics: bool = False, + abort_event: Event | None = None, + progress_reporter: NapariVolumewiseProgress | None = None, ) -> xr.DataArray: """Run `register_volumewise` with GUI-friendly defaults. @@ -301,7 +456,7 @@ def _run_register_volumewise( Registration model. metric : {"correlation", "mattes_mi"} Similarity metric. - learning_rate : float or {"auto"} + learning_rate : float or {"auto"}, default: 0.01 Optimizer learning rate. number_of_iterations : int Maximum number of optimizer iterations per frame. @@ -309,6 +464,24 @@ def _run_register_volumewise( Whether to enable the registration pyramid. resample_interpolation : {"linear", "bspline"} Interpolator for the resampled output. + number_of_histogram_bins : int + Histogram bins for Mattes MI metric. + convergence_minimum_value : float + Convergence threshold. + convergence_window_size : int + Window size for convergence estimation. + initialization : {"geometry", "moments", "none"} + Transform initializer. + shrink_factors : tuple of int or None + Shrink factors per resolution level. + smoothing_sigmas : tuple of int or None + Smoothing sigmas per resolution level. + keep_diagnostics : bool + Store detailed optimization diagnostics. + abort_event : threading.Event, optional + Cooperative cancellation flag forwarded to `register_volumewise`. + progress_reporter : NapariVolumewiseProgress, optional + GUI-thread bridge-backed reporter forwarded to `register_volumewise`. Returns ------- @@ -325,7 +498,16 @@ def _run_register_volumewise( number_of_iterations=number_of_iterations, use_multi_resolution=use_multi_resolution, resample_interpolation=resample_interpolation, + number_of_histogram_bins=number_of_histogram_bins, + convergence_minimum_value=convergence_minimum_value, + convergence_window_size=convergence_window_size, + initialization=initialization, + shrink_factors=shrink_factors, + smoothing_sigmas=smoothing_sigmas, + keep_diagnostics=keep_diagnostics, show_progress=False, + abort_event=abort_event, + progress_reporter=progress_reporter, ) @@ -342,10 +524,15 @@ def __init__(self, viewer: napari.Viewer) -> None: super().__init__() self.viewer = viewer self._worker = None + self._abort_event: Event | None = None self._loaded_transform_payload: AffineTransformPayload | None = None # Per-run progress state. Set on the GUI thread before the worker starts. self._progress_bridge: NapariProgressBridge | None = None self._progress_layer: Image | None = None + self._volumewise_progress_bridge: NapariVolumewiseProgressBridge | None = None + self._volumewise_progress_layer: Image | None = None + self._volumewise_progress_time_axis: int | None = None + self._volumewise_progress_total: int | None = None # Moving layer hidden during the run so the resampled preview can # overlay the fixed layer without a duplicate moving/final-image # overlap. Visibility is not restored on teardown. @@ -354,10 +541,41 @@ def __init__(self, viewer: napari.Viewer) -> None: # across subsequent runs, and torn down with the progress state. self._metric_plotter: RegistrationMetricPlotter | None = None self._metric_dock: QDockWidget | None = None + self._active_mode: Literal["register_volume", "register_volumewise"] = ( + "register_volume" + ) + self._mode_parameters: dict[str, dict[str, Any]] = {} self._setup_ui() self.viewer.layers.events.inserted.connect(self._refresh_layers) self.viewer.layers.events.removed.connect(self._refresh_layers) + def _make_form_label(self, text: str, *, tooltip: str | None = None) -> QLabel: + """Return a form label with an optional tooltip.""" + label = QLabel(text) + if tooltip is not None: + label.setToolTip(tooltip) + return label + + def _make_advanced_row( + self, + layout: QFormLayout, + label: str, + widget: QWidget, + *, + tooltip: str | None = None, + ) -> QWidget: + """Create a row container for advanced parameters that can be shown/hidden together.""" + container = QWidget() + row_layout = QHBoxLayout(container) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(8) + lbl = self._make_form_label(label, tooltip=tooltip) + lbl.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + row_layout.addWidget(lbl) + row_layout.addWidget(widget, stretch=1) + layout.addRow(container) + return container + def _setup_ui(self) -> None: layout = QVBoxLayout(self) layout.setContentsMargins(10, 10, 10, 10) @@ -418,7 +636,13 @@ def _setup_ui(self) -> None: self._mode_group.addButton(self._time_series_radio) mode_row.addWidget(self._single_volume_radio) mode_row.addWidget(self._time_series_radio) - operation_layout.addRow("Mode", mode_row) + operation_layout.addRow( + self._make_form_label( + "Mode", + tooltip="Registration workflow. Use 'Between scans' for moving/fixed registration and 'Within-scan' for frame-to-reference motion correction.", + ), + mode_row, + ) self._moving_label = QLabel("Moving layer") self._moving_combo = QComboBox() @@ -430,6 +654,9 @@ def _setup_ui(self) -> None: QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self._moving_combo.currentTextChanged.connect(self._on_moving_layer_changed) + self._moving_label.setToolTip( + "Layer containing the moving image or time series to register." + ) operation_layout.addRow(self._moving_label, self._moving_combo) self._fixed_label = QLabel("Fixed layer") @@ -441,14 +668,19 @@ def _setup_ui(self) -> None: self._fixed_combo.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) + self._fixed_label.setToolTip( + "Reference layer that defines the registration target grid." + ) operation_layout.addRow(self._fixed_label, self._fixed_combo) self._reference_time_label = QLabel("Ref. time") self._reference_time_spin = QSpinBox() self._reference_time_spin.setMinimum(0) + self._reference_time_label.setToolTip( + "Time index used as the registration target for within-scan motion correction." + ) operation_layout.addRow(self._reference_time_label, self._reference_time_spin) - self._n_jobs_label = QLabel("Jobs") self._n_jobs_spin = QSpinBox() self._n_jobs_spin.setRange(-128, 128) self._n_jobs_spin.setSpecialValueText("auto") @@ -456,7 +688,6 @@ def _setup_ui(self) -> None: self._n_jobs_spin.setToolTip( "Number of workers for time-series registration. -1 uses all CPUs." ) - operation_layout.addRow(self._n_jobs_label, self._n_jobs_spin) self._layer_validation = QLabel("") self._layer_validation.setWordWrap(True) @@ -480,7 +711,13 @@ def _setup_ui(self) -> None: self._transform_combo.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - params_layout.addRow("Transform", self._transform_combo) + params_layout.addRow( + self._make_form_label( + "Transform", + tooltip="Transform model optimized during registration: translation, rigid, affine, or bspline for between-scan registration.", + ), + self._transform_combo, + ) self._metric_combo = QComboBox() self._metric_combo.setMinimumContentsLength(14) @@ -491,58 +728,280 @@ def _setup_ui(self) -> None: QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self._metric_combo.addItems(["correlation", "mattes_mi"]) - params_layout.addRow("Metric", self._metric_combo) + params_layout.addRow( + self._make_form_label( + "Metric", + tooltip="Similarity metric optimized during registration. 'correlation' suits same-modality data; 'mattes_mi' is more robust across intensity changes.", + ), + self._metric_combo, + ) - self._interpolation_combo = QComboBox() - self._interpolation_combo.setMinimumContentsLength(14) - self._interpolation_combo.setSizeAdjustPolicy( - QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + self._initialization_combo = QComboBox() + self._initialization_combo.addItems( + ["geometry", "moments", "none", "napari transform"] ) - self._interpolation_combo.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + self._initialization_combo.setToolTip( + "Transform initializer before optimization. 'geometry' aligns centers; " + "'moments' aligns centers of mass; 'none' uses identity; " + "'napari transform' uses the currently selected affine transform from the Transforms panel." ) - self._interpolation_combo.addItems(["linear", "bspline"]) - params_layout.addRow("Interpolation", self._interpolation_combo) - self._interpolation_combo.setToolTip( - "Interpolator used for the resampled output." + params_layout.addRow( + self._make_form_label( + "Initialization", + tooltip="How to initialize the transform before optimization: image geometry, centers of mass, identity, or the selected napari transform.", + ), + self._initialization_combo, ) learning_rate_row = QHBoxLayout() self._learning_rate_auto_check = QCheckBox("Auto") self._learning_rate_auto_check.setChecked(True) - self._learning_rate_spin = QDoubleSpinBox() - self._learning_rate_spin.setRange(1e-6, 1e3) - self._learning_rate_spin.setDecimals(4) - self._learning_rate_spin.setSingleStep(0.01) - self._learning_rate_spin.setValue(0.1) - self._learning_rate_spin.setEnabled(False) + self._learning_rate_edit = ScientificDoubleSpinBox() + self._learning_rate_edit.setRange(1e-10, 1e3) + self._learning_rate_edit.setSingleStep(0.1) + self._learning_rate_edit.setValue(0.1) + self._learning_rate_edit.setToolTip( + "Optimizer step size. Accepts decimal (0.1) or scientific notation (1e-5)." + ) + self._learning_rate_edit.setEnabled(False) + self._learning_rate_auto_check.toggled.connect( + lambda checked: self._learning_rate_edit.setEnabled(not checked) + ) learning_rate_row.addWidget(self._learning_rate_auto_check) - learning_rate_row.addWidget(self._learning_rate_spin, stretch=1) - params_layout.addRow("Learning rate", learning_rate_row) + learning_rate_row.addWidget(self._learning_rate_edit, stretch=1) + params_layout.addRow( + self._make_form_label( + "Learning rate", + tooltip="Optimizer step size. Auto re-estimates it each iteration; otherwise enter a fixed decimal or scientific-notation value.", + ), + learning_rate_row, + ) self._iterations_spin = QSpinBox() self._iterations_spin.setRange(1, 100_000) self._iterations_spin.setValue(100) - params_layout.addRow("Iterations", self._iterations_spin) + params_layout.addRow( + self._make_form_label( + "Iterations", + tooltip="Maximum number of optimizer iterations.", + ), + self._iterations_spin, + ) - self._multi_resolution_check = QCheckBox("Use multi-resolution") + self._advanced_group = QWidget() + advanced_group_layout = QVBoxLayout(self._advanced_group) + advanced_group_layout.setContentsMargins(6, 6, 6, 6) + advanced_group_layout.setSpacing(6) + + advanced_header = QWidget() + advanced_header_layout = QHBoxLayout(advanced_header) + advanced_header_layout.setContentsMargins(0, 0, 0, 0) + advanced_header_layout.setSpacing(6) + + self._advanced_toggle = QToolButton() + self._advanced_toggle.setCheckable(True) + self._advanced_toggle.setChecked(False) + self._advanced_toggle.setAutoRaise(True) + self._advanced_toggle.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonTextBesideIcon + ) + self._advanced_toggle.setText("Advanced") + self._advanced_toggle.setArrowType(Qt.ArrowType.RightArrow) + advanced_header_layout.addWidget(self._advanced_toggle) + advanced_header_layout.addStretch(1) + advanced_group_layout.addWidget(advanced_header) + + self._advanced_content = QWidget() + advanced_layout = QFormLayout(self._advanced_content) + advanced_layout.setSpacing(6) + advanced_layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) + advanced_layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow + ) + + self._histogram_bins_spin = QSpinBox() + self._histogram_bins_spin.setRange(8, 512) + self._histogram_bins_spin.setValue(50) + self._histogram_bins_spin.setToolTip( + "Number of histogram bins for Mattes mutual information metric." + ) + self._histogram_bins_row = self._make_advanced_row( + advanced_layout, + "Histogram bins", + self._histogram_bins_spin, + tooltip="Number of histogram bins used by the Mattes mutual information metric.", + ) + + self._convergence_min_edit = ScientificDoubleSpinBox() + self._convergence_min_edit.setRange(1e-10, 1.0) + self._convergence_min_edit.setSingleStep(1e-6) + self._convergence_min_edit.setValue(1e-6) + self._convergence_min_edit.setToolTip( + "Convergence threshold. Accepts decimal (0.000001) or scientific notation (1e-6)." + ) + self._convergence_min_row = self._make_advanced_row( + advanced_layout, + "Convergence min", + self._convergence_min_edit, + tooltip="Convergence threshold below which the optimizer stops early.", + ) + + self._convergence_window_spin = QSpinBox() + self._convergence_window_spin.setRange(1, 100) + self._convergence_window_spin.setValue(10) + self._convergence_window_spin.setToolTip( + "Number of recent metric values for convergence estimation." + ) + self._convergence_window_row = self._make_advanced_row( + advanced_layout, + "Convergence window", + self._convergence_window_spin, + tooltip="Number of recent metric values used to estimate convergence.", + ) + + self._multi_resolution_check = QCheckBox("Enabled") + self._multi_resolution_check.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed + ) self._multi_resolution_check.setToolTip( "Run registration from coarse to fine resolution levels." ) self._multi_resolution_check.setChecked(False) - params_layout.addRow(self._multi_resolution_check) + self._multi_resolution_row = self._make_advanced_row( + advanced_layout, + "Multi-resolution", + self._multi_resolution_check, + tooltip="Whether to optimize from coarse to fine resolution levels.", + ) + + self._shrink_factors_edit = QLineEdit("6, 2, 1") + self._shrink_factors_edit.setToolTip( + "Comma-separated shrink factors per resolution level for multi-resolution." + ) + self._shrink_factors_row = self._make_advanced_row( + advanced_layout, + "Shrink factors", + self._shrink_factors_edit, + tooltip="Comma-separated downsampling factors for each multi-resolution level.", + ) + + self._smoothing_sigmas_edit = QLineEdit("6, 2, 1") + self._smoothing_sigmas_edit.setToolTip( + "Comma-separated smoothing sigmas per resolution level for multi-resolution." + ) + self._smoothing_sigmas_row = self._make_advanced_row( + advanced_layout, + "Smoothing sigmas", + self._smoothing_sigmas_edit, + tooltip="Comma-separated Gaussian smoothing sigmas applied at each multi-resolution level.", + ) + + self._interpolation_combo = QComboBox() + self._interpolation_combo.setMinimumContentsLength(14) + self._interpolation_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._interpolation_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._interpolation_combo.addItems(["linear", "bspline"]) + self._interpolation_combo.setToolTip( + "Interpolator used for the resampled output." + ) + self._interpolation_row = self._make_advanced_row( + advanced_layout, + "Resample interp.", + self._interpolation_combo, + tooltip="Interpolator used when resampling the registered output onto the target grid.", + ) + + self._fill_value_auto_check = QCheckBox("minimum") + self._fill_value_auto_check.setChecked(True) + self._fill_value_auto_check.setToolTip( + "Automatically use the minimum intensity of the fixed image as fill value." + ) + self._fill_value_spin = QDoubleSpinBox() + self._fill_value_spin.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed + ) + self._fill_value_spin.setRange(-1e6, 1e6) + self._fill_value_spin.setDecimals(3) + self._fill_value_spin.setValue(0.0) + self._fill_value_spin.setEnabled(False) + self._fill_value_spin.setToolTip( + "Fill value for resampled output outside the input domain." + ) + self._fill_value_auto_check.toggled.connect( + lambda checked: self._fill_value_spin.setEnabled(not checked) + ) + self._multi_resolution_check.toggled.connect( + self._update_multi_resolution_enabled + ) + fill_value_row = QHBoxLayout() + fill_value_row.addWidget(self._fill_value_auto_check) + fill_value_row.addWidget(self._fill_value_spin, stretch=1) + fill_value_container = QWidget() + fill_value_container.setLayout(fill_value_row) + self._fill_value_row = self._make_advanced_row( + advanced_layout, + "Fill value", + fill_value_container, + tooltip="Value written outside the moving image field of view after resampling. Choose 'minimum' to use the image minimum automatically.", + ) + + self._keep_diagnostics_check = QCheckBox("Keep full traces") + self._keep_diagnostics_check.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed + ) + self._keep_diagnostics_check.setToolTip( + "Whether to keep the full per-frame optimizer traces for within-scan registration." + ) + self._keep_diagnostics_row = self._make_advanced_row( + advanced_layout, + "Diagnostics", + self._keep_diagnostics_check, + tooltip="Whether to store the full per-frame optimizer traces for within-scan registration.", + ) + + self._n_jobs_row = self._make_advanced_row( + advanced_layout, + "Parallel jobs", + self._n_jobs_spin, + tooltip="Number of parallel workers used for within-scan registration. -1 uses all CPUs.", + ) + + advanced_group_layout.addWidget(self._advanced_content) + self._advanced_toggle.toggled.connect(self._on_advanced_toggled) + self._metric_combo.currentTextChanged.connect( + self._update_metric_dependent_visibility + ) + self._on_advanced_toggled(False) + self._update_multi_resolution_enabled(False) + self._update_metric_dependent_visibility(self._metric_combo.currentText()) self._register_panel = QWidget() register_layout = QVBoxLayout(self._register_panel) register_layout.setContentsMargins(0, 0, 0, 0) register_layout.setSpacing(8) + params_layout.addRow(self._advanced_group) + register_layout.addWidget(operation_group) register_layout.addWidget(params_group) + btn_row = QHBoxLayout() self._run_btn = QPushButton("Run registration") self._run_btn.setObjectName("primary_btn") self._run_btn.clicked.connect(self._run_registration) - register_layout.addWidget(self._run_btn) + btn_row.addWidget(self._run_btn) + + self._abort_btn = QPushButton("Abort") + self._abort_btn.setObjectName("danger_btn") + self._abort_btn.setToolTip("Abort the running registration.") + self._abort_btn.clicked.connect(self._abort_registration) + self._abort_btn.hide() + btn_row.addWidget(self._abort_btn) + + register_layout.addLayout(btn_row) layout.addWidget(self._register_panel) @@ -600,7 +1059,8 @@ def _setup_ui(self) -> None: self._progress = QProgressBar() self._progress.setRange(0, 0) - self._progress.setMaximumHeight(4) + self._progress.setMinimumHeight(18) + self._progress.setTextVisible(True) self._progress.hide() layout.addWidget(self._progress) @@ -614,9 +1074,21 @@ def _setup_ui(self) -> None: self._validate_registration_selection ) self._learning_rate_auto_check.toggled.connect( - self._learning_rate_spin.setDisabled + lambda checked: self._learning_rate_edit.setEnabled(not checked) ) + self._mode_parameters = { + "register_volume": self._snapshot_mode_parameters(is_volumewise=False), + "register_volumewise": { + **self._snapshot_mode_parameters(is_volumewise=False), + "transform": "rigid", + "learning_rate_auto": False, + "learning_rate_value": 0.01, + "n_jobs": -1, + "keep_diagnostics": False, + }, + } + self._refresh_layers() self._on_panel_changed() self._on_mode_changed() @@ -776,7 +1248,6 @@ def _set_layer_validation_style( self._moving_label.setStyleSheet("color: #e05555;" if moving_invalid else "") self._fixed_label.setStyleSheet("color: #e05555;" if fixed_invalid else "") self._reference_time_label.setStyleSheet("") - self._n_jobs_label.setStyleSheet("") if message: self._layer_validation.setText(message) self._layer_validation.show() @@ -784,15 +1255,36 @@ def _set_layer_validation_style( self._layer_validation.hide() self._layer_validation.clear() + def _set_run_btn_enabled(self, enabled: bool) -> None: + """Enable or disable the Run button without changing its busy text. + + The button is also disabled in `_begin_work` while a registration is + running; this helper only handles the idle-state gating driven by + layer-selection validation. + """ + # Don't override the busy state. + if self._run_btn.text() == "Registering…": + return + self._run_btn.setEnabled(enabled) + def _validate_registration_selection(self) -> bool: - """Validate the current registration-layer selection and show inline feedback.""" + """Validate the current registration-layer selection and show inline feedback. + + Returns + ------- + bool + `True` when the selection is valid and a registration can be + started, `False` otherwise. As a side effect, the Run button is + enabled/disabled to match the validation result. + """ moving_layer = self._selected_layer(self._moving_combo) fixed_layer = self._selected_layer(self._fixed_combo) operation = self._operation() if moving_layer is None: self._set_layer_validation_style() - return True + self._set_run_btn_enabled(False) + return False try: moving = _layer_to_dataarray(moving_layer) @@ -801,6 +1293,7 @@ def _validate_registration_selection(self) -> bool: moving_invalid=True, message="Could not read the selected moving layer.", ) + self._set_run_btn_enabled(False) return False if operation == "register_volumewise": @@ -809,8 +1302,10 @@ def _validate_registration_selection(self) -> bool: moving_invalid=True, message="Within-scan registration requires a layer with a time dimension.", ) + self._set_run_btn_enabled(False) return False self._set_layer_validation_style() + self._set_run_btn_enabled(True) return True moving_invalid = TIME_DIM in moving.dims @@ -823,6 +1318,7 @@ def _validate_registration_selection(self) -> bool: fixed_invalid=True, message="Between-scans registration requires different moving and fixed layers.", ) + self._set_run_btn_enabled(False) return False try: @@ -832,6 +1328,7 @@ def _validate_registration_selection(self) -> bool: fixed_invalid=True, message="Could not read the selected fixed layer.", ) + self._set_run_btn_enabled(False) return False if fixed_layer is moving_layer: @@ -843,12 +1340,14 @@ def _validate_registration_selection(self) -> bool: fixed_invalid = TIME_DIM in fixed.dims message = "Between-scans registration requires spatial-only layers." + valid = not (moving_invalid or fixed_invalid) self._set_layer_validation_style( moving_invalid=moving_invalid, fixed_invalid=fixed_invalid, message=message, ) - return not (moving_invalid or fixed_invalid) + self._set_run_btn_enabled(valid) + return valid def _on_moving_layer_changed(self, _name: str) -> None: """Update dependent widgets when the moving layer changes.""" @@ -867,18 +1366,52 @@ def _on_panel_changed(self) -> None: self._register_panel.setVisible(show_register) self._transforms_panel.setVisible(not show_register) - def _on_mode_changed(self) -> None: - """Update the panel when the registration mode changes.""" - is_volumewise = self._operation() == "register_volumewise" + def _on_advanced_toggled(self, checked: bool) -> None: + """Expand or collapse the advanced-parameter group.""" + self._advanced_content.setVisible(checked) + self._advanced_toggle.setArrowType( + Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow + ) - self._fixed_label.setVisible(not is_volumewise) - self._fixed_combo.setVisible(not is_volumewise) - self._fixed_combo.setEnabled(not is_volumewise) - self._reference_time_label.setVisible(is_volumewise) - self._reference_time_spin.setVisible(is_volumewise) - self._n_jobs_label.setVisible(is_volumewise) - self._n_jobs_spin.setVisible(is_volumewise) + def _update_metric_dependent_visibility(self, metric: str) -> None: + """Show metric-specific advanced parameters for the current metric.""" + self._histogram_bins_row.setVisible(metric == "mattes_mi") + + def _update_multi_resolution_enabled(self, checked: bool) -> None: + """Show or hide multi-resolution-only parameter inputs.""" + self._shrink_factors_row.setVisible(checked) + self._smoothing_sigmas_row.setVisible(checked) + def _snapshot_mode_parameters(self, *, is_volumewise: bool) -> dict[str, Any]: + """Capture the current parameter state for one registration mode.""" + return { + "transform": self._transform_combo.currentText() or "rigid", + "metric": self._metric_combo.currentText(), + "initialization": self._initialization_combo.currentText(), + "learning_rate_auto": self._learning_rate_auto_check.isChecked(), + "learning_rate_value": self._learning_rate_edit.value(), + "number_of_iterations": self._iterations_spin.value(), + "number_of_histogram_bins": self._histogram_bins_spin.value(), + "convergence_minimum_value": self._convergence_min_edit.value(), + "convergence_window_size": self._convergence_window_spin.value(), + "use_multi_resolution": self._multi_resolution_check.isChecked(), + "shrink_factors": self._shrink_factors_edit.text(), + "smoothing_sigmas": self._smoothing_sigmas_edit.text(), + "resample_interpolation": self._interpolation_combo.currentText(), + "fill_value_auto": self._fill_value_auto_check.isChecked(), + "fill_value": self._fill_value_spin.value(), + "reference_time": self._reference_time_spin.value(), + "n_jobs": self._n_jobs_spin.value(), + "keep_diagnostics": self._keep_diagnostics_check.isChecked(), + "advanced_open": self._advanced_toggle.isChecked(), + "is_volumewise": is_volumewise, + } + + def _apply_mode_parameters( + self, params: dict[str, Any], *, is_volumewise: bool + ) -> None: + """Restore the parameter state for one registration mode.""" + self._transform_combo.blockSignals(True) self._transform_combo.clear() if is_volumewise: self._transform_combo.addItems(["translation", "rigid", "affine"]) @@ -886,9 +1419,77 @@ def _on_mode_changed(self) -> None: self._transform_combo.addItems( ["translation", "rigid", "affine", "bspline"] ) - rigid_index = self._transform_combo.findText("rigid") - if rigid_index >= 0: - self._transform_combo.setCurrentIndex(rigid_index) + transform = cast("str", params.get("transform", "rigid")) + transform_index = self._transform_combo.findText(transform) + if transform_index < 0: + transform_index = self._transform_combo.findText("rigid") + if transform_index >= 0: + self._transform_combo.setCurrentIndex(transform_index) + self._transform_combo.blockSignals(False) + + self._metric_combo.setCurrentText(cast("str", params["metric"])) + self._initialization_combo.setCurrentText(cast("str", params["initialization"])) + self._learning_rate_auto_check.setChecked( + cast("bool", params["learning_rate_auto"]) + ) + self._learning_rate_edit.setValue(cast("float", params["learning_rate_value"])) + self._iterations_spin.setValue(cast("int", params["number_of_iterations"])) + self._histogram_bins_spin.setValue( + cast("int", params["number_of_histogram_bins"]) + ) + self._convergence_min_edit.setValue( + cast("float", params["convergence_minimum_value"]) + ) + self._convergence_window_spin.setValue( + cast("int", params["convergence_window_size"]) + ) + self._multi_resolution_check.setChecked( + cast("bool", params["use_multi_resolution"]) + ) + self._shrink_factors_edit.setText(cast("str", params["shrink_factors"])) + self._smoothing_sigmas_edit.setText(cast("str", params["smoothing_sigmas"])) + self._interpolation_combo.setCurrentText( + cast("str", params["resample_interpolation"]) + ) + self._fill_value_auto_check.setChecked(cast("bool", params["fill_value_auto"])) + self._fill_value_spin.setValue(cast("float", params["fill_value"])) + self._reference_time_spin.setValue(cast("int", params["reference_time"])) + self._n_jobs_spin.setValue(cast("int", params["n_jobs"])) + self._keep_diagnostics_check.setChecked( + cast("bool", params["keep_diagnostics"]) + ) + self._advanced_toggle.setChecked(cast("bool", params["advanced_open"])) + self._on_advanced_toggled(self._advanced_toggle.isChecked()) + self._update_metric_dependent_visibility(self._metric_combo.currentText()) + self._update_multi_resolution_enabled(self._multi_resolution_check.isChecked()) + + def _on_mode_changed(self) -> None: + """Update the panel when the registration mode changes.""" + new_mode = self._operation() + previous_mode = self._active_mode + previous_is_volumewise = previous_mode == "register_volumewise" + is_volumewise = new_mode == "register_volumewise" + + if previous_mode in self._mode_parameters: + self._mode_parameters[previous_mode] = self._snapshot_mode_parameters( + is_volumewise=previous_is_volumewise + ) + + self._fixed_label.setVisible(not is_volumewise) + self._fixed_combo.setVisible(not is_volumewise) + self._fixed_combo.setEnabled(not is_volumewise) + self._reference_time_label.setVisible(is_volumewise) + self._reference_time_spin.setVisible(is_volumewise) + self._n_jobs_row.setVisible(is_volumewise) + + self._fill_value_row.setVisible(not is_volumewise) + self._keep_diagnostics_row.setVisible(is_volumewise) + + self._apply_mode_parameters( + self._mode_parameters[new_mode], + is_volumewise=is_volumewise, + ) + self._active_mode = new_mode self._update_reference_time_bounds() self._validate_registration_selection() @@ -897,10 +1498,115 @@ def _begin_work(self) -> None: """Put the panel into its busy state.""" self._run_btn.setEnabled(False) self._run_btn.setText("Registering…") + self._abort_btn.setEnabled(True) + self._abort_btn.setText("Abort") + self._abort_btn.show() self._status.hide() + if self._volumewise_progress_total is None: + self._progress.setRange(0, 0) + else: + self._progress.setRange(0, self._volumewise_progress_total) + self._progress.setValue(0) self._progress.show() QApplication.processEvents() + def _abort_registration(self) -> None: + """Request cooperative cancellation of the running registration.""" + if self._worker is None or self._abort_event is None: + return + self._abort_event.set() + self._abort_btn.setEnabled(False) + self._abort_btn.setText("Aborting…") + self._set_error("Aborting registration…") + + def _setup_volumewise_progress( + self, + *, + moving_layer: "Image", + moving: xr.DataArray, + layer_name: str, + total_iterations_per_frame: int, + ) -> NapariVolumewiseProgress: + """Create a progress bridge and output layer for volumewise registration.""" + self._teardown_volumewise_progress(remove_layer=True) + + moving_layer.colormap = "red" + + display_kwargs = _image_display_kwargs_from_layer(moving_layer) + display_kwargs["colormap"] = "cyan" + display_kwargs["blending"] = "additive" + contrast_limits = tuple(calc_data_range(moving.data)) + preview = moving.copy(deep=True) + preview.data[...] = float(np.min(moving.data)) + + _, layer = plot_napari( + preview, + viewer=self.viewer, + name=layer_name, + show_colorbar=False, + contrast_limits=contrast_limits, + **display_kwargs, + ) + bridge = NapariVolumewiseProgressBridge() + bridge.iteration_progress.connect(self._update_volumewise_progress_bar) + bridge.frame_completed.connect(self._update_volumewise_progress_frame) + + self._volumewise_progress_bridge = bridge + self._volumewise_progress_layer = cast("Image", layer) + self._volumewise_progress_time_axis = moving.dims.index(TIME_DIM) + self._volumewise_progress_total = ( + moving.sizes[TIME_DIM] * total_iterations_per_frame + ) + self._progress.setRange(0, self._volumewise_progress_total) + self._progress.setValue(0) + return NapariVolumewiseProgress( + bridge, + n_frames=moving.sizes[TIME_DIM], + total_iterations_per_frame=total_iterations_per_frame, + ) + + def _update_volumewise_progress_bar( + self, + completed_iterations: int, + total_iterations: int, + ) -> None: + """Update the determinate progress bar for volumewise registration.""" + self._progress.setRange(0, max(total_iterations, 1)) + self._progress.setValue(min(completed_iterations, total_iterations)) + + def _update_volumewise_progress_frame( + self, + frame_index: int, + arr: object, + ) -> None: + """Write one completed registered frame into the volumewise output layer.""" + layer = self._volumewise_progress_layer + time_axis = self._volumewise_progress_time_axis + if layer is None or time_axis is None or not isinstance(arr, np.ndarray): + return + + data = np.asarray(layer.data).copy() + if time_axis >= data.ndim: + return + index = tuple( + frame_index if axis == time_axis else slice(None) + for axis in range(data.ndim) + ) + data[index] = arr + layer.data = data # type: ignore[invalid-assignment] + + def _teardown_volumewise_progress(self, *, remove_layer: bool) -> None: + """Reset volumewise progress-layer state.""" + if remove_layer and self._volumewise_progress_layer is not None: + try: + self.viewer.layers.remove(self._volumewise_progress_layer) + except (KeyError, ValueError): + pass + self._volumewise_progress_bridge = None + self._volumewise_progress_layer = None + self._volumewise_progress_time_axis = None + self._volumewise_progress_total = None + def _setup_volume_progress( self, *, @@ -1149,8 +1855,15 @@ def _find_main_window(self, widget: QWidget) -> QMainWindow | None: def _end_work(self) -> None: """Restore the idle UI state after background work.""" + self._worker = None + self._abort_event = None self._run_btn.setEnabled(True) self._run_btn.setText("Run registration") + self._abort_btn.setEnabled(True) + self._abort_btn.setText("Abort") + self._abort_btn.hide() + self._progress.setRange(0, 0) + self._progress.setValue(0) self._progress.hide() def _set_error(self, message: str) -> None: @@ -1273,12 +1986,22 @@ def _run_registration(self) -> None: if self._learning_rate_auto_check.isChecked(): learning_rate = "auto" else: - learning_rate = float(self._learning_rate_spin.value()) + learning_rate = self._learning_rate_edit.value() moving = _layer_to_dataarray(moving_layer) except Exception as exc: # noqa: BLE001 self._set_error(str(exc)) return + convergence_minimum_value = self._convergence_min_edit.value() + + # Parse advanced parameters + shrink_factors = _parse_sequence(self._shrink_factors_edit.text()) + smoothing_sigmas = _parse_sequence(self._smoothing_sigmas_edit.text()) + use_multi_res = self._multi_resolution_check.isChecked() + if not use_multi_res: + shrink_factors = None + smoothing_sigmas = None + payload: dict[str, Any] = { "operation": operation, "moving_layer_name": moving_layer.name, @@ -1286,9 +2009,20 @@ def _run_registration(self) -> None: "metric": self._metric_combo.currentText(), "learning_rate": learning_rate, "number_of_iterations": self._iterations_spin.value(), - "use_multi_resolution": self._multi_resolution_check.isChecked(), + "use_multi_resolution": use_multi_res, "resample_interpolation": self._interpolation_combo.currentText(), + "number_of_histogram_bins": self._histogram_bins_spin.value(), + "convergence_minimum_value": convergence_minimum_value, + "convergence_window_size": self._convergence_window_spin.value(), + "initialization": self._initialization_combo.currentText(), + "shrink_factors": shrink_factors, + "smoothing_sigmas": smoothing_sigmas, + "keep_diagnostics": self._keep_diagnostics_check.isChecked(), + "fill_value": None + if self._fill_value_auto_check.isChecked() + else self._fill_value_spin.value(), } + self._abort_event = Event() if operation == "register_volume": if fixed_layer is None: @@ -1319,7 +2053,7 @@ def _run_registration(self) -> None: ), ) - worker = thread_worker(_run_register_volume)( + worker = thread_worker(_run_register_volume_registration_volume)( moving, fixed, transform_type=cast( @@ -1333,7 +2067,17 @@ def _run_registration(self) -> None: resample_interpolation=cast( "Literal['linear', 'bspline']", payload["resample_interpolation"] ), + number_of_histogram_bins=payload["number_of_histogram_bins"], + convergence_minimum_value=payload["convergence_minimum_value"], + convergence_window_size=payload["convergence_window_size"], + initialization=cast( + "Literal['geometry', 'moments', 'none']", payload["initialization"] + ), + shrink_factors=payload["shrink_factors"] or (6, 2, 1), + smoothing_sigmas=payload["smoothing_sigmas"] or (6, 2, 1), + fill_value=payload["fill_value"], progress_plotter=progress_plotter, + abort_event=self._abort_event, ) else: if TIME_DIM not in moving.dims: @@ -1345,6 +2089,13 @@ def _run_registration(self) -> None: payload["reference_time"] = self._reference_time_spin.value() payload["n_jobs"] = self._n_jobs_spin.value() + progress_reporter = self._setup_volumewise_progress( + moving_layer=cast("Image", moving_layer), + moving=moving, + layer_name=f"{payload['moving_layer_name']} registered", + total_iterations_per_frame=payload["number_of_iterations"], + ) + worker = thread_worker(_run_register_volumewise)( moving, reference_time=payload["reference_time"], @@ -1359,6 +2110,17 @@ def _run_registration(self) -> None: resample_interpolation=cast( "Literal['linear', 'bspline']", payload["resample_interpolation"] ), + number_of_histogram_bins=payload["number_of_histogram_bins"], + convergence_minimum_value=payload["convergence_minimum_value"], + convergence_window_size=payload["convergence_window_size"], + initialization=cast( + "Literal['geometry', 'moments', 'none']", payload["initialization"] + ), + shrink_factors=payload["shrink_factors"] or (6, 2, 1), + smoothing_sigmas=payload["smoothing_sigmas"] or (6, 2, 1), + keep_diagnostics=payload["keep_diagnostics"], + abort_event=self._abort_event, + progress_reporter=progress_reporter, ) self._worker = worker @@ -1398,12 +2160,14 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non registered.attrs["registration_transform"] = transform registered.attrs["registration_diagnostics"] = diagnostics registered.attrs["registration_operation"] = operation + registered.attrs["registration_status"] = diagnostics.status layer_name = ( f"{payload['moving_layer_name']} → {payload['fixed_layer_name']}" ) metadata: dict[str, Any] = { "registration_transform": transform, "registration_diagnostics": diagnostics, + "registration_status": diagnostics.status, } if isinstance(transform, np.ndarray): affine_transform = np.asarray(transform, dtype=float) @@ -1445,19 +2209,51 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non display_kwargs["blending"] = "additive" contrast_limits = tuple(calc_data_range(registered.data)) - _, layer = plot_napari( - registered, - viewer=self.viewer, - name=layer_name, - show_colorbar=False, - contrast_limits=contrast_limits, - **display_kwargs, - ) + if ( + operation == "register_volumewise" + and self._volumewise_progress_layer is not None + ): + layer = self._volumewise_progress_layer + layer.data = np.asarray(registered.data) # type: ignore[invalid-assignment] + if hasattr(layer, "contrast_limits"): + layer.contrast_limits = contrast_limits + self._teardown_volumewise_progress(remove_layer=False) + else: + _, layer = plot_napari( + registered, + viewer=self.viewer, + name=layer_name, + show_colorbar=False, + contrast_limits=contrast_limits, + **display_kwargs, + ) layer.metadata.update(metadata) layer.metadata["xarray"] = registered self.viewer.layers.selection.active = layer self._refresh_transform_controls() - show_info(f"Added registered layer: {layer.name}") + + motion_params = metadata.get("motion_params") + volumewise_aborted = False + if operation == "register_volumewise" and motion_params is not None: + try: + statuses = motion_params["status"] + except Exception: # noqa: BLE001 + statuses = None + if statuses is not None: + volumewise_aborted = bool((statuses == "aborted").any()) + registration_status = ( + cast("str", metadata["registration_status"]) + if operation == "register_volume" + else ("aborted" if volumewise_aborted else "completed") + ) + if operation == "register_volumewise": + self._progress.setValue(self._progress.maximum()) + + if registration_status == "aborted": + self._set_error("Registration aborted; added partial result.") + show_info(f"Registration aborted; added partial layer: {layer.name}") + else: + show_info(f"Added registered layer: {layer.name}") def _on_apply_transform_finished( self, payload: dict[str, str], result: xr.DataArray @@ -1500,5 +2296,6 @@ def _on_registration_failed(self, exc: BaseException) -> None: Exception raised by the worker. """ self._teardown_volume_progress() + self._teardown_volumewise_progress(remove_layer=True) self._set_error(str(exc)) show_error(str(exc)) diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py index 1d01a859..28c24e40 100644 --- a/src/confusius/_napari/_registration/_progress.py +++ b/src/confusius/_napari/_registration/_progress.py @@ -36,6 +36,7 @@ from __future__ import annotations +from threading import Lock from typing import TYPE_CHECKING, Any, Callable import numpy as np @@ -45,8 +46,9 @@ if TYPE_CHECKING: import SimpleITK as sitk + import xarray as xr - from confusius.registration import RegistrationProgress + from confusius.registration import RegistrationDiagnostics, RegistrationProgress class NapariProgressBridge(QObject): @@ -169,6 +171,110 @@ def close(self) -> None: self._bridge.finished.emit() +class NapariVolumewiseProgressBridge(QObject): + """Thread-boundary signal bridge for volumewise registration progress.""" + + iteration_progress = Signal(int, int) + """:pyqtSignal: Emitted with `(completed_iterations, total_iterations)`. + """ + + frame_completed = Signal(int, object) + """:pyqtSignal: Emitted with `(frame_index, registered_frame_array)` when one + frame finishes. + """ + + finished = Signal() + """:pyqtSignal: Emitted once when the volumewise run ends.""" + + +class NapariVolumewiseProgress: + """Aggregate per-frame progress for `register_volumewise` on the GUI thread. + + Parameters + ---------- + bridge : NapariVolumewiseProgressBridge + GUI-thread signal bridge used to forward progress updates. + n_frames : int + Number of frames that will be registered. + total_iterations_per_frame : int + Expected maximum number of optimizer iterations per frame. + """ + + def __init__( + self, + bridge: NapariVolumewiseProgressBridge, + *, + n_frames: int, + total_iterations_per_frame: int, + ) -> None: + self._bridge = bridge + self._n_frames = n_frames + self._total_iterations_per_frame = total_iterations_per_frame + self._iteration_counts = [0] * n_frames + self._lock = Lock() + + def iteration( + self, + frame_index: int, + iteration: int, + total_iterations: int, + ) -> None: + """Update the aggregated completed-iteration count. + + Parameters + ---------- + frame_index : int + Index of the frame being optimized. + iteration : int + Current 1-indexed optimizer iteration for that frame. + total_iterations : int + Maximum number of iterations expected for that frame. + """ + with self._lock: + if total_iterations > self._total_iterations_per_frame: + self._total_iterations_per_frame = total_iterations + self._iteration_counts[frame_index] = max( + self._iteration_counts[frame_index], + iteration, + ) + completed = sum(self._iteration_counts) + total = self._n_frames * self._total_iterations_per_frame + self._bridge.iteration_progress.emit(completed, total) + + def frame_completed( + self, + frame_index: int, + registered_frame: "xr.DataArray", + diagnostics: "RegistrationDiagnostics", + ) -> None: + """Emit one completed frame for GUI-side layer updates. + + Parameters + ---------- + frame_index : int + Index of the completed frame. + registered_frame : xarray.DataArray + Registered frame data to write into the napari output layer. + diagnostics : confusius.registration.RegistrationDiagnostics + Diagnostics collected for the completed frame. + """ + with self._lock: + self._iteration_counts[frame_index] = max( + self._iteration_counts[frame_index], + diagnostics.n_iterations, + ) + completed = sum(self._iteration_counts) + total = self._n_frames * self._total_iterations_per_frame + self._bridge.iteration_progress.emit(completed, total) + self._bridge.frame_completed.emit( + frame_index, np.asarray(registered_frame.values) + ) + + def close(self) -> None: + """Signal the end of the volumewise run.""" + self._bridge.finished.emit() + + def make_napari_progress_factory( bridge: NapariProgressBridge, ) -> "Callable[..., RegistrationProgress]": diff --git a/src/confusius/_napari/_registration/_transforms.py b/src/confusius/_napari/_registration/_transforms.py index aa6e30ce..b2eb3806 100644 --- a/src/confusius/_napari/_registration/_transforms.py +++ b/src/confusius/_napari/_registration/_transforms.py @@ -24,6 +24,7 @@ class TransformDiagnosticsPayload(TypedDict): final_metric_value: float n_iterations: int stop_condition: str + status: str class OutputGridPayload(TypedDict): @@ -138,6 +139,7 @@ def make_affine_transform_payload( "final_metric_value": float(diagnostics.final_metric_value), "n_iterations": int(diagnostics.n_iterations), "stop_condition": diagnostics.stop_condition, + "status": diagnostics.status, }, } diff --git a/src/confusius/_napari/_widget.py b/src/confusius/_napari/_widget.py index 32fd2f51..315b82b1 100644 --- a/src/confusius/_napari/_widget.py +++ b/src/confusius/_napari/_widget.py @@ -243,7 +243,7 @@ class ConfUSIusWidget(QWidget): def __init__(self, napari_viewer: napari.Viewer) -> None: super().__init__() self.viewer = napari_viewer - self.setMinimumWidth(350) + self.setMinimumWidth(400) self.setSizePolicy( QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding, diff --git a/src/confusius/registration/__init__.py b/src/confusius/registration/__init__.py index 92f990f2..ea5d2626 100644 --- a/src/confusius/registration/__init__.py +++ b/src/confusius/registration/__init__.py @@ -9,6 +9,7 @@ decompose_affine, ) from confusius.registration.diagnostics import RegistrationDiagnostics +from confusius.registration.exceptions import RegistrationAbortedError from confusius.registration.motion import ( compute_framewise_displacement, create_motion_dataframe, @@ -20,8 +21,10 @@ ) from confusius.registration.volume import register_volume from confusius.registration.volumewise import register_volumewise +from confusius.registration.volumewise_progress import VolumewiseProgressReporter __all__ = [ + "RegistrationAbortedError", "RegistrationDiagnostics", "RegistrationProgress", "RegistrationProgressPlotter", @@ -31,6 +34,7 @@ "resample_volume", "resample_like", "register_volumewise", + "VolumewiseProgressReporter", "extract_motion_parameters", "compute_framewise_displacement", "create_motion_dataframe", diff --git a/src/confusius/registration/_utils.py b/src/confusius/registration/_utils.py index 4339b34e..8b1aff4d 100644 --- a/src/confusius/registration/_utils.py +++ b/src/confusius/registration/_utils.py @@ -1,6 +1,8 @@ """Internal utilities shared by registration modules.""" import os +import signal +import threading from contextlib import contextmanager from copy import deepcopy from typing import TYPE_CHECKING, Generator @@ -9,6 +11,8 @@ import xarray as xr if TYPE_CHECKING: + from threading import Event + import SimpleITK as sitk @@ -70,6 +74,63 @@ def set_sitk_thread_count(n: int) -> Generator[None, None, None]: sitk.ProcessObject.SetGlobalDefaultNumberOfThreads(prev) +@contextmanager +def abort_on_sigint( + abort_event: "Event | None", +) -> Generator["Event", None, None]: + """Return an abort event that is set cooperatively on the first Ctrl+C. + + Parameters + ---------- + abort_event : threading.Event or None + Existing cooperative-cancellation event to reuse. If not provided, a + new event is created for the duration of the context. + + Yields + ------ + threading.Event + Event that is set when cooperative cancellation is requested, either + explicitly by the caller or via a Ctrl+C signal handled on the main + thread. + + Notes + ----- + On the main thread, the first `SIGINT`/Ctrl+C is converted into + `abort_event.set()` so long-running registrations can stop cleanly at the + next SimpleITK iteration boundary and return their current partial result. + A second Ctrl+C falls back to the previous signal handler so users can + still force an immediate interrupt if graceful cancellation stalls. + """ + shared_abort_event = abort_event or threading.Event() + + if threading.current_thread() is not threading.main_thread(): + yield shared_abort_event + return + + previous_handler = signal.getsignal(signal.SIGINT) + saw_sigint = False + + def _handle_sigint(signum: int, frame: object) -> None: + nonlocal saw_sigint + if not saw_sigint: + saw_sigint = True + shared_abort_event.set() + return + + if previous_handler in {signal.SIG_DFL, signal.default_int_handler}: + raise KeyboardInterrupt + if previous_handler == signal.SIG_IGN: + return + if callable(previous_handler): + previous_handler(signum, frame) + + signal.signal(signal.SIGINT, _handle_sigint) + try: + yield shared_abort_event + finally: + signal.signal(signal.SIGINT, previous_handler) + + def dataarray_to_sitk_image(da: xr.DataArray) -> "sitk.Image": """Convert a spatial or spatiotemporal DataArray to a SimpleITK image. diff --git a/src/confusius/registration/diagnostics.py b/src/confusius/registration/diagnostics.py index b251f98f..81538f6a 100644 --- a/src/confusius/registration/diagnostics.py +++ b/src/confusius/registration/diagnostics.py @@ -36,8 +36,13 @@ class RegistrationDiagnostics: smaller than `register_volume`'s `number_of_iterations` if the optimizer converged early. stop_condition : str - Human-readable description of the optimizer stop condition, as - returned by SimpleITK's `GetOptimizerStopConditionDescription`. + Human-readable description of the optimizer stop condition. When the + registration completes normally, this is SimpleITK's + `GetOptimizerStopConditionDescription`. When the registration is + aborted cooperatively, this is a short abort message. + status : {"completed", "aborted"} + Whether the registration ran to completion or returned an intermediate + result after cooperative cancellation. """ metric: Literal["correlation", "mattes_mi"] @@ -45,3 +50,4 @@ class RegistrationDiagnostics: final_metric_value: float n_iterations: int stop_condition: str + status: Literal["completed", "aborted"] diff --git a/src/confusius/registration/exceptions.py b/src/confusius/registration/exceptions.py new file mode 100644 index 00000000..2bb15203 --- /dev/null +++ b/src/confusius/registration/exceptions.py @@ -0,0 +1,5 @@ +"""Exceptions raised by registration workflows.""" + + +class RegistrationAbortedError(RuntimeError): + """Raised when a registration run is cancelled before completion.""" diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index 1ef12552..99f3c8bb 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -8,6 +8,7 @@ import xarray as xr from confusius.registration._utils import ( + abort_on_sigint, dataarray_to_sitk_image, replace_affines_attr, set_sitk_thread_count, @@ -20,6 +21,7 @@ if TYPE_CHECKING: import SimpleITK as sitk + from threading import Event from confusius.registration._progress import RegistrationProgress @@ -262,6 +264,8 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 plot_metric: bool = ..., plot_composite: bool = ..., fill_value: float | None = ..., + abort_event: "Event | None" = ..., + iteration_callback: Callable[[int, float], None] | None = ..., ) -> "tuple[xr.DataArray, npt.NDArray[np.floating], RegistrationDiagnostics]": """Overload for linear transforms (translation/rigid/affine).""" ... @@ -296,6 +300,8 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 plot_metric: bool = ..., plot_composite: bool = ..., fill_value: float | None = ..., + abort_event: "Event | None" = ..., + iteration_callback: Callable[[int, float], None] | None = ..., ) -> "tuple[xr.DataArray, xr.DataArray, RegistrationDiagnostics]": """Overload for bspline transform (returns DataArray transform).""" ... @@ -329,6 +335,8 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 plot_metric: bool = ..., plot_composite: bool = ..., fill_value: float | None = ..., + abort_event: "Event | None" = ..., + iteration_callback: Callable[[int, float], None] | None = ..., ) -> "tuple[xr.DataArray, npt.NDArray[np.floating], RegistrationDiagnostics]": """Overload for default transform (rigid, returns affine).""" ... @@ -362,6 +370,8 @@ def register_volume( plot_metric: bool = True, plot_composite: bool = True, fill_value: float | None = None, + abort_event: "Event | None" = None, + iteration_callback: Callable[[int, float], None] | None = None, ) -> "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics]": # noqa: E501 """Register a single 2D or 3D volume to a fixed reference. @@ -496,6 +506,15 @@ def register_volume( `plot_composite=True`). If not provided, defaults to the minimum value of `moving`, which renders out-of-FOV regions as background regardless of intensity scale (important for dB data where 0 is maximum intensity). + abort_event : threading.Event, optional + Cooperative cancellation flag. If set before or during optimisation, the + registration stops at the next SimpleITK iteration boundary and returns + the current intermediate result with `diagnostics.status="aborted"`. + iteration_callback : callable, optional + Callback invoked at every optimizer iteration as + `iteration_callback(iteration, metric_value)`, where `iteration` is + 1-indexed. Useful for higher-level progress aggregation such as + `register_volumewise`. Returns ------- @@ -700,10 +719,12 @@ def register_volume( # Always collect per-iteration metric values so callers get convergence # diagnostics regardless of whether the live progress plot is enabled. metric_values: list[float] = [] - registration.AddCommand( - sitk.sitkIterationEvent, - lambda: metric_values.append(registration.GetMetricValue()), - ) + + def _record_iteration() -> None: + metric_value = float(registration.GetMetricValue()) + metric_values.append(metric_value) + if iteration_callback is not None: + iteration_callback(len(metric_values), metric_value) needs_fill_value = resample or (show_progress and plot_composite) _fill_value = ( @@ -712,33 +733,62 @@ def register_volume( else (float(moving.min()) if needs_fill_value else None) ) - if show_progress: - from confusius.registration._progress import ( - RegistrationProgress, - RegistrationProgressPlotter, + with abort_on_sigint(abort_event) as effective_abort_event: + registration.AddCommand(sitk.sitkIterationEvent, _record_iteration) + registration.AddCommand( + sitk.sitkIterationEvent, + lambda: ( + registration.StopRegistration() + if effective_abort_event.is_set() + else None + ), ) - resample_kwargs: dict[str, object] = { - "interpolation": resample_interpolation, - "sitk_threads": sitk_threads, - } - if _fill_value is not None: - resample_kwargs["default_value"] = _fill_value - - plotter_factory = progress_plotter or RegistrationProgressPlotter - plotter: RegistrationProgress = plotter_factory( - registration, - fixed_sitk, - moving_sitk, - plot_metric=plot_metric, - plot_composite=plot_composite, - resample_kwargs=resample_kwargs, - ) - registration.AddCommand(sitk.sitkIterationEvent, plotter.update) - registration.AddCommand(sitk.sitkEndEvent, plotter.close) + if show_progress: + from confusius.registration._progress import ( + RegistrationProgress, + RegistrationProgressPlotter, + ) - with set_sitk_thread_count(sitk_threads): - sitk_optimized_transform = registration.Execute(fixed_reg, moving_reg) + resample_kwargs: dict[str, object] = { + "interpolation": resample_interpolation, + "sitk_threads": sitk_threads, + } + if _fill_value is not None: + resample_kwargs["default_value"] = _fill_value + + plotter_factory = progress_plotter or RegistrationProgressPlotter + plotter: RegistrationProgress = plotter_factory( + registration, + fixed_sitk, + moving_sitk, + plot_metric=plot_metric, + plot_composite=plot_composite, + resample_kwargs=resample_kwargs, + ) + registration.AddCommand(sitk.sitkIterationEvent, plotter.update) + registration.AddCommand(sitk.sitkEndEvent, plotter.close) + + executed = False + if effective_abort_event.is_set(): + if transform_type == "bspline": + sitk_optimized_transform = sitk_initial_transform + elif initial_transform is not None: + sitk_optimized_transform = affine_to_sitk_linear_transform( + initial_transform + ) + else: + sitk_optimized_transform = sitk.TranslationTransform(ndim) + aborted = True + stop_condition = "Registration aborted before optimisation started." + else: + with set_sitk_thread_count(sitk_threads): + sitk_optimized_transform = registration.Execute(fixed_reg, moving_reg) + executed = True + aborted = effective_abort_event.is_set() + stop_condition = registration.GetOptimizerStopConditionDescription() + if aborted and not stop_condition.strip(): + stop_condition = "Registration aborted." # When resampling, the output lives on the fixed grid; otherwise the moving volume # is returned unchanged and its own coordinates are preserved. @@ -783,17 +833,20 @@ def register_volume( else: optimized_transform = sitk_linear_transform_to_affine(sitk_optimized_transform) - final_metric_value = ( - float(metric_values[-1]) - if metric_values - else float(registration.GetMetricValue()) - ) + final_metric_value: float + if metric_values: + final_metric_value = float(metric_values[-1]) + elif executed: + final_metric_value = float(registration.GetMetricValue()) + else: + final_metric_value = float("nan") diagnostics = RegistrationDiagnostics( metric=metric, metric_values=np.asarray(metric_values, dtype=float), final_metric_value=final_metric_value, n_iterations=len(metric_values), - stop_condition=registration.GetOptimizerStopConditionDescription(), + stop_condition=stop_condition, + status="aborted" if aborted else "completed", ) return result, optimized_transform, diagnostics diff --git a/src/confusius/registration/volumewise.py b/src/confusius/registration/volumewise.py index 42d27aec..feebd4d8 100644 --- a/src/confusius/registration/volumewise.py +++ b/src/confusius/registration/volumewise.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from contextlib import nullcontext -from typing import Literal, cast +from typing import TYPE_CHECKING, Literal, cast import numpy as np import numpy.typing as npt @@ -14,6 +14,11 @@ from confusius.registration.volume import register_volume from confusius.validation import validate_fusi_dataarray +if TYPE_CHECKING: + from threading import Event + + from confusius.registration.volumewise_progress import VolumewiseProgressReporter + def register_volumewise( data: xr.DataArray, @@ -23,7 +28,7 @@ def register_volumewise( transform: Literal["translation", "rigid", "affine"] = "rigid", metric: Literal["correlation", "mattes_mi"] = "correlation", number_of_histogram_bins: int = 50, - learning_rate: float | Literal["auto"] = "auto", + learning_rate: float | Literal["auto"] = 0.01, number_of_iterations: int = 100, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, @@ -35,6 +40,8 @@ def register_volumewise( resample_interpolation: Literal["linear", "bspline"] = "linear", show_progress: bool = True, keep_diagnostics: bool = False, + abort_event: "Event | None" = None, + progress_reporter: "VolumewiseProgressReporter | None" = None, ) -> xr.DataArray: """Register all volumes in a fUSI recording to a reference volume. @@ -60,7 +67,7 @@ def register_volumewise( number_of_histogram_bins : int, default: 50 Number of histogram bins used by Mattes mutual information. Only relevant when `metric="mattes_mi"`. - learning_rate : float or "auto", default: "auto" + learning_rate : float or "auto", default: 0.01 Optimizer step size in normalised units (after `SetOptimizerScalesFromPhysicalShift`). `"auto"` re-estimates the rate at every iteration. A float uses that value directly; if registration diverges or fails to converge, reduce @@ -117,6 +124,17 @@ def register_volumewise( up over long recordings. The cheap per-frame summaries (`final_metric_value`, `n_iterations`) are always added to `motion_params` regardless of this flag. + abort_event : threading.Event, optional + Cooperative cancellation flag shared across frames. If set before or + during execution, in-flight frame registrations stop at the next + optimiser iteration boundary and this function returns the partial + dataset collected so far. Frames that were not started keep their + original values, and per-frame `motion_params` rows are marked via the + diagnostics status. + progress_reporter : VolumewiseProgressReporter, optional + Thread-safe reporter notified at every optimizer iteration and whenever + one frame completes. Useful for GUI progress bars or progressively + filling an output layer while frames finish. Returns ------- @@ -124,7 +142,7 @@ def register_volumewise( Registered data with the same coordinates as input, input attributes, and added motion metadata in `attrs["reference_time"]` and `attrs["motion_params"]`. `motion_params` always carries per-frame - `final_metric_value` and `n_iterations` columns. When + `final_metric_value`, `n_iterations`, and `status` columns. When `keep_diagnostics=True`, `attrs["registration_diagnostics"]` also carries a list of [`RegistrationDiagnostics`][confusius.registration.RegistrationDiagnostics] @@ -183,49 +201,82 @@ def register_volumewise( progress_context = joblib_progress("Registering volumes...", total=n_frames) - with progress_context: - results = cast( - "list[tuple[xr.DataArray, npt.NDArray[np.floating] | None, RegistrationDiagnostics]]", # noqa: E501 - Parallel(n_jobs=n_jobs)( - delayed(register_volume)( - volume, - ref_da, - transform_type=transform, - metric=metric, - number_of_histogram_bins=number_of_histogram_bins, - learning_rate=learning_rate, - number_of_iterations=number_of_iterations, - convergence_minimum_value=convergence_minimum_value, - convergence_window_size=convergence_window_size, - centering_initialization=initialization, - optimizer_weights=optimizer_weights, - use_multi_resolution=use_multi_resolution, - shrink_factors=shrink_factors, - smoothing_sigmas=smoothing_sigmas, - resample=True, - resample_interpolation=resample_interpolation, - # Restrict SimpleITK to 1 thread per worker to avoid - # over-subscribing the CPU when joblib spawns many workers. - sitk_threads=1, - show_progress=False, + parallel_kwargs: dict[str, object] = {"n_jobs": n_jobs} + if abort_event is not None or progress_reporter is not None: + # Use threads when cancellation or progress reporting is enabled so + # every worker sees the shared reporter / event instance. + parallel_kwargs["prefer"] = "threads" + + def _register_one( + frame_index: int, + volume: xr.DataArray, + ) -> tuple[ + int, xr.DataArray, npt.NDArray[np.floating] | None, RegistrationDiagnostics + ]: + def _iteration_callback(iteration: int, _metric_value: float) -> None: + if progress_reporter is not None: + progress_reporter.iteration( + frame_index, + iteration, + number_of_iterations, ) - for volume in data_moved - ), + + registered_da, frame_affine, frame_diag = register_volume( + volume, + ref_da, + transform_type=transform, + metric=metric, + number_of_histogram_bins=number_of_histogram_bins, + learning_rate=learning_rate, + number_of_iterations=number_of_iterations, + convergence_minimum_value=convergence_minimum_value, + convergence_window_size=convergence_window_size, + centering_initialization=initialization, + optimizer_weights=optimizer_weights, + use_multi_resolution=use_multi_resolution, + shrink_factors=shrink_factors, + smoothing_sigmas=smoothing_sigmas, + resample=True, + resample_interpolation=resample_interpolation, + # Restrict SimpleITK to 1 thread per worker to avoid + # over-subscribing the CPU when joblib spawns many workers. + sitk_threads=1, + show_progress=False, + abort_event=abort_event, + iteration_callback=_iteration_callback + if progress_reporter is not None + else None, ) + return frame_index, registered_da, frame_affine, frame_diag arr = data_moved.values - output = np.zeros_like(arr) - affines: list[npt.NDArray[np.floating] | None] = [] - final_metric_values: list[float] = [] - n_iterations_per_frame: list[int] = [] - diagnostics: list[RegistrationDiagnostics] = [] - for t, (registered_da, frame_affine, frame_diag) in enumerate(results): - output[t] = registered_da.values - affines.append(frame_affine) - final_metric_values.append(frame_diag.final_metric_value) - n_iterations_per_frame.append(frame_diag.n_iterations) - if keep_diagnostics: - diagnostics.append(frame_diag) + output = np.array(arr, copy=True) + affines: list[npt.NDArray[np.floating] | None] = [None] * n_frames + final_metric_values = [float("nan")] * n_frames + n_iterations_per_frame = [0] * n_frames + statuses = ["aborted"] * n_frames + diagnostics: list[RegistrationDiagnostics | None] = [None] * n_frames + + try: + with progress_context: + results = Parallel(return_as="generator_unordered", **parallel_kwargs)( + delayed(_register_one)(t, volume) for t, volume in enumerate(data_moved) + ) + for t, registered_da, frame_affine, frame_diag in results: + skipped = ( + frame_diag.status == "aborted" and frame_diag.n_iterations == 0 + ) + output[t] = arr[t] if skipped else registered_da.values + affines[t] = None if skipped else frame_affine + final_metric_values[t] = frame_diag.final_metric_value + n_iterations_per_frame[t] = frame_diag.n_iterations + statuses[t] = frame_diag.status + diagnostics[t] = frame_diag + if progress_reporter is not None: + progress_reporter.frame_completed(t, registered_da, frame_diag) + finally: + if progress_reporter is not None: + progress_reporter.close() time_coords = ( data_moved.coords["time"].values if "time" in data_moved.coords else None @@ -238,6 +289,7 @@ def register_volumewise( # for spotting frames that failed to converge, so we always keep them. motion_df["final_metric_value"] = final_metric_values motion_df["n_iterations"] = n_iterations_per_frame + motion_df["status"] = statuses result = xr.DataArray( output, @@ -251,6 +303,8 @@ def register_volumewise( if keep_diagnostics: # The full diagnostics list carries every frame's optimizer metric # trace, which adds up over long recordings — gated behind the flag. - result.attrs["registration_diagnostics"] = diagnostics + result.attrs["registration_diagnostics"] = [ + cast("RegistrationDiagnostics", d) for d in diagnostics + ] return result.transpose(*data.dims) diff --git a/src/confusius/registration/volumewise_progress.py b/src/confusius/registration/volumewise_progress.py new file mode 100644 index 00000000..9b34a502 --- /dev/null +++ b/src/confusius/registration/volumewise_progress.py @@ -0,0 +1,61 @@ +"""Progress reporting protocol for `register_volumewise`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + import xarray as xr + + from confusius.registration import RegistrationDiagnostics + + +class VolumewiseProgressReporter(Protocol): + """Duck-typed contract for `register_volumewise` progress reporting. + + Implementations may be called from worker threads when volumewise + registration runs in parallel. Any GUI updates must therefore be marshalled + via thread-safe mechanisms such as Qt signals. + """ + + def iteration( + self, + frame_index: int, + iteration: int, + total_iterations: int, + ) -> None: + """Report the current optimizer iteration for one frame. + + Parameters + ---------- + frame_index : int + Index of the frame being optimized. + iteration : int + Current 1-indexed optimizer iteration for that frame. + total_iterations : int + Maximum number of iterations expected for that frame. + """ + ... + + def frame_completed( + self, + frame_index: int, + registered_frame: "xr.DataArray", + diagnostics: "RegistrationDiagnostics", + ) -> None: + """Report that one frame finished and provide its registered output. + + Parameters + ---------- + frame_index : int + Index of the completed frame. + registered_frame : xarray.DataArray + Registered frame output. + diagnostics : confusius.registration.RegistrationDiagnostics + Diagnostics collected for the completed frame. + """ + ... + + def close(self) -> None: + """Report that the full volumewise run has ended.""" + ... diff --git a/src/confusius/xarray/registration.py b/src/confusius/xarray/registration.py index 51605593..cfbd1206 100644 --- a/src/confusius/xarray/registration.py +++ b/src/confusius/xarray/registration.py @@ -67,8 +67,10 @@ def to_volume( number_of_histogram_bins : int, default: 50 Number of histogram bins (only used when `metric="mattes_mi"`). learning_rate : float or "auto", default: "auto" - Optimizer step size in normalised units (after `SetOptimizerScalesFromPhysicalShift`). - `"auto"` re-estimates the rate at every iteration. + Optimizer step size in normalised units (after + `SetOptimizerScalesFromPhysicalShift`). `"auto"` re-estimates the rate at + every iteration. A float uses that value directly; if registration diverges + or fails to converge, reduce it. number_of_iterations : int, default: 100 Maximum number of optimizer iterations. convergence_minimum_value : float, default: 1e-6 @@ -166,7 +168,7 @@ def volumewise( transform: Literal["translation", "rigid", "affine"] = "rigid", metric: Literal["correlation", "mattes_mi"] = "correlation", number_of_histogram_bins: int = 50, - learning_rate: float | Literal["auto"] = "auto", + learning_rate: float | Literal["auto"] = 0.01, number_of_iterations: int = 100, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, @@ -194,9 +196,11 @@ def volumewise( Similarity metric for registration. number_of_histogram_bins : int, default: 50 Number of histogram bins (only used when `metric="mattes_mi"`). - learning_rate : float or "auto", default: "auto" - Optimizer step size in normalised units (after `SetOptimizerScalesFromPhysicalShift`). - `"auto"` re-estimates the rate at every iteration. + learning_rate : float or "auto", default: 0.01 + Optimizer step size in normalised units (after + `SetOptimizerScalesFromPhysicalShift`). `"auto"` re-estimates the rate at + every iteration. A float uses that value directly; if registration diverges + or fails to converge, reduce it. number_of_iterations : int, default: 100 Maximum number of optimizer iterations. convergence_minimum_value : float, default: 1e-6 diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 5d28c084..719a8793 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from threading import Event import numpy as np import pytest @@ -36,6 +37,7 @@ class _FakeDiagnostics: final_metric_value: float = -1.0 n_iterations: int = 1 stop_condition: str = "done" + status: str = "completed" class TestRefreshLayers: @@ -61,25 +63,113 @@ def test_volumewise_hides_fixed_selector(self, registration_panel): registration_panel._time_series_radio.setChecked(True) assert registration_panel._fixed_combo.isHidden() assert not registration_panel._reference_time_spin.isHidden() - assert not registration_panel._n_jobs_spin.isHidden() + assert not registration_panel._n_jobs_row.isHidden() + + def test_parallel_jobs_is_in_advanced_parameters(self, registration_panel): + registration_panel._time_series_radio.setChecked(True) + registration_panel._advanced_toggle.setChecked(True) + + assert not registration_panel._n_jobs_row.isHidden() + assert registration_panel._n_jobs_spin.parent() is not None def test_volume_shows_fixed_selector(self, registration_panel): registration_panel._time_series_radio.setChecked(True) registration_panel._single_volume_radio.setChecked(True) assert not registration_panel._fixed_combo.isHidden() assert registration_panel._reference_time_spin.isHidden() - assert registration_panel._n_jobs_spin.isHidden() + assert registration_panel._n_jobs_row.isHidden() def test_defaults_transform_to_rigid(self, registration_panel): assert registration_panel._transform_combo.currentText() == "rigid" registration_panel._time_series_radio.setChecked(True) assert registration_panel._transform_combo.currentText() == "rigid" - def test_learning_rate_auto_disables_spinbox(self, registration_panel): + def test_learning_rate_auto_disables_edit(self, registration_panel): + assert registration_panel._learning_rate_auto_check.isChecked() + assert not registration_panel._learning_rate_edit.isEnabled() + registration_panel._learning_rate_auto_check.setChecked(False) + assert registration_panel._learning_rate_edit.isEnabled() + + def test_volumewise_learning_rate_defaults_to_fixed_0_01(self, registration_panel): + registration_panel._time_series_radio.setChecked(True) + + assert not registration_panel._learning_rate_auto_check.isChecked() + assert registration_panel._learning_rate_edit.isEnabled() + assert registration_panel._learning_rate_edit.value() == pytest.approx(0.01) + + def test_mode_switch_preserves_session_parameters(self, registration_panel): + registration_panel._time_series_radio.setChecked(True) + registration_panel._learning_rate_auto_check.setChecked(True) + registration_panel._learning_rate_edit.setValue(0.23) + registration_panel._n_jobs_spin.setValue(3) + + registration_panel._single_volume_radio.setChecked(True) + registration_panel._learning_rate_edit.setValue(0.42) + registration_panel._time_series_radio.setChecked(True) + assert registration_panel._learning_rate_auto_check.isChecked() - assert not registration_panel._learning_rate_spin.isEnabled() + assert registration_panel._learning_rate_edit.value() == pytest.approx(0.23) + assert registration_panel._n_jobs_spin.value() == 3 + + def test_advanced_group_is_collapsed_by_default(self, registration_panel): + assert not registration_panel._advanced_toggle.isChecked() + assert registration_panel._advanced_content.isHidden() + assert registration_panel._advanced_toggle.text() == "Advanced" + registration_panel._advanced_toggle.click() + assert not registration_panel._advanced_content.isHidden() + + def test_scientific_notation_spinboxes_parse_values(self, registration_panel): registration_panel._learning_rate_auto_check.setChecked(False) - assert registration_panel._learning_rate_spin.isEnabled() + registration_panel._learning_rate_edit.lineEdit().setText("1e-5") + registration_panel._learning_rate_edit.interpretText() + assert registration_panel._learning_rate_edit.value() == pytest.approx(1e-5) + + registration_panel._convergence_min_edit.lineEdit().setText("2.5e-7") + registration_panel._convergence_min_edit.interpretText() + assert registration_panel._convergence_min_edit.value() == pytest.approx(2.5e-7) + + def test_spinbox_defaults_and_minima(self, registration_panel): + assert registration_panel._learning_rate_edit.minimum() == pytest.approx(1e-10) + assert registration_panel._learning_rate_edit.value() == pytest.approx(0.1) + assert registration_panel._convergence_min_edit.minimum() == pytest.approx( + 1e-10 + ) + assert registration_panel._convergence_min_edit.value() == pytest.approx(1e-6) + + def test_metric_specific_rows_follow_metric(self, registration_panel): + registration_panel._advanced_toggle.setChecked(True) + assert registration_panel._metric_combo.currentText() == "correlation" + assert registration_panel._histogram_bins_row.isHidden() + + registration_panel._metric_combo.setCurrentText("mattes_mi") + assert not registration_panel._histogram_bins_row.isHidden() + + def test_multi_resolution_toggle_hides_dependent_inputs(self, registration_panel): + registration_panel._advanced_toggle.setChecked(True) + assert not registration_panel._multi_resolution_check.isChecked() + assert registration_panel._shrink_factors_row.isHidden() + assert registration_panel._smoothing_sigmas_row.isHidden() + + registration_panel._multi_resolution_check.setChecked(True) + + assert not registration_panel._shrink_factors_row.isHidden() + assert not registration_panel._smoothing_sigmas_row.isHidden() + + def test_initialization_is_in_basic_parameters(self, registration_panel): + assert registration_panel._initialization_combo.parent() is not None + + +class TestAbort: + def test_abort_sets_cancellation_event(self, registration_panel): + registration_panel._worker = object() + registration_panel._abort_event = Event() + registration_panel._begin_work() + + registration_panel._abort_registration() + + assert registration_panel._abort_event.is_set() + assert not registration_panel._abort_btn.isEnabled() + assert registration_panel._abort_btn.text() == "Aborting…" class TestValidation: @@ -178,6 +268,68 @@ def test_registration_panel_is_present_in_main_widget(self, viewer): assert "Registration" in widget._accordion_panels +class TestVolumewiseProgress: + def test_setup_updates_progress_bar_and_output_layer( + self, viewer, registration_panel + ): + from confusius._napari._registration._panel import _layer_to_dataarray + + moving = xr.DataArray( + np.linspace(-2.0, 3.0, 3 * 4 * 6, dtype=np.float32).reshape(3, 4, 6), + dims=["time", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(3), dims=["time"]), + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + moving_layer = viewer.add_image( + moving.values, + name="series", + metadata={"xarray": moving}, + ) + moving = _layer_to_dataarray(moving_layer) + + progress = registration_panel._setup_volumewise_progress( + moving_layer=moving_layer, + moving=moving, + layer_name="series registered", + total_iterations_per_frame=5, + ) + + assert registration_panel._volumewise_progress_layer is not None + assert registration_panel._progress.maximum() == 15 + assert registration_panel._progress.isTextVisible() + assert registration_panel._progress.minimumHeight() >= 18 + assert moving_layer.colormap.name == "red" + + preview_layer = viewer.layers["series registered"] + assert preview_layer.colormap.name == "cyan" + assert preview_layer.blending == "additive" + np.testing.assert_array_equal( + np.asarray(preview_layer.data), + np.full(moving.shape, float(moving.min()), dtype=np.float32), + ) + + progress.iteration(1, 2, 5) + assert registration_panel._progress.value() == 2 + + frame = xr.DataArray( + np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + progress.frame_completed(1, frame, _FakeDiagnostics(n_iterations=2)) + + np.testing.assert_array_equal( + np.asarray(viewer.layers["series registered"].data)[1], + np.asarray(frame.values), + ) + + class TestFinishedCallbacks: def test_volume_result_adds_new_layer_with_transform_metadata( self, viewer, registration_panel @@ -214,6 +366,7 @@ def test_volume_result_adds_new_layer_with_transform_metadata( layer = viewer.layers["moving → fixed"] assert layer.metadata["registration_transform"] is transform assert layer.metadata["registration_diagnostics"] is diagnostics + assert layer.metadata["registration_status"] == "completed" np.testing.assert_array_equal( affine_transform_from_payload(layer.metadata["confusius_transform"]), transform, @@ -360,9 +513,7 @@ def test_progress_layer_data_updates_on_iteration( assert moving.colormap.name == "cyan" assert moving.blending == "additive" - def test_setup_creates_metric_plotter_dock( - self, viewer, registration_panel - ): + def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): """`_setup_volume_progress` lazily creates and docks the metric plotter.""" moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") fixed = xr.DataArray( @@ -433,6 +584,7 @@ def test_volumewise_result_adds_registered_layer(self, viewer, registration_pane layer = viewer.layers["series registered"] assert layer.metadata["reference_time"] == 1 assert layer.metadata["registration_operation"] == "register_volumewise" + assert "registration_status" not in layer.metadata assert ( layer.metadata["xarray"].attrs["registration_operation"] == "register_volumewise" diff --git a/tests/unit/test_registration/test_volume.py b/tests/unit/test_registration/test_volume.py index ecc79703..346a6254 100644 --- a/tests/unit/test_registration/test_volume.py +++ b/tests/unit/test_registration/test_volume.py @@ -1,10 +1,14 @@ """Unit tests for single-volume registration.""" +import signal +from threading import Event + import numpy as np import pytest import xarray as xr from numpy.testing import assert_allclose, assert_array_equal +from confusius.registration._utils import abort_on_sigint from confusius.registration.diagnostics import RegistrationDiagnostics from confusius.registration.resampling import resample_like, resample_volume from confusius.registration.volume import register_volume @@ -61,6 +65,30 @@ def test_shape_mismatch_no_error( ) assert result.shape == moving.shape + def test_abort_event_returns_partial_result(self, sample_2d_dataarray_spatial): + """A pre-set abort event returns an aborted diagnostics record.""" + abort_event = Event() + abort_event.set() + + result, _transform, diagnostics = register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="translation", + abort_event=abort_event, + ) + + assert result.shape == sample_2d_dataarray_spatial.shape + assert diagnostics.status == "aborted" + assert diagnostics.n_iterations == 0 + + def test_abort_on_sigint_sets_abort_event(self): + """First Ctrl+C is converted into cooperative cancellation.""" + with abort_on_sigint(None) as abort_event: + handler = signal.getsignal(signal.SIGINT) + assert callable(handler) + handler(signal.SIGINT, None) + assert abort_event.is_set() + class TestRegisterVolumeOutput: """Output properties for register_volume.""" @@ -651,7 +679,9 @@ def test_explicit_default_value_overrides(self, sample_2d_dataarray_spatial): "x": sample_2d_dataarray_spatial.coords["x"].values[:8], }, ) - result = resample_like(moving, sample_2d_dataarray_spatial, np.eye(3), default_value=0.0) + result = resample_like( + moving, sample_2d_dataarray_spatial, np.eye(3), default_value=0.0 + ) assert float(result.values[-1, -1]) == pytest.approx(0.0, abs=1e-5) def test_output_coords_match_reference( @@ -882,4 +912,6 @@ def test_default_fill_value_is_moving_min(self): transform_type="translation", ) # Default fill should be moving.min() == 2.0, not 0.0. - assert float(result.values[0, 0]) == pytest.approx(float(moving.min()), abs=1e-5) + assert float(result.values[0, 0]) == pytest.approx( + float(moving.min()), abs=1e-5 + ) diff --git a/tests/unit/test_registration/test_volumewise.py b/tests/unit/test_registration/test_volumewise.py index 32ddae37..92225014 100644 --- a/tests/unit/test_registration/test_volumewise.py +++ b/tests/unit/test_registration/test_volumewise.py @@ -1,5 +1,7 @@ """Unit tests for volumewise registration functions.""" +from threading import Event + import numpy as np import pytest import xarray as xr @@ -9,6 +11,32 @@ from confusius.registration.volumewise import register_volumewise +class _FakeVolumewiseProgressReporter: + def __init__(self) -> None: + self.iterations: list[tuple[int, int, int]] = [] + self.completed_frames: list[int] = [] + self.closed = False + + def iteration( + self, + frame_index: int, + iteration: int, + total_iterations: int, + ) -> None: + self.iterations.append((frame_index, iteration, total_iterations)) + + def frame_completed( + self, + frame_index: int, + registered_frame: xr.DataArray, + diagnostics: RegistrationDiagnostics, + ) -> None: + self.completed_frames.append(frame_index) + + def close(self) -> None: + self.closed = True + + class TestRegisterVolumewise: """Tests for register_volumewise function.""" @@ -69,6 +97,62 @@ def _guarded_import(name, *args, **kwargs): assert result.shape == sample_2d_dataarray.shape + def test_abort_event_returns_partial_dataset(self, sample_2d_dataarray): + """A pre-set abort event returns an aborted partial dataset.""" + abort_event = Event() + abort_event.set() + + result = register_volumewise( + sample_2d_dataarray, + n_jobs=2, + transform="translation", + abort_event=abort_event, + ) + + assert result.shape == sample_2d_dataarray.shape + assert set(result.attrs["motion_params"]["status"]) == {"aborted"} + assert_allclose(result.values, sample_2d_dataarray.values) + + def test_progress_reporter_receives_iteration_and_frame_updates( + self, sample_2d_dataarray, monkeypatch + ): + reporter = _FakeVolumewiseProgressReporter() + + def _fake_register_volume(_volume, _ref_da, **kwargs): + iteration_callback = kwargs["iteration_callback"] + if iteration_callback is not None: + iteration_callback(1, -1.0) + iteration_callback(2, -0.5) + diagnostics = RegistrationDiagnostics( + metric="correlation", + metric_values=np.asarray([-1.0, -0.5]), + final_metric_value=-0.5, + n_iterations=2, + stop_condition="done", + status="completed", + ) + return _volume.copy(), np.eye(3), diagnostics + + monkeypatch.setattr( + "confusius.registration.volumewise.register_volume", + _fake_register_volume, + ) + + result = register_volumewise( + sample_2d_dataarray, + n_jobs=1, + transform="translation", + show_progress=False, + progress_reporter=reporter, + ) + + assert result.shape == sample_2d_dataarray.shape + assert reporter.iterations + assert sorted(reporter.completed_frames) == list( + range(sample_2d_dataarray.sizes["time"]) + ) + assert reporter.closed + def test_wrong_dimensionality_raises(self): """Data that is neither 2D+t nor 3D+t raises ValueError.""" # 1D+time = 2D total. From d7bac5dca12c5483676823a9f85af8ccf5bcecb3 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 17:15:25 +0100 Subject: [PATCH 06/72] chore(tests): remove unused imports --- tests/unit/test_datasets/test_registry.py | 1 - tests/unit/test_io/test_loadsave.py | 2 +- tests/unit/test_napari/test_registration_metric_plotter.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/test_datasets/test_registry.py b/tests/unit/test_datasets/test_registry.py index 40b186c6..30faae14 100644 --- a/tests/unit/test_datasets/test_registry.py +++ b/tests/unit/test_datasets/test_registry.py @@ -2,7 +2,6 @@ from __future__ import annotations -import pytest from confusius.datasets import list_datasets from confusius.datasets._registry import _REGISTRY diff --git a/tests/unit/test_io/test_loadsave.py b/tests/unit/test_io/test_loadsave.py index 38d52d6d..eaa29ceb 100644 --- a/tests/unit/test_io/test_loadsave.py +++ b/tests/unit/test_io/test_loadsave.py @@ -1,6 +1,6 @@ """Unit tests for confusius.io.loadsave module.""" -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch import numpy as np import pytest diff --git a/tests/unit/test_napari/test_registration_metric_plotter.py b/tests/unit/test_napari/test_registration_metric_plotter.py index dab3f9d0..f948c207 100644 --- a/tests/unit/test_napari/test_registration_metric_plotter.py +++ b/tests/unit/test_napari/test_registration_metric_plotter.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest -from qtpy.QtCore import Qt @pytest.fixture From 9008224c2384c7570af4493177f35813ca841155 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 17:29:46 +0100 Subject: [PATCH 07/72] chore(registration): refine panel plan and iteration step --- NAPARI_REGISTRATION_PLAN.md | 30 +++++++++++++++++++ src/confusius/_napari/_registration/_panel.py | 1 + .../test_napari/test_registration_panel.py | 1 + 3 files changed, 32 insertions(+) diff --git a/NAPARI_REGISTRATION_PLAN.md b/NAPARI_REGISTRATION_PLAN.md index 9b660a37..a21fec62 100644 --- a/NAPARI_REGISTRATION_PLAN.md +++ b/NAPARI_REGISTRATION_PLAN.md @@ -43,6 +43,13 @@ Deliver a thin but usable panel focused on running registrations and adding the - Both must be spatial-only volumes. - Result layer name should clearly indicate the fixed target. - Keep the estimated transform and diagnostics in metadata for later reuse. +- Future between-scan previews should use dedicated temporary napari layers + (Fixed / Moving / Resampled moving) instead of mutating the original + viewer layers in place. +- When time-series inputs are selected in between-scan mode, they should be + reduced to a time-averaged spatial volume before registration. +- Registration should support an optional intensity-scale preprocessing step + so fixed and moving previews live in the same display space. ### `register_volumewise` @@ -50,6 +57,9 @@ Deliver a thin but usable panel focused on running registrations and adding the - Uses a selected `reference_time`. - Adds the registered time series as a new layer. - Preserve motion metadata already returned by `register_volumewise`. +- Volumewise progress should also move toward separate napari layer objects + for preview/result state, while reusing the same underlying data whenever + possible to avoid unnecessary copies. ## Implementation notes @@ -101,6 +111,12 @@ Transform management. - Optional support for non-affine transform payloads in the future. - Decide whether volumewise should also hide / retint the source layer after completion, mirroring the single-volume workflow more closely. +- Support B-spline transform payloads in the Transforms tab if we want to save + and reload non-affine registrations. +- Make it possible to use an existing saved / computed transform as the + initialization transform for a new registration run. +- Make it possible to use the current napari layer transform as the + initialization transform. ### Phase 3 @@ -135,6 +151,11 @@ Progress integration. - During volumewise progress, the original layer is tinted red and the in-progress output layer is tinted cyan + additive for visual comparison. +#### Remaining polish + +- Fix the volumewise progress bar so it reliably reaches 100% on completion. +- Investigate and fix abort support on Windows. + ### Phase 5 Panel polish. @@ -175,6 +196,15 @@ Panel polish. - Better internal layout for the registration tab as it grows. - Unified payload support for manual napari-created transforms. - Optional support for non-affine transform payloads in the future. +- Add the B-spline `mesh_size` parameter to the basic parameters area and show + it only when the selected transform is `bspline`. +- Shorten and clarify the names of the temporary / output registration layers. +- Add an intensity-scale control (for example `dB`, `sqrt`, or off) to + registration previews and preprocessing. The default should be enabled and + use `dB`. +- Rework between-scan preview layers so fixed, moving, and registered-moving + are separate dedicated layers with appropriate contrast handling. +- Set the iterations spinbox step size to 100. ### Phase 6 diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 9b6b3589..151d2bf7 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -779,6 +779,7 @@ def _setup_ui(self) -> None: self._iterations_spin = QSpinBox() self._iterations_spin.setRange(1, 100_000) + self._iterations_spin.setSingleStep(100) self._iterations_spin.setValue(100) params_layout.addRow( self._make_form_label( diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 719a8793..57acb84e 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -135,6 +135,7 @@ def test_spinbox_defaults_and_minima(self, registration_panel): 1e-10 ) assert registration_panel._convergence_min_edit.value() == pytest.approx(1e-6) + assert registration_panel._iterations_spin.singleStep() == 100 def test_metric_specific_rows_follow_metric(self, registration_panel): registration_panel._advanced_toggle.setChecked(True) From 81ba6504e26b433938c965e86cd0b42d08429ea1 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 17:58:33 +0100 Subject: [PATCH 08/72] feat(registration): refine bspline mesh size controls --- src/confusius/_napari/_registration/_panel.py | 70 ++++++++++++++++++- .../test_napari/test_registration_panel.py | 23 ++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 151d2bf7..477a3ce1 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -12,8 +12,7 @@ from napari.layers.utils.layer_utils import calc_data_range from napari.qt.threading import thread_worker from napari.utils.notifications import show_error, show_info -from qtpy.QtCore import Qt, QTimer -from qtpy.QtCore import QRegularExpression +from qtpy.QtCore import QRegularExpression, Qt, QTimer from qtpy.QtGui import QValidator from qtpy.QtWidgets import ( QApplication, @@ -318,6 +317,7 @@ def _run_register_volume_registration_volume( number_of_iterations: int, use_multi_resolution: bool, resample_interpolation: Literal["linear", "bspline"], + mesh_size: tuple[int, int, int] = (10, 10, 10), number_of_histogram_bins: int = 50, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, @@ -350,6 +350,8 @@ def _run_register_volume_registration_volume( Whether to enable the registration pyramid. resample_interpolation : {"linear", "bspline"} Interpolator for the resampled output. + mesh_size : tuple of int, default: (10, 10, 10) + B-spline mesh size. number_of_histogram_bins : int Histogram bins for Mattes MI metric. convergence_minimum_value : float @@ -388,6 +390,7 @@ def _run_register_volume_registration_volume( use_multi_resolution=use_multi_resolution, resample=True, resample_interpolation=resample_interpolation, + mesh_size=mesh_size, number_of_histogram_bins=number_of_histogram_bins, convergence_minimum_value=convergence_minimum_value, convergence_window_size=convergence_window_size, @@ -719,6 +722,43 @@ def _setup_ui(self) -> None: self._transform_combo, ) + self._mesh_size_z_spin = QSpinBox() + self._mesh_size_z_spin.setRange(1, 512) + self._mesh_size_z_spin.setValue(10) + self._mesh_size_z_spin.setMaximumWidth(48) + self._mesh_size_z_spin.setToolTip("B-spline mesh size along z.") + self._mesh_size_y_spin = QSpinBox() + self._mesh_size_y_spin.setRange(1, 512) + self._mesh_size_y_spin.setValue(10) + self._mesh_size_y_spin.setMaximumWidth(48) + self._mesh_size_y_spin.setToolTip("B-spline mesh size along y.") + self._mesh_size_x_spin = QSpinBox() + self._mesh_size_x_spin.setRange(1, 512) + self._mesh_size_x_spin.setValue(10) + self._mesh_size_x_spin.setMaximumWidth(48) + self._mesh_size_x_spin.setToolTip("B-spline mesh size along x.") + self._mesh_size_row = QWidget() + mesh_size_layout = QVBoxLayout(self._mesh_size_row) + mesh_size_layout.setContentsMargins(0, 0, 0, 0) + mesh_size_layout.setSpacing(4) + mesh_size_label = self._make_form_label( + "Mesh size", + tooltip="B-spline mesh size used for B-spline registration.", + ) + mesh_size_layout.addWidget(mesh_size_label) + mesh_size_inputs = QHBoxLayout() + mesh_size_inputs.setContentsMargins(0, 0, 0, 0) + mesh_size_inputs.setSpacing(6) + mesh_size_inputs.addWidget(QLabel("Z")) + mesh_size_inputs.addWidget(self._mesh_size_z_spin) + mesh_size_inputs.addWidget(QLabel("Y")) + mesh_size_inputs.addWidget(self._mesh_size_y_spin) + mesh_size_inputs.addWidget(QLabel("X")) + mesh_size_inputs.addWidget(self._mesh_size_x_spin) + mesh_size_inputs.addStretch(1) + mesh_size_layout.addLayout(mesh_size_inputs) + params_layout.addRow(self._mesh_size_row) + self._metric_combo = QComboBox() self._metric_combo.setMinimumContentsLength(14) self._metric_combo.setSizeAdjustPolicy( @@ -976,9 +1016,13 @@ def _setup_ui(self) -> None: self._metric_combo.currentTextChanged.connect( self._update_metric_dependent_visibility ) + self._transform_combo.currentTextChanged.connect( + self._update_transform_dependent_visibility + ) self._on_advanced_toggled(False) self._update_multi_resolution_enabled(False) self._update_metric_dependent_visibility(self._metric_combo.currentText()) + self._update_transform_dependent_visibility(self._transform_combo.currentText()) self._register_panel = QWidget() register_layout = QVBoxLayout(self._register_panel) @@ -1383,6 +1427,12 @@ def _update_multi_resolution_enabled(self, checked: bool) -> None: self._shrink_factors_row.setVisible(checked) self._smoothing_sigmas_row.setVisible(checked) + def _update_transform_dependent_visibility(self, transform: str) -> None: + """Show or hide transform-specific basic parameters.""" + self._mesh_size_row.setVisible( + self._operation() == "register_volume" and transform == "bspline" + ) + def _snapshot_mode_parameters(self, *, is_volumewise: bool) -> dict[str, Any]: """Capture the current parameter state for one registration mode.""" return { @@ -1393,6 +1443,11 @@ def _snapshot_mode_parameters(self, *, is_volumewise: bool) -> dict[str, Any]: "learning_rate_value": self._learning_rate_edit.value(), "number_of_iterations": self._iterations_spin.value(), "number_of_histogram_bins": self._histogram_bins_spin.value(), + "mesh_size": ( + self._mesh_size_z_spin.value(), + self._mesh_size_y_spin.value(), + self._mesh_size_x_spin.value(), + ), "convergence_minimum_value": self._convergence_min_edit.value(), "convergence_window_size": self._convergence_window_spin.value(), "use_multi_resolution": self._multi_resolution_check.isChecked(), @@ -1438,6 +1493,10 @@ def _apply_mode_parameters( self._histogram_bins_spin.setValue( cast("int", params["number_of_histogram_bins"]) ) + mesh_size = cast("tuple[int, int, int]", params["mesh_size"]) + self._mesh_size_z_spin.setValue(mesh_size[0]) + self._mesh_size_y_spin.setValue(mesh_size[1]) + self._mesh_size_x_spin.setValue(mesh_size[2]) self._convergence_min_edit.setValue( cast("float", params["convergence_minimum_value"]) ) @@ -1463,6 +1522,7 @@ def _apply_mode_parameters( self._on_advanced_toggled(self._advanced_toggle.isChecked()) self._update_metric_dependent_visibility(self._metric_combo.currentText()) self._update_multi_resolution_enabled(self._multi_resolution_check.isChecked()) + self._update_transform_dependent_visibility(self._transform_combo.currentText()) def _on_mode_changed(self) -> None: """Update the panel when the registration mode changes.""" @@ -2012,6 +2072,11 @@ def _run_registration(self) -> None: "number_of_iterations": self._iterations_spin.value(), "use_multi_resolution": use_multi_res, "resample_interpolation": self._interpolation_combo.currentText(), + "mesh_size": ( + self._mesh_size_z_spin.value(), + self._mesh_size_y_spin.value(), + self._mesh_size_x_spin.value(), + ), "number_of_histogram_bins": self._histogram_bins_spin.value(), "convergence_minimum_value": convergence_minimum_value, "convergence_window_size": self._convergence_window_spin.value(), @@ -2068,6 +2133,7 @@ def _run_registration(self) -> None: resample_interpolation=cast( "Literal['linear', 'bspline']", payload["resample_interpolation"] ), + mesh_size=payload["mesh_size"] or (10, 10, 10), number_of_histogram_bins=payload["number_of_histogram_bins"], convergence_minimum_value=payload["convergence_minimum_value"], convergence_window_size=payload["convergence_window_size"], diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 57acb84e..4a1c7695 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -159,6 +159,29 @@ def test_multi_resolution_toggle_hides_dependent_inputs(self, registration_panel def test_initialization_is_in_basic_parameters(self, registration_panel): assert registration_panel._initialization_combo.parent() is not None + def test_mesh_size_is_basic_and_only_visible_for_bspline(self, registration_panel): + assert registration_panel._mesh_size_row.parent() is not None + assert registration_panel._mesh_size_row.isHidden() + assert registration_panel._mesh_size_z_spin.value() == 10 + assert registration_panel._mesh_size_y_spin.value() == 10 + assert registration_panel._mesh_size_x_spin.value() == 10 + + registration_panel._transform_combo.setCurrentText("bspline") + assert not registration_panel._mesh_size_row.isHidden() + + registration_panel._mesh_size_z_spin.setValue(5) + registration_panel._mesh_size_y_spin.setValue(7) + registration_panel._mesh_size_x_spin.setValue(9) + assert registration_panel._mesh_size_z_spin.value() == 5 + assert registration_panel._mesh_size_y_spin.value() == 7 + assert registration_panel._mesh_size_x_spin.value() == 9 + + registration_panel._transform_combo.setCurrentText("rigid") + assert registration_panel._mesh_size_row.isHidden() + + registration_panel._time_series_radio.setChecked(True) + assert registration_panel._mesh_size_row.isHidden() + class TestAbort: def test_abort_sets_cancellation_event(self, registration_panel): From 0d83b0ecd8b19ccb2a21a69598538e7204667c72 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 18:00:35 +0100 Subject: [PATCH 09/72] feat(registration): shorten preview and result layer names --- src/confusius/_napari/_registration/_panel.py | 28 +++++++++++++---- .../test_napari/test_registration_panel.py | 30 +++++++++---------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 477a3ce1..012ef596 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -1201,6 +1201,16 @@ def _transform_source_label( label = f"{label} — {suffix}" return label + def _volume_result_layer_name(self, moving_name: str, fixed_name: str) -> str: + """Return the napari layer name for between-scan registration output.""" + del moving_name, fixed_name + return "Registered" + + def _volumewise_result_layer_name(self, moving_name: str) -> str: + """Return the napari layer name for within-scan registration output.""" + del moving_name + return "Motion corrected" + def _refresh_transform_controls(self) -> None: """Refresh transform-related layer selectors.""" source_data = self._transform_source_combo.currentData() @@ -2114,8 +2124,9 @@ def _run_registration(self) -> None: moving_layer=cast("Image", moving_layer), fixed_layer=cast("Image", fixed_layer), fixed=fixed, - layer_name=( - f"{payload['moving_layer_name']} → {payload['fixed_layer_name']}" + layer_name=self._volume_result_layer_name( + cast("str", payload["moving_layer_name"]), + cast("str", payload["fixed_layer_name"]), ), ) @@ -2159,7 +2170,9 @@ def _run_registration(self) -> None: progress_reporter = self._setup_volumewise_progress( moving_layer=cast("Image", moving_layer), moving=moving, - layer_name=f"{payload['moving_layer_name']} registered", + layer_name=self._volumewise_result_layer_name( + cast("str", payload["moving_layer_name"]) + ), total_iterations_per_frame=payload["number_of_iterations"], ) @@ -2228,8 +2241,9 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non registered.attrs["registration_diagnostics"] = diagnostics registered.attrs["registration_operation"] = operation registered.attrs["registration_status"] = diagnostics.status - layer_name = ( - f"{payload['moving_layer_name']} → {payload['fixed_layer_name']}" + layer_name = self._volume_result_layer_name( + cast("str", payload["moving_layer_name"]), + cast("str", payload["fixed_layer_name"]), ) metadata: dict[str, Any] = { "registration_transform": transform, @@ -2252,7 +2266,9 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non registered = cast("xr.DataArray", result).copy(deep=False) registered.attrs = registered.attrs.copy() registered.attrs["registration_operation"] = operation - layer_name = f"{payload['moving_layer_name']} registered" + layer_name = self._volumewise_result_layer_name( + cast("str", payload["moving_layer_name"]) + ) metadata = { "motion_params": registered.attrs.get("motion_params"), "reference_time": payload["reference_time"], diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 4a1c7695..73882b7d 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -317,7 +317,7 @@ def test_setup_updates_progress_bar_and_output_layer( progress = registration_panel._setup_volumewise_progress( moving_layer=moving_layer, moving=moving, - layer_name="series registered", + layer_name="Motion corrected", total_iterations_per_frame=5, ) @@ -327,7 +327,7 @@ def test_setup_updates_progress_bar_and_output_layer( assert registration_panel._progress.minimumHeight() >= 18 assert moving_layer.colormap.name == "red" - preview_layer = viewer.layers["series registered"] + preview_layer = viewer.layers["Motion corrected"] assert preview_layer.colormap.name == "cyan" assert preview_layer.blending == "additive" np.testing.assert_array_equal( @@ -349,7 +349,7 @@ def test_setup_updates_progress_bar_and_output_layer( progress.frame_completed(1, frame, _FakeDiagnostics(n_iterations=2)) np.testing.assert_array_equal( - np.asarray(viewer.layers["series registered"].data)[1], + np.asarray(viewer.layers["Motion corrected"].data)[1], np.asarray(frame.values), ) @@ -387,7 +387,7 @@ def test_volume_result_adds_new_layer_with_transform_metadata( (registered, transform, diagnostics), ) - layer = viewer.layers["moving → fixed"] + layer = viewer.layers["Registered"] assert layer.metadata["registration_transform"] is transform assert layer.metadata["registration_diagnostics"] is diagnostics assert layer.metadata["registration_status"] == "completed" @@ -421,10 +421,10 @@ def test_volume_result_replaces_preview_layer( moving_layer=moving, fixed_layer=fixed_layer, fixed=fixed, - layer_name="moving → fixed", + layer_name="Registered", ) assert factory is not None - assert "moving → fixed" in {layer.name for layer in viewer.layers} + assert "Registered" in {layer.name for layer in viewer.layers} assert registration_panel._progress_layer is not None assert registration_panel._progress_bridge is not None # The fixed layer is tinted red so the cyan overlay reads as the @@ -440,7 +440,7 @@ def test_volume_result_replaces_preview_layer( # with the moving image resampled onto the fixed grid, so the first # frame is a meaningful "unaligned moving on fixed" view rather than # a zero-valued blank. - preview_layer = viewer.layers["moving → fixed"] + preview_layer = viewer.layers["Registered"] assert preview_layer.colormap.name == "cyan" assert preview_layer.blending == "additive" assert preview_layer.visible @@ -474,7 +474,7 @@ def test_volume_result_replaces_preview_layer( assert registration_panel._progress_bridge is None # The result layer picks up the same cyan + additive styling so the # red/cyan overlay survives past teardown. - result_layer = viewer.layers["moving → fixed"] + result_layer = viewer.layers["Registered"] assert result_layer.colormap.name == "cyan" assert result_layer.blending == "additive" # The moving layer stays hidden, with its cyan + additive tint, so @@ -507,24 +507,24 @@ def test_progress_layer_data_updates_on_iteration( moving_layer=moving, fixed_layer=fixed_layer, fixed=fixed, - layer_name="moving → fixed", + layer_name="Registered", ) # The preview is seeded with the moving image resampled onto the # fixed grid, so it's visible and meaningful from the start. - preview_layer = viewer.layers["moving → fixed"] + preview_layer = viewer.layers["Registered"] assert preview_layer.visible next_arr = np.full((4, 6), 0.5, dtype=np.float32) registration_panel._update_progress_layer(next_arr) np.testing.assert_array_equal( - np.asarray(viewer.layers["moving → fixed"].data), next_arr + np.asarray(viewer.layers["Registered"].data), next_arr ) # Shape mismatch is silently ignored. registration_panel._update_progress_layer(np.zeros((3, 6), dtype=np.float32)) np.testing.assert_array_equal( - np.asarray(viewer.layers["moving → fixed"].data), next_arr + np.asarray(viewer.layers["Registered"].data), next_arr ) # Teardown removes the preview; the moving layer stays hidden with @@ -532,7 +532,7 @@ def test_progress_layer_data_updates_on_iteration( registration_panel._teardown_volume_progress() assert registration_panel._progress_layer is None assert registration_panel._progress_bridge is None - assert "moving → fixed" not in {layer.name for layer in viewer.layers} + assert "Registered" not in {layer.name for layer in viewer.layers} assert not moving.visible assert moving.colormap.name == "cyan" assert moving.blending == "additive" @@ -557,7 +557,7 @@ def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): moving_layer=moving, fixed_layer=fixed_layer, fixed=fixed, - layer_name="moving → fixed", + layer_name="Registered", ) assert registration_panel._metric_plotter is not None @@ -605,7 +605,7 @@ def test_volumewise_result_adds_registered_layer(self, viewer, registration_pane registration_panel._on_registration_finished(payload, registered) - layer = viewer.layers["series registered"] + layer = viewer.layers["Motion corrected"] assert layer.metadata["reference_time"] == 1 assert layer.metadata["registration_operation"] == "register_volumewise" assert "registration_status" not in layer.metadata From 9d3bff7710c9b198313d78a4fff8616440e04c81 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 18:15:06 +0100 Subject: [PATCH 10/72] feat(registration): average time series in between-scan mode --- src/confusius/_napari/_registration/_panel.py | 60 +++++++++---- .../test_napari/test_registration_panel.py | 84 +++++++++++++++++++ 2 files changed, 128 insertions(+), 16 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 012ef596..20fe5f64 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -179,6 +179,27 @@ def _layer_to_dataarray(layer: "Layer") -> xr.DataArray: return xr.DataArray(data, dims=axis_labels, coords=coords) +def _prepare_between_scan_data(data: xr.DataArray) -> xr.DataArray: + """Return a spatial-only DataArray for between-scan registration. + + Parameters + ---------- + data : xarray.DataArray + Input layer data. + + Returns + ------- + xarray.DataArray + Spatial-only data. If the input has a time dimension, it is averaged + over time with attributes preserved. + """ + if TIME_DIM not in data.dims: + return data + averaged = data.mean(dim=TIME_DIM, keep_attrs=True) + averaged.attrs = data.attrs.copy() + return averaged + + def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: """Return image-display kwargs copied from an existing napari layer. @@ -1363,7 +1384,7 @@ def _validate_registration_selection(self) -> bool: self._set_run_btn_enabled(True) return True - moving_invalid = TIME_DIM in moving.dims + moving_invalid = False fixed_invalid = False message: str | None = None @@ -1377,7 +1398,7 @@ def _validate_registration_selection(self) -> bool: return False try: - fixed = _layer_to_dataarray(fixed_layer) + _layer_to_dataarray(fixed_layer) except Exception: self._set_layer_validation_style( fixed_invalid=True, @@ -1390,10 +1411,6 @@ def _validate_registration_selection(self) -> bool: moving_invalid = True fixed_invalid = True message = "Moving and fixed layers must be different." - elif TIME_DIM in moving.dims or TIME_DIM in fixed.dims: - moving_invalid = TIME_DIM in moving.dims - fixed_invalid = TIME_DIM in fixed.dims - message = "Between-scans registration requires spatial-only layers." valid = not (moving_invalid or fixed_invalid) self._set_layer_validation_style( @@ -1683,6 +1700,7 @@ def _setup_volume_progress( *, moving_layer: "Image", fixed_layer: "Image", + moving: xr.DataArray, fixed: xr.DataArray, layer_name: str, ) -> "Callable[..., RegistrationProgress] | None": @@ -1704,9 +1722,11 @@ def _setup_volume_progress( fixed_layer : napari.layers.Layer Fixed reference layer. Defines the shape, scale, translate, and coordinate system of the preview/output layer. + moving : xarray.DataArray + Spatial-only moving data used to seed the preview layer. fixed : xarray.DataArray - DataArray view of `fixed_layer`, used to build the empty preview - grid. + Spatial-only DataArray view of `fixed_layer`, used to build the + empty preview grid. layer_name : str Name for the preview (and later final) layer. @@ -1753,9 +1773,8 @@ def _setup_volume_progress( # overwrite the data in place as the registration progresses. try: identity = np.eye(fixed.ndim + 1, dtype=float) - moving_da = _layer_to_dataarray(moving_layer) preview = resample_like( - moving_da, + moving, fixed, identity, interpolation=cast( @@ -1877,7 +1896,10 @@ def _ensure_metric_plotter(self) -> RegistrationMetricPlotter | None: # Mirror the HiDPI click-offset fix from the SignalPanel so the # canvas paints at the right device-pixel ratio the first time. def _settle_layout() -> None: - main_win = self._find_main_window(dock) + try: + main_win = self._find_main_window(dock) + except RuntimeError: + return if main_win is None: return from qtpy.QtCore import QSize @@ -1917,11 +1939,17 @@ def _find_main_window(self, widget: QWidget) -> QMainWindow | None: QMainWindow or None The main window if found, otherwise None. """ - parent = widget.parent() + try: + parent = widget.parent() + except RuntimeError: + return None while parent is not None: if isinstance(parent, QMainWindow): return parent - parent = parent.parent() + try: + parent = parent.parent() + except RuntimeError: + return None return None def _end_work(self) -> None: @@ -2114,15 +2142,15 @@ def _run_registration(self) -> None: self._set_error(str(exc)) return - if TIME_DIM in moving.dims or TIME_DIM in fixed.dims: - self._set_error("register_volume requires spatial-only layers.") - return + moving = _prepare_between_scan_data(moving) + fixed = _prepare_between_scan_data(fixed) payload["fixed_layer_name"] = fixed_layer.name progress_plotter = self._setup_volume_progress( moving_layer=cast("Image", moving_layer), fixed_layer=cast("Image", fixed_layer), + moving=moving, fixed=fixed, layer_name=self._volume_result_layer_name( cast("str", payload["moving_layer_name"]), diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 73882b7d..b53c91d6 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -229,6 +229,63 @@ def test_within_scan_requires_time_dimension(self, viewer, registration_panel): in registration_panel._layer_validation.text() ) + def test_between_scans_accepts_time_series_by_averaging( + self, viewer, registration_panel + ): + moving = xr.DataArray( + np.zeros((3, 4, 6), dtype=np.float32), + dims=["time", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(3), dims=["time"]), + "y": xr.DataArray(np.arange(4), dims=["y"]), + "x": xr.DataArray(np.arange(6), dims=["x"]), + }, + ) + fixed = xr.DataArray( + np.ones((3, 4, 6), dtype=np.float32), + dims=["time", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(3), dims=["time"]), + "y": xr.DataArray(np.arange(4), dims=["y"]), + "x": xr.DataArray(np.arange(6), dims=["x"]), + }, + ) + viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + viewer.add_image(fixed.values, name="fixed", metadata={"xarray": fixed}) + registration_panel._refresh_layers() + registration_panel._moving_combo.setCurrentText("moving") + registration_panel._fixed_combo.setCurrentText("fixed") + + assert registration_panel._validate_registration_selection() + + +class TestBetweenScanPreparation: + def test_prepare_between_scan_data_averages_time(self): + from confusius._napari._registration._panel import _prepare_between_scan_data + + data = xr.DataArray( + np.stack( + [ + np.zeros((4, 6), dtype=np.float32), + np.ones((4, 6), dtype=np.float32), + ], + axis=0, + ), + dims=["time", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(2), dims=["time"]), + "y": xr.DataArray(np.arange(4), dims=["y"]), + "x": xr.DataArray(np.arange(6), dims=["x"]), + }, + attrs={"foo": "bar"}, + ) + + averaged = _prepare_between_scan_data(data) + + assert averaged.dims == ("y", "x") + assert averaged.attrs["foo"] == "bar" + np.testing.assert_allclose(averaged.values, 0.5) + class TestLayerToDataArray: def test_reconstructs_dataarray_from_generic_layer(self, viewer): @@ -406,6 +463,14 @@ def test_volume_result_replaces_preview_layer( """A preview layer created by `_setup_volume_progress` is removed after `_on_registration_finished` so the final result is the only layer with that name.""" + moving_data = xr.DataArray( + np.zeros((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") fixed = xr.DataArray( np.ones((4, 6), dtype=np.float32), @@ -420,6 +485,7 @@ def test_volume_result_replaces_preview_layer( factory = registration_panel._setup_volume_progress( moving_layer=moving, fixed_layer=fixed_layer, + moving=moving_data, fixed=fixed, layer_name="Registered", ) @@ -492,6 +558,14 @@ def test_progress_layer_data_updates_on_iteration( ): """`_update_progress_layer` writes the iterated array into the preview layer's data, refreshing the canvas.""" + moving_data = xr.DataArray( + np.zeros((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") fixed = xr.DataArray( np.zeros((4, 6), dtype=np.float32), @@ -506,6 +580,7 @@ def test_progress_layer_data_updates_on_iteration( registration_panel._setup_volume_progress( moving_layer=moving, fixed_layer=fixed_layer, + moving=moving_data, fixed=fixed, layer_name="Registered", ) @@ -539,6 +614,14 @@ def test_progress_layer_data_updates_on_iteration( def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): """`_setup_volume_progress` lazily creates and docks the metric plotter.""" + moving_data = xr.DataArray( + np.zeros((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") fixed = xr.DataArray( np.zeros((4, 6), dtype=np.float32), @@ -556,6 +639,7 @@ def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): registration_panel._setup_volume_progress( moving_layer=moving, fixed_layer=fixed_layer, + moving=moving_data, fixed=fixed, layer_name="Registered", ) From 34b414a9e79c6b3892f22f79dc2a2dddc5c1e279 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 18:30:30 +0100 Subject: [PATCH 11/72] feat(registration): keep preview layers after runs --- src/confusius/_napari/_registration/_panel.py | 98 +++++++++++-------- .../test_napari/test_registration_panel.py | 80 ++++++++------- 2 files changed, 102 insertions(+), 76 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 20fe5f64..1c0ae288 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -553,14 +553,12 @@ def __init__(self, viewer: napari.Viewer) -> None: # Per-run progress state. Set on the GUI thread before the worker starts. self._progress_bridge: NapariProgressBridge | None = None self._progress_layer: Image | None = None + self._progress_fixed_layer: Image | None = None + self._progress_moving_layer: Image | None = None self._volumewise_progress_bridge: NapariVolumewiseProgressBridge | None = None self._volumewise_progress_layer: Image | None = None self._volumewise_progress_time_axis: int | None = None self._volumewise_progress_total: int | None = None - # Moving layer hidden during the run so the resampled preview can - # overlay the fixed layer without a duplicate moving/final-image - # overlap. Visibility is not restored on teardown. - self._progress_hidden_layer: Image | None = None # Bottom-dock metric curve. Created lazily on the first run, reused # across subsequent runs, and torn down with the progress state. self._metric_plotter: RegistrationMetricPlotter | None = None @@ -1227,6 +1225,18 @@ def _volume_result_layer_name(self, moving_name: str, fixed_name: str) -> str: del moving_name, fixed_name return "Registered" + def _volume_preview_layer_name(self) -> str: + """Return the napari layer name for between-scan progress preview.""" + return "Resampled moving" + + def _volume_fixed_preview_layer_name(self) -> str: + """Return the napari layer name for the fixed preview layer.""" + return "Fixed" + + def _volume_moving_preview_layer_name(self) -> str: + """Return the napari layer name for the moving preview layer.""" + return "Moving" + def _volumewise_result_layer_name(self, moving_name: str) -> str: """Return the napari layer name for within-scan registration output.""" del moving_name @@ -1739,18 +1749,14 @@ def _setup_volume_progress( """ self._teardown_volume_progress() - # Tint the fixed layer red so the resampled preview can overlay it via - # the classic red/cyan alignment view. The tint persists after the - # run; the user can reset it via the layer controls. - fixed_layer.colormap = "red" + fixed_display_kwargs = _image_display_kwargs_from_layer(fixed_layer) + fixed_display_kwargs["colormap"] = "red" - # Re-tint the moving layer cyan and switch it to additive blending so - # the registered overlay keeps the red/cyan alignment look if the - # user re-enables it after the run. - moving_layer.colormap = "cyan" - moving_layer.blending = "additive" + moving_display_kwargs = _image_display_kwargs_from_layer(moving_layer) + moving_display_kwargs["colormap"] = "cyan" + moving_display_kwargs["blending"] = "additive" - display_kwargs = _image_display_kwargs_from_layer(moving_layer) + display_kwargs = dict(moving_display_kwargs) # Seed contrast limits with the moving layer so the preview is shown in # the same intensity space as the final resampled volume. moving_contrast = getattr(moving_layer, "contrast_limits", None) @@ -1794,6 +1800,20 @@ def _setup_volume_progress( ) try: + _, fixed_preview_layer = plot_napari( + fixed, + viewer=self.viewer, + name=self._volume_fixed_preview_layer_name(), + show_colorbar=False, + **fixed_display_kwargs, + ) + _, moving_preview_layer = plot_napari( + moving, + viewer=self.viewer, + name=self._volume_moving_preview_layer_name(), + show_colorbar=False, + **moving_display_kwargs, + ) _, layer = plot_napari( preview, viewer=self.viewer, @@ -1812,11 +1832,9 @@ def _setup_volume_progress( # no extra slot is required here. self._progress_bridge = bridge self._progress_layer = cast("Image", layer) - # Hide the moving layer *before* the worker starts so the resampled - # preview never overlaps with the original input. The hidden state - # persists past teardown. - self._progress_hidden_layer = moving_layer - self._progress_hidden_layer.visible = False + self._progress_fixed_layer = cast("Image", fixed_preview_layer) + self._progress_moving_layer = cast("Image", moving_preview_layer) + self._progress_moving_layer.visible = False # Lazily build the bottom-dock metric plotter. The widget is reused # across runs; only the data buffer is reset. @@ -1859,19 +1877,21 @@ def _teardown_volume_progress(self) -> None: The metric plotter is kept (docked, with its final trace) so the user can inspect the convergence curve after the run. """ - if self._progress_layer is not None: - try: - self.viewer.layers.remove(self._progress_layer) - except (KeyError, ValueError): - pass - self._progress_layer = None + for attr_name in ( + "_progress_layer", + "_progress_fixed_layer", + "_progress_moving_layer", + ): + layer = cast("Image | None", getattr(self, attr_name)) + if layer is not None: + try: + self.viewer.layers.remove(layer) + except (KeyError, ValueError): + pass + setattr(self, attr_name, None) # Drop the bridge reference; the plotter connection becomes inert # when the bridge is garbage-collected. self._progress_bridge = None - # Drop the reference without restoring visibility: the moving layer - # stays hidden so the resampled output remains the visible moving - # stand-in after the run. - self._progress_hidden_layer = None def _ensure_metric_plotter(self) -> RegistrationMetricPlotter | None: """Return the bottom-dock metric plotter, creating and docking it on first use. @@ -2152,10 +2172,7 @@ def _run_registration(self) -> None: fixed_layer=cast("Image", fixed_layer), moving=moving, fixed=fixed, - layer_name=self._volume_result_layer_name( - cast("str", payload["moving_layer_name"]), - cast("str", payload["fixed_layer_name"]), - ), + layer_name=self._volume_preview_layer_name(), ) worker = thread_worker(_run_register_volume_registration_volume)( @@ -2252,12 +2269,6 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non """ operation = cast(str, payload["operation"]) - # Remove the live preview layer if one was created. The freshly-added - # result layer is the authoritative output; the preview is just visual - # scaffolding during optimisation. - if operation == "register_volume": - self._teardown_volume_progress() - if operation == "register_volume": registered, transform, diagnostics = cast( "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics]", @@ -2320,7 +2331,14 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non display_kwargs["blending"] = "additive" contrast_limits = tuple(calc_data_range(registered.data)) - if ( + if operation == "register_volume" and self._progress_layer is not None: + layer = self._progress_layer + layer.data = np.asarray(registered.data) # type: ignore[invalid-assignment] + layer.name = layer_name + if hasattr(layer, "contrast_limits"): + layer.contrast_limits = contrast_limits + self._progress_bridge = None + elif ( operation == "register_volumewise" and self._volumewise_progress_layer is not None ): diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index b53c91d6..ffec5228 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -487,26 +487,29 @@ def test_volume_result_replaces_preview_layer( fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, - layer_name="Registered", + layer_name="Resampled moving", ) assert factory is not None - assert "Registered" in {layer.name for layer in viewer.layers} + assert {"Fixed", "Moving", "Resampled moving"}.issubset( + {layer.name for layer in viewer.layers} + ) assert registration_panel._progress_layer is not None assert registration_panel._progress_bridge is not None - # The fixed layer is tinted red so the cyan overlay reads as the - # classic red/cyan alignment view. - assert fixed_layer.colormap.name == "red" - # The moving layer is re-tinted cyan + additive, then hidden before - # the worker starts so the resampled preview never overlaps it. - assert moving.colormap.name == "cyan" - assert moving.blending == "additive" - assert registration_panel._progress_hidden_layer is moving - assert not moving.visible - # The preview is rendered in cyan with additive blending and seeded - # with the moving image resampled onto the fixed grid, so the first - # frame is a meaningful "unaligned moving on fixed" view rather than - # a zero-valued blank. - preview_layer = viewer.layers["Registered"] + assert registration_panel._progress_fixed_layer is not None + assert registration_panel._progress_moving_layer is not None + # Original layers are left untouched. + assert fixed_layer.colormap.name != "red" + assert moving.colormap.name != "cyan" + assert moving.blending != "additive" + assert moving.visible + # Dedicated preview layers carry the registration styling. + fixed_preview = viewer.layers["Fixed"] + moving_preview = viewer.layers["Moving"] + preview_layer = viewer.layers["Resampled moving"] + assert fixed_preview.colormap.name == "red" + assert moving_preview.colormap.name == "cyan" + assert moving_preview.blending == "additive" + assert not moving_preview.visible assert preview_layer.colormap.name == "cyan" assert preview_layer.blending == "additive" assert preview_layer.visible @@ -535,19 +538,23 @@ def test_volume_result_replaces_preview_layer( (registered, transform, diagnostics), ) - # Preview has been torn down; the result layer is the only match. - assert registration_panel._progress_layer is None + # The resampled preview is kept and promoted to the final registered + # layer so the user can keep reviewing the fixed / moving / result + # stack after the run. + assert registration_panel._progress_layer is viewer.layers["Registered"] assert registration_panel._progress_bridge is None - # The result layer picks up the same cyan + additive styling so the - # red/cyan overlay survives past teardown. + assert {"Fixed", "Moving", "Registered"}.issubset( + {layer.name for layer in viewer.layers} + ) + assert not viewer.layers["Moving"].visible result_layer = viewer.layers["Registered"] assert result_layer.colormap.name == "cyan" assert result_layer.blending == "additive" - # The moving layer stays hidden, with its cyan + additive tint, so - # the registered output remains the visible stand-in. - assert not moving.visible - assert moving.colormap.name == "cyan" - assert moving.blending == "additive" + # Original source layers remain untouched. + assert moving.visible + assert moving.colormap.name != "cyan" + assert moving.blending != "additive" + assert fixed_layer.colormap.name != "red" assert np.array_equal( np.asarray(result_layer.data), np.asarray(registered.values), @@ -582,35 +589,36 @@ def test_progress_layer_data_updates_on_iteration( fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, - layer_name="Registered", + layer_name="Resampled moving", ) # The preview is seeded with the moving image resampled onto the # fixed grid, so it's visible and meaningful from the start. - preview_layer = viewer.layers["Registered"] + preview_layer = viewer.layers["Resampled moving"] assert preview_layer.visible next_arr = np.full((4, 6), 0.5, dtype=np.float32) registration_panel._update_progress_layer(next_arr) np.testing.assert_array_equal( - np.asarray(viewer.layers["Registered"].data), next_arr + np.asarray(viewer.layers["Resampled moving"].data), next_arr ) # Shape mismatch is silently ignored. registration_panel._update_progress_layer(np.zeros((3, 6), dtype=np.float32)) np.testing.assert_array_equal( - np.asarray(viewer.layers["Registered"].data), next_arr + np.asarray(viewer.layers["Resampled moving"].data), next_arr ) - # Teardown removes the preview; the moving layer stays hidden with - # its cyan + additive styling. + # Teardown removes the preview layers while leaving the originals untouched. registration_panel._teardown_volume_progress() assert registration_panel._progress_layer is None assert registration_panel._progress_bridge is None - assert "Registered" not in {layer.name for layer in viewer.layers} - assert not moving.visible - assert moving.colormap.name == "cyan" - assert moving.blending == "additive" + assert "Resampled moving" not in {layer.name for layer in viewer.layers} + assert "Fixed" not in {layer.name for layer in viewer.layers} + assert "Moving" not in {layer.name for layer in viewer.layers} + assert moving.visible + assert moving.colormap.name != "cyan" + assert moving.blending != "additive" def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): """`_setup_volume_progress` lazily creates and docks the metric plotter.""" @@ -641,7 +649,7 @@ def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, - layer_name="Registered", + layer_name="Resampled moving", ) assert registration_panel._metric_plotter is not None From d8a3a8cde3f62196246d46da3815468cea8879a9 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 20:24:08 +0100 Subject: [PATCH 12/72] feat(registration): keep volumewise preview layers --- src/confusius/_napari/_registration/_panel.py | 54 ++++++++++++++---- .../test_napari/test_registration_panel.py | 55 ++++++++++++++++++- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 1c0ae288..5fe5b96a 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -557,6 +557,7 @@ def __init__(self, viewer: napari.Viewer) -> None: self._progress_moving_layer: Image | None = None self._volumewise_progress_bridge: NapariVolumewiseProgressBridge | None = None self._volumewise_progress_layer: Image | None = None + self._volumewise_moving_preview_layer: Image | None = None self._volumewise_progress_time_axis: int | None = None self._volumewise_progress_total: int | None = None # Bottom-dock metric curve. Created lazily on the first run, reused @@ -1242,6 +1243,10 @@ def _volumewise_result_layer_name(self, moving_name: str) -> str: del moving_name return "Motion corrected" + def _volumewise_moving_preview_layer_name(self) -> str: + """Return the napari layer name for the within-scan moving preview.""" + return "Moving" + def _refresh_transform_controls(self) -> None: """Refresh transform-related layer selectors.""" source_data = self._transform_source_combo.currentData() @@ -1628,15 +1633,33 @@ def _setup_volumewise_progress( """Create a progress bridge and output layer for volumewise registration.""" self._teardown_volumewise_progress(remove_layer=True) - moving_layer.colormap = "red" + moving_display_kwargs = _image_display_kwargs_from_layer(moving_layer) + moving_display_kwargs["colormap"] = "red" - display_kwargs = _image_display_kwargs_from_layer(moving_layer) + display_kwargs = dict(moving_display_kwargs) display_kwargs["colormap"] = "cyan" display_kwargs["blending"] = "additive" contrast_limits = tuple(calc_data_range(moving.data)) - preview = moving.copy(deep=True) - preview.data[...] = float(np.min(moving.data)) + preview_data = np.full( + moving.shape, + fill_value=float(np.min(moving.data)), + dtype=np.asarray(moving.data).dtype, + ) + preview = xr.DataArray( + preview_data, + dims=moving.dims, + coords=moving.coords, + attrs=moving.attrs.copy(), + ) + _, moving_preview_layer = plot_napari( + moving, + viewer=self.viewer, + name=self._volumewise_moving_preview_layer_name(), + show_colorbar=False, + contrast_limits=contrast_limits, + **moving_display_kwargs, + ) _, layer = plot_napari( preview, viewer=self.viewer, @@ -1651,6 +1674,7 @@ def _setup_volumewise_progress( self._volumewise_progress_bridge = bridge self._volumewise_progress_layer = cast("Image", layer) + self._volumewise_moving_preview_layer = cast("Image", moving_preview_layer) self._volumewise_progress_time_axis = moving.dims.index(TIME_DIM) self._volumewise_progress_total = ( moving.sizes[TIME_DIM] * total_iterations_per_frame @@ -1683,7 +1707,7 @@ def _update_volumewise_progress_frame( if layer is None or time_axis is None or not isinstance(arr, np.ndarray): return - data = np.asarray(layer.data).copy() + data = np.asarray(layer.data) if time_axis >= data.ndim: return index = tuple( @@ -1691,17 +1715,23 @@ def _update_volumewise_progress_frame( for axis in range(data.ndim) ) data[index] = arr - layer.data = data # type: ignore[invalid-assignment] + layer.refresh() def _teardown_volumewise_progress(self, *, remove_layer: bool) -> None: """Reset volumewise progress-layer state.""" - if remove_layer and self._volumewise_progress_layer is not None: - try: - self.viewer.layers.remove(self._volumewise_progress_layer) - except (KeyError, ValueError): - pass + if remove_layer: + for attr_name in ( + "_volumewise_progress_layer", + "_volumewise_moving_preview_layer", + ): + layer = cast("Image | None", getattr(self, attr_name)) + if layer is not None: + try: + self.viewer.layers.remove(layer) + except (KeyError, ValueError): + pass + setattr(self, attr_name, None) self._volumewise_progress_bridge = None - self._volumewise_progress_layer = None self._volumewise_progress_time_axis = None self._volumewise_progress_total = None diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index ffec5228..7668e932 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -379,11 +379,14 @@ def test_setup_updates_progress_bar_and_output_layer( ) assert registration_panel._volumewise_progress_layer is not None + assert registration_panel._volumewise_moving_preview_layer is not None assert registration_panel._progress.maximum() == 15 assert registration_panel._progress.isTextVisible() assert registration_panel._progress.minimumHeight() >= 18 - assert moving_layer.colormap.name == "red" + assert moving_layer.colormap.name != "red" + moving_preview_layer = viewer.layers["Moving"] + assert moving_preview_layer.colormap.name == "red" preview_layer = viewer.layers["Motion corrected"] assert preview_layer.colormap.name == "cyan" assert preview_layer.blending == "additive" @@ -705,3 +708,53 @@ def test_volumewise_result_adds_registered_layer(self, viewer, registration_pane layer.metadata["xarray"].attrs["registration_operation"] == "register_volumewise" ) + + def test_volumewise_finished_keeps_preview_layers( + self, viewer, registration_panel + ): + moving = xr.DataArray( + np.zeros((3, 4, 6), dtype=np.float32), + dims=["time", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(3), dims=["time"]), + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + moving_layer = viewer.add_image( + moving.values, + name="series", + metadata={"xarray": moving}, + ) + registration_panel._setup_volumewise_progress( + moving_layer=moving_layer, + moving=moving, + layer_name="Motion corrected", + total_iterations_per_frame=5, + ) + + registered = xr.DataArray( + np.ones((3, 4, 6), dtype=np.float32), + dims=["time", "y", "x"], + coords=moving.coords, + attrs={"motion_params": object()}, + ) + payload = { + "operation": "register_volumewise", + "moving_layer_name": "series", + "transform": "rigid", + "metric": "correlation", + "learning_rate": "auto", + "number_of_iterations": 100, + "use_multi_resolution": False, + "resample_interpolation": "linear", + "reference_time": 1, + } + + registration_panel._on_registration_finished(payload, registered) + + assert {"Moving", "Motion corrected"}.issubset( + {layer.name for layer in viewer.layers} + ) + assert viewer.layers["series"].colormap.name != "red" + assert viewer.layers["Moving"].colormap.name == "red" From 9dfb63a51d595b8c843dc87060cf7e37dd1be40e Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 22:01:18 +0100 Subject: [PATCH 13/72] refactor(registration): simplify volumewise progress --- src/confusius/_napari/_registration/_panel.py | 17 +++---- .../_napari/_registration/_progress.py | 50 +++---------------- src/confusius/registration/volumewise.py | 17 ++----- .../registration/volumewise_progress.py | 19 ------- .../test_napari/test_registration_panel.py | 47 +++++++++++++++-- .../unit/test_registration/test_volumewise.py | 16 +----- 6 files changed, 60 insertions(+), 106 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 5fe5b96a..e49f08d6 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -1628,7 +1628,6 @@ def _setup_volumewise_progress( moving_layer: "Image", moving: xr.DataArray, layer_name: str, - total_iterations_per_frame: int, ) -> NapariVolumewiseProgress: """Create a progress bridge and output layer for volumewise registration.""" self._teardown_volumewise_progress(remove_layer=True) @@ -1669,32 +1668,29 @@ def _setup_volumewise_progress( **display_kwargs, ) bridge = NapariVolumewiseProgressBridge() - bridge.iteration_progress.connect(self._update_volumewise_progress_bar) + bridge.frame_progress.connect(self._update_volumewise_progress_bar) bridge.frame_completed.connect(self._update_volumewise_progress_frame) self._volumewise_progress_bridge = bridge self._volumewise_progress_layer = cast("Image", layer) self._volumewise_moving_preview_layer = cast("Image", moving_preview_layer) self._volumewise_progress_time_axis = moving.dims.index(TIME_DIM) - self._volumewise_progress_total = ( - moving.sizes[TIME_DIM] * total_iterations_per_frame - ) + self._volumewise_progress_total = moving.sizes[TIME_DIM] self._progress.setRange(0, self._volumewise_progress_total) self._progress.setValue(0) return NapariVolumewiseProgress( bridge, n_frames=moving.sizes[TIME_DIM], - total_iterations_per_frame=total_iterations_per_frame, ) def _update_volumewise_progress_bar( self, - completed_iterations: int, - total_iterations: int, + completed_frames: int, + total_frames: int, ) -> None: """Update the determinate progress bar for volumewise registration.""" - self._progress.setRange(0, max(total_iterations, 1)) - self._progress.setValue(min(completed_iterations, total_iterations)) + self._progress.setRange(0, max(total_frames, 1)) + self._progress.setValue(min(completed_frames, total_frames)) def _update_volumewise_progress_frame( self, @@ -2248,7 +2244,6 @@ def _run_registration(self) -> None: layer_name=self._volumewise_result_layer_name( cast("str", payload["moving_layer_name"]) ), - total_iterations_per_frame=payload["number_of_iterations"], ) worker = thread_worker(_run_register_volumewise)( diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py index 28c24e40..6d96c830 100644 --- a/src/confusius/_napari/_registration/_progress.py +++ b/src/confusius/_napari/_registration/_progress.py @@ -174,8 +174,8 @@ def close(self) -> None: class NapariVolumewiseProgressBridge(QObject): """Thread-boundary signal bridge for volumewise registration progress.""" - iteration_progress = Signal(int, int) - """:pyqtSignal: Emitted with `(completed_iterations, total_iterations)`. + frame_progress = Signal(int, int) + """:pyqtSignal: Emitted with `(completed_frames, total_frames)`. """ frame_completed = Signal(int, object) @@ -196,8 +196,6 @@ class NapariVolumewiseProgress: GUI-thread signal bridge used to forward progress updates. n_frames : int Number of frames that will be registered. - total_iterations_per_frame : int - Expected maximum number of optimizer iterations per frame. """ def __init__( @@ -205,42 +203,12 @@ def __init__( bridge: NapariVolumewiseProgressBridge, *, n_frames: int, - total_iterations_per_frame: int, ) -> None: self._bridge = bridge self._n_frames = n_frames - self._total_iterations_per_frame = total_iterations_per_frame - self._iteration_counts = [0] * n_frames + self._completed_frames: set[int] = set() self._lock = Lock() - def iteration( - self, - frame_index: int, - iteration: int, - total_iterations: int, - ) -> None: - """Update the aggregated completed-iteration count. - - Parameters - ---------- - frame_index : int - Index of the frame being optimized. - iteration : int - Current 1-indexed optimizer iteration for that frame. - total_iterations : int - Maximum number of iterations expected for that frame. - """ - with self._lock: - if total_iterations > self._total_iterations_per_frame: - self._total_iterations_per_frame = total_iterations - self._iteration_counts[frame_index] = max( - self._iteration_counts[frame_index], - iteration, - ) - completed = sum(self._iteration_counts) - total = self._n_frames * self._total_iterations_per_frame - self._bridge.iteration_progress.emit(completed, total) - def frame_completed( self, frame_index: int, @@ -259,13 +227,11 @@ def frame_completed( Diagnostics collected for the completed frame. """ with self._lock: - self._iteration_counts[frame_index] = max( - self._iteration_counts[frame_index], - diagnostics.n_iterations, - ) - completed = sum(self._iteration_counts) - total = self._n_frames * self._total_iterations_per_frame - self._bridge.iteration_progress.emit(completed, total) + del diagnostics + self._completed_frames.add(frame_index) + completed = len(self._completed_frames) + total = self._n_frames + self._bridge.frame_progress.emit(completed, total) self._bridge.frame_completed.emit( frame_index, np.asarray(registered_frame.values) ) diff --git a/src/confusius/registration/volumewise.py b/src/confusius/registration/volumewise.py index feebd4d8..34fb0f01 100644 --- a/src/confusius/registration/volumewise.py +++ b/src/confusius/registration/volumewise.py @@ -132,9 +132,9 @@ def register_volumewise( original values, and per-frame `motion_params` rows are marked via the diagnostics status. progress_reporter : VolumewiseProgressReporter, optional - Thread-safe reporter notified at every optimizer iteration and whenever - one frame completes. Useful for GUI progress bars or progressively - filling an output layer while frames finish. + Thread-safe reporter notified whenever one frame completes. Useful for + GUI progress bars or progressively filling an output layer while frames + finish. Returns ------- @@ -213,14 +213,6 @@ def _register_one( ) -> tuple[ int, xr.DataArray, npt.NDArray[np.floating] | None, RegistrationDiagnostics ]: - def _iteration_callback(iteration: int, _metric_value: float) -> None: - if progress_reporter is not None: - progress_reporter.iteration( - frame_index, - iteration, - number_of_iterations, - ) - registered_da, frame_affine, frame_diag = register_volume( volume, ref_da, @@ -243,9 +235,6 @@ def _iteration_callback(iteration: int, _metric_value: float) -> None: sitk_threads=1, show_progress=False, abort_event=abort_event, - iteration_callback=_iteration_callback - if progress_reporter is not None - else None, ) return frame_index, registered_da, frame_affine, frame_diag diff --git a/src/confusius/registration/volumewise_progress.py b/src/confusius/registration/volumewise_progress.py index 9b34a502..6666e099 100644 --- a/src/confusius/registration/volumewise_progress.py +++ b/src/confusius/registration/volumewise_progress.py @@ -18,25 +18,6 @@ class VolumewiseProgressReporter(Protocol): via thread-safe mechanisms such as Qt signals. """ - def iteration( - self, - frame_index: int, - iteration: int, - total_iterations: int, - ) -> None: - """Report the current optimizer iteration for one frame. - - Parameters - ---------- - frame_index : int - Index of the frame being optimized. - iteration : int - Current 1-indexed optimizer iteration for that frame. - total_iterations : int - Maximum number of iterations expected for that frame. - """ - ... - def frame_completed( self, frame_index: int, diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 7668e932..6dbc58b2 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -375,12 +375,11 @@ def test_setup_updates_progress_bar_and_output_layer( moving_layer=moving_layer, moving=moving, layer_name="Motion corrected", - total_iterations_per_frame=5, ) assert registration_panel._volumewise_progress_layer is not None assert registration_panel._volumewise_moving_preview_layer is not None - assert registration_panel._progress.maximum() == 15 + assert registration_panel._progress.maximum() == 3 assert registration_panel._progress.isTextVisible() assert registration_panel._progress.minimumHeight() >= 18 assert moving_layer.colormap.name != "red" @@ -395,8 +394,7 @@ def test_setup_updates_progress_bar_and_output_layer( np.full(moving.shape, float(moving.min()), dtype=np.float32), ) - progress.iteration(1, 2, 5) - assert registration_panel._progress.value() == 2 + assert registration_panel._progress.value() == 0 frame = xr.DataArray( np.ones((4, 6), dtype=np.float32), @@ -408,12 +406,52 @@ def test_setup_updates_progress_bar_and_output_layer( ) progress.frame_completed(1, frame, _FakeDiagnostics(n_iterations=2)) + assert registration_panel._progress.value() == 1 np.testing.assert_array_equal( np.asarray(viewer.layers["Motion corrected"].data)[1], np.asarray(frame.values), ) + def test_frame_completion_updates_frame_progress( + self, viewer, registration_panel + ): + moving = xr.DataArray( + np.zeros((3, 4, 6), dtype=np.float32), + dims=["time", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(3), dims=["time"]), + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + moving_layer = viewer.add_image( + moving.values, + name="series", + metadata={"xarray": moving}, + ) + progress = registration_panel._setup_volumewise_progress( + moving_layer=moving_layer, + moving=moving, + layer_name="Motion corrected", + ) + + frame = xr.DataArray( + np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + progress.frame_completed(0, frame, _FakeDiagnostics(n_iterations=2)) + progress.frame_completed(1, frame, _FakeDiagnostics(n_iterations=1)) + progress.frame_completed(2, frame, _FakeDiagnostics(n_iterations=3)) + + assert registration_panel._progress.maximum() == 3 + assert registration_panel._progress.value() == 3 + + class TestFinishedCallbacks: def test_volume_result_adds_new_layer_with_transform_metadata( self, viewer, registration_panel @@ -730,7 +768,6 @@ def test_volumewise_finished_keeps_preview_layers( moving_layer=moving_layer, moving=moving, layer_name="Motion corrected", - total_iterations_per_frame=5, ) registered = xr.DataArray( diff --git a/tests/unit/test_registration/test_volumewise.py b/tests/unit/test_registration/test_volumewise.py index 92225014..b485dff8 100644 --- a/tests/unit/test_registration/test_volumewise.py +++ b/tests/unit/test_registration/test_volumewise.py @@ -13,18 +13,9 @@ class _FakeVolumewiseProgressReporter: def __init__(self) -> None: - self.iterations: list[tuple[int, int, int]] = [] self.completed_frames: list[int] = [] self.closed = False - def iteration( - self, - frame_index: int, - iteration: int, - total_iterations: int, - ) -> None: - self.iterations.append((frame_index, iteration, total_iterations)) - def frame_completed( self, frame_index: int, @@ -113,16 +104,12 @@ def test_abort_event_returns_partial_dataset(self, sample_2d_dataarray): assert set(result.attrs["motion_params"]["status"]) == {"aborted"} assert_allclose(result.values, sample_2d_dataarray.values) - def test_progress_reporter_receives_iteration_and_frame_updates( + def test_progress_reporter_receives_frame_updates( self, sample_2d_dataarray, monkeypatch ): reporter = _FakeVolumewiseProgressReporter() def _fake_register_volume(_volume, _ref_da, **kwargs): - iteration_callback = kwargs["iteration_callback"] - if iteration_callback is not None: - iteration_callback(1, -1.0) - iteration_callback(2, -0.5) diagnostics = RegistrationDiagnostics( metric="correlation", metric_values=np.asarray([-1.0, -0.5]), @@ -147,7 +134,6 @@ def _fake_register_volume(_volume, _ref_da, **kwargs): ) assert result.shape == sample_2d_dataarray.shape - assert reporter.iterations assert sorted(reporter.completed_frames) == list( range(sample_2d_dataarray.sizes["time"]) ) From 3f7d91359b1b9b789a40f83753c2dbc1c89fd99d Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 22:03:20 +0100 Subject: [PATCH 14/72] style(registration): reduce progress bar height --- src/confusius/_napari/_registration/_panel.py | 2 +- tests/unit/test_napari/test_registration_panel.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index e49f08d6..2c664bd6 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -1124,7 +1124,7 @@ def _setup_ui(self) -> None: self._progress = QProgressBar() self._progress.setRange(0, 0) - self._progress.setMinimumHeight(18) + self._progress.setMinimumHeight(16) self._progress.setTextVisible(True) self._progress.hide() layout.addWidget(self._progress) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 6dbc58b2..0816247d 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -381,7 +381,7 @@ def test_setup_updates_progress_bar_and_output_layer( assert registration_panel._volumewise_moving_preview_layer is not None assert registration_panel._progress.maximum() == 3 assert registration_panel._progress.isTextVisible() - assert registration_panel._progress.minimumHeight() >= 18 + assert registration_panel._progress.minimumHeight() >= 16 assert moving_layer.colormap.name != "red" moving_preview_layer = viewer.layers["Moving"] From e62e4fa91d2856da94129c1c513a46a48f236170 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 22:48:02 +0100 Subject: [PATCH 15/72] feat(registration): unify initialization API --- src/confusius/_napari/_registration/_panel.py | 204 +++++++++++++++--- src/confusius/registration/bspline.py | 2 +- src/confusius/registration/volume.py | 114 +++++----- src/confusius/registration/volumewise.py | 16 +- src/confusius/xarray/registration.py | 30 ++- .../test_napari/test_registration_panel.py | 139 ++++++++++++ tests/unit/test_registration/test_volume.py | 30 +-- tests/unit/test_xarray/test_wrapper_calls.py | 196 +++++++++-------- 8 files changed, 532 insertions(+), 199 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 2c664bd6..5c3f6429 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -342,7 +342,9 @@ def _run_register_volume_registration_volume( number_of_histogram_bins: int = 50, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, - initialization: Literal["geometry", "moments", "none"] = "geometry", + initialization: Literal["center_geometry", "center_moments"] + | None = "center_geometry", + initial_transform: npt.NDArray[np.floating] | None = None, shrink_factors: Sequence[int] = (6, 2, 1), smoothing_sigmas: Sequence[int] = (6, 2, 1), fill_value: float | None = None, @@ -379,8 +381,10 @@ def _run_register_volume_registration_volume( Convergence threshold. convergence_window_size : int Window size for convergence estimation. - initialization : {"geometry", "moments", "none"} + initialization : {"center_geometry", "center_moments"} or None Transform initializer. + initial_transform : numpy.ndarray, optional + Pre-computed affine transform used as a warm start before optimization. shrink_factors : sequence of int Shrink factors per resolution level. smoothing_sigmas : sequence of int @@ -415,7 +419,9 @@ def _run_register_volume_registration_volume( number_of_histogram_bins=number_of_histogram_bins, convergence_minimum_value=convergence_minimum_value, convergence_window_size=convergence_window_size, - centering_initialization=initialization, + initialization=initialization + if initial_transform is None + else initial_transform, shrink_factors=shrink_factors, smoothing_sigmas=smoothing_sigmas, fill_value=fill_value, @@ -459,7 +465,8 @@ def _run_register_volumewise( number_of_histogram_bins: int = 50, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, - initialization: Literal["geometry", "moments", "none"] = "geometry", + initialization: Literal["center_geometry", "center_moments"] + | None = "center_geometry", shrink_factors: Sequence[int] = (6, 2, 1), smoothing_sigmas: Sequence[int] = (6, 2, 1), keep_diagnostics: bool = False, @@ -494,7 +501,7 @@ def _run_register_volumewise( Convergence threshold. convergence_window_size : int Window size for convergence estimation. - initialization : {"geometry", "moments", "none"} + initialization : {"center_geometry", "center_moments"} or None Transform initializer. shrink_factors : tuple of int or None Shrink factors per resolution level. @@ -798,21 +805,40 @@ def _setup_ui(self) -> None: self._initialization_combo = QComboBox() self._initialization_combo.addItems( - ["geometry", "moments", "none", "napari transform"] + ["center_geometry", "center_moments", "none"] ) self._initialization_combo.setToolTip( - "Transform initializer before optimization. 'geometry' aligns centers; " - "'moments' aligns centers of mass; 'none' uses identity; " - "'napari transform' uses the currently selected affine transform from the Transforms panel." + "Transform initializer before optimization. 'center_geometry' aligns centers; " + "'center_moments' aligns centers of mass; 'none' uses identity." ) params_layout.addRow( self._make_form_label( "Initialization", - tooltip="How to initialize the transform before optimization: image geometry, centers of mass, identity, or the selected napari transform.", + tooltip="How to initialize the transform before optimization: center geometry, center moments, or identity.", ), self._initialization_combo, ) + self._initial_transform_combo = QComboBox() + self._initial_transform_combo.setMinimumContentsLength(18) + self._initial_transform_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._initial_transform_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._initial_transform_combo.setToolTip( + "Optional pre-computed affine transform used as a warm start before optimization." + ) + self._initial_transform_row_label = self._make_form_label( + "Initial transform", + tooltip="Optional pre-computed affine transform from the Transforms tab used as a warm start before optimization.", + ) + params_layout.addRow( + self._initial_transform_row_label, + self._initial_transform_combo, + ) + learning_rate_row = QHBoxLayout() self._learning_rate_auto_check = QCheckBox("Auto") self._learning_rate_auto_check.setChecked(True) @@ -1138,6 +1164,15 @@ def _setup_ui(self) -> None: self._fixed_combo.currentTextChanged.connect( self._validate_registration_selection ) + self._initialization_combo.currentTextChanged.connect( + self._validate_registration_selection + ) + self._initial_transform_combo.currentTextChanged.connect( + self._validate_registration_selection + ) + self._transform_source_combo.currentTextChanged.connect( + self._validate_registration_selection + ) self._learning_rate_auto_check.toggled.connect( lambda checked: self._learning_rate_edit.setEnabled(not checked) ) @@ -1250,28 +1285,44 @@ def _volumewise_moving_preview_layer_name(self) -> str: def _refresh_transform_controls(self) -> None: """Refresh transform-related layer selectors.""" source_data = self._transform_source_combo.currentData() + initial_transform_data = self._initial_transform_combo.currentData() target_name = self._transform_target_combo.currentText() - self._transform_source_combo.blockSignals(True) - self._transform_source_combo.clear() + transform_options: list[tuple[str, tuple[str, str]]] = [] if self._loaded_transform_payload is not None: - self._transform_source_combo.addItem( - self._transform_source_label( - self._loaded_transform_payload, - suffix="loaded", - ), - ("loaded", ""), + transform_options.append( + ( + self._transform_source_label( + self._loaded_transform_payload, + suffix="loaded", + ), + ("loaded", ""), + ) ) for layer in self.viewer.layers: payload = _affine_payload_from_layer(layer) if payload is None: continue - self._transform_source_combo.addItem( - self._transform_source_label(payload, suffix=layer.name), - ("layer", layer.name), + transform_options.append( + ( + self._transform_source_label(payload, suffix=layer.name), + ("layer", layer.name), + ) ) + + self._transform_source_combo.blockSignals(True) + self._transform_source_combo.clear() + for label, data in transform_options: + self._transform_source_combo.addItem(label, data) self._transform_source_combo.blockSignals(False) + self._initial_transform_combo.blockSignals(True) + self._initial_transform_combo.clear() + self._initial_transform_combo.addItem("None", None) + for label, data in transform_options: + self._initial_transform_combo.addItem(label, data) + self._initial_transform_combo.blockSignals(False) + self._transform_target_combo.blockSignals(True) self._transform_target_combo.clear() self._transform_target_combo.addItems( @@ -1285,6 +1336,12 @@ def _refresh_transform_controls(self) -> None: self._transform_source_combo.setCurrentIndex(i) break + if initial_transform_data is not None: + for i in range(self._initial_transform_combo.count()): + if self._initial_transform_combo.itemData(i) == initial_transform_data: + self._initial_transform_combo.setCurrentIndex(i) + break + target_index = self._transform_target_combo.findText(target_name) if target_index >= 0: self._transform_target_combo.setCurrentIndex(target_index) @@ -1306,6 +1363,52 @@ def _selected_transform_payload(self) -> AffineTransformPayload | None: return None return _affine_payload_from_layer(layer) + def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: + """Return the transform payload selected for registration initialization.""" + source_data = self._initial_transform_combo.currentData() + if source_data is None: + return None + if not isinstance(source_data, tuple) or len(source_data) != 2: + return None + + source_kind, source_name = source_data + if source_kind == "loaded": + return self._loaded_transform_payload + if source_kind != "layer" or not source_name: + return None + try: + layer = cast("Layer", self.viewer.layers[source_name]) + except KeyError: + return None + return _affine_payload_from_layer(layer) + + def _validate_initial_transform_selection( + self, + *, + operation: Literal["register_volume", "register_volumewise"], + moving: xr.DataArray, + fixed: xr.DataArray | None = None, + ) -> str | None: + """Return an inline validation message for transform initialization.""" + payload = self._selected_initial_transform_payload() + if payload is None or operation != "register_volume": + return None + if fixed is None: + return "Select a fixed layer." + + try: + affine = affine_transform_from_payload(payload) + except Exception as exc: # noqa: BLE001 + return str(exc) + + expected_shape = (moving.ndim + 1, moving.ndim + 1) + if affine.shape != expected_shape: + return ( + f"Selected initialization transform has shape {affine.shape}, " + f"but this registration expects {expected_shape}." + ) + return None + def _update_reference_time_bounds(self) -> None: """Clamp the volumewise reference-time widget to the moving layer.""" moving_layer = self._selected_layer(self._moving_combo) @@ -1395,9 +1498,13 @@ def _validate_registration_selection(self) -> bool: ) self._set_run_btn_enabled(False) return False - self._set_layer_validation_style() - self._set_run_btn_enabled(True) - return True + init_message = self._validate_initial_transform_selection( + operation=operation, + moving=moving, + ) + self._set_layer_validation_style(message=init_message) + self._set_run_btn_enabled(init_message is None) + return init_message is None moving_invalid = False fixed_invalid = False @@ -1413,7 +1520,7 @@ def _validate_registration_selection(self) -> bool: return False try: - _layer_to_dataarray(fixed_layer) + fixed = _layer_to_dataarray(fixed_layer) except Exception: self._set_layer_validation_style( fixed_invalid=True, @@ -1427,7 +1534,14 @@ def _validate_registration_selection(self) -> bool: fixed_invalid = True message = "Moving and fixed layers must be different." - valid = not (moving_invalid or fixed_invalid) + if message is None: + message = self._validate_initial_transform_selection( + operation=operation, + moving=_prepare_between_scan_data(moving), + fixed=_prepare_between_scan_data(fixed), + ) + + valid = not (moving_invalid or fixed_invalid or message is not None) self._set_layer_validation_style( moving_invalid=moving_invalid, fixed_invalid=fixed_invalid, @@ -1481,6 +1595,7 @@ def _snapshot_mode_parameters(self, *, is_volumewise: bool) -> dict[str, Any]: "transform": self._transform_combo.currentText() or "rigid", "metric": self._metric_combo.currentText(), "initialization": self._initialization_combo.currentText(), + "initial_transform_source": self._initial_transform_combo.currentData(), "learning_rate_auto": self._learning_rate_auto_check.isChecked(), "learning_rate_value": self._learning_rate_edit.value(), "number_of_iterations": self._iterations_spin.value(), @@ -1527,6 +1642,15 @@ def _apply_mode_parameters( self._metric_combo.setCurrentText(cast("str", params["metric"])) self._initialization_combo.setCurrentText(cast("str", params["initialization"])) + initial_transform_source = params.get("initial_transform_source") + if initial_transform_source is not None: + for i in range(self._initial_transform_combo.count()): + if ( + self._initial_transform_combo.itemData(i) + == initial_transform_source + ): + self._initial_transform_combo.setCurrentIndex(i) + break self._learning_rate_auto_check.setChecked( cast("bool", params["learning_rate_auto"]) ) @@ -1581,6 +1705,9 @@ def _on_mode_changed(self) -> None: self._fixed_label.setVisible(not is_volumewise) self._fixed_combo.setVisible(not is_volumewise) self._fixed_combo.setEnabled(not is_volumewise) + self._initial_transform_row_label.setVisible(not is_volumewise) + self._initial_transform_combo.setVisible(not is_volumewise) + self._initial_transform_combo.setEnabled(not is_volumewise) self._reference_time_label.setVisible(is_volumewise) self._reference_time_spin.setVisible(is_volumewise) self._n_jobs_row.setVisible(is_volumewise) @@ -2191,6 +2318,18 @@ def _run_registration(self) -> None: moving = _prepare_between_scan_data(moving) fixed = _prepare_between_scan_data(fixed) + initial_transform_payload = self._selected_initial_transform_payload() + initial_transform: npt.NDArray[np.floating] | None = None + if initial_transform_payload is not None: + try: + initial_transform = affine_transform_from_payload( + initial_transform_payload + ) + except Exception as exc: # noqa: BLE001 + self._set_error(str(exc)) + return + payload["initial_transform_source"] = initial_transform_payload["name"] + payload["fixed_layer_name"] = fixed_layer.name progress_plotter = self._setup_volume_progress( @@ -2220,8 +2359,12 @@ def _run_registration(self) -> None: convergence_minimum_value=payload["convergence_minimum_value"], convergence_window_size=payload["convergence_window_size"], initialization=cast( - "Literal['geometry', 'moments', 'none']", payload["initialization"] + "Literal['center_geometry', 'center_moments'] | None", + None + if payload["initialization"] == "none" + else payload["initialization"], ), + initial_transform=initial_transform, shrink_factors=payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=payload["smoothing_sigmas"] or (6, 2, 1), fill_value=payload["fill_value"], @@ -2242,7 +2385,7 @@ def _run_registration(self) -> None: moving_layer=cast("Image", moving_layer), moving=moving, layer_name=self._volumewise_result_layer_name( - cast("str", payload["moving_layer_name"]) + payload["moving_layer_name"] ), ) @@ -2264,7 +2407,10 @@ def _run_registration(self) -> None: convergence_minimum_value=payload["convergence_minimum_value"], convergence_window_size=payload["convergence_window_size"], initialization=cast( - "Literal['geometry', 'moments', 'none']", payload["initialization"] + "Literal['center_geometry', 'center_moments'] | None", + None + if payload["initialization"] == "none" + else payload["initialization"], ), shrink_factors=payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=payload["smoothing_sigmas"] or (6, 2, 1), diff --git a/src/confusius/registration/bspline.py b/src/confusius/registration/bspline.py index 5f2e0b13..c2c8b65e 100644 --- a/src/confusius/registration/bspline.py +++ b/src/confusius/registration/bspline.py @@ -14,7 +14,7 @@ "affines": { "bspline_initialization": [[...]] # optional (N+1, N+1) pre-affine; # only present when register_volume - # was called with initial_transform. + # was called with affine initialization. } } ``` diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index 99f3c8bb..e32fd6e8 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -37,7 +37,9 @@ def _validate_register_volume_inputs( learning_rate: float | Literal["auto"], number_of_iterations: int, convergence_window_size: int, - centering_initialization: Literal["geometry", "moments", "none"], + initialization: Literal["center_geometry", "center_moments"] + | npt.NDArray[np.floating] + | None, shrink_factors: Sequence[int], smoothing_sigmas: Sequence[int], resample_interpolation: Literal["linear", "bspline"], @@ -66,8 +68,8 @@ def _validate_register_volume_inputs( Maximum number of optimizer iterations. convergence_window_size : int Window size for convergence checking. - centering_initialization : {"geometry", "moments", "none"} - Transform initializer name. + initialization : {"center_geometry", "center_moments"} or (N+1, N+1) numpy.ndarray, optional + Transform initialization mode or precomputed affine transform. shrink_factors : sequence of int Downsampling factors per pyramid level. smoothing_sigmas : sequence of int @@ -138,12 +140,14 @@ def _validate_register_volume_inputs( f"Invalid metric {metric!r}. Expected one of {sorted(valid_metrics)}." ) - valid_initializations = {"geometry", "moments", "none"} - if centering_initialization not in valid_initializations: - raise ValueError( - f"Invalid initialization {centering_initialization!r}. " - f"Expected one of {sorted(valid_initializations)}." - ) + valid_initializations = {"center_geometry", "center_moments"} + if initialization is not None and not isinstance(initialization, np.ndarray): + if initialization not in valid_initializations: + raise ValueError( + f"Invalid initialization {initialization!r}. " + f"Expected one of {sorted(valid_initializations)}, None, or a " + "homogeneous affine matrix." + ) valid_interpolations = {"linear", "bspline"} if resample_interpolation not in valid_interpolations: @@ -249,9 +253,10 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 number_of_iterations: int = ..., convergence_minimum_value: float = ..., convergence_window_size: int = ..., - centering_initialization: Literal["geometry", "moments", "none"] = ..., + initialization: Literal["center_geometry", "center_moments"] + | npt.NDArray[np.floating] + | None = ..., optimizer_weights: list[float] | None = ..., - initial_transform: "npt.NDArray[np.floating] | None" = ..., mesh_size: tuple[int, int, int] = ..., use_multi_resolution: bool = ..., shrink_factors: Sequence[int] = ..., @@ -285,9 +290,10 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 number_of_iterations: int = ..., convergence_minimum_value: float = ..., convergence_window_size: int = ..., - centering_initialization: Literal["geometry", "moments", "none"] = ..., + initialization: Literal["center_geometry", "center_moments"] + | npt.NDArray[np.floating] + | None = ..., optimizer_weights: list[float] | None = ..., - initial_transform: "npt.NDArray[np.floating] | None" = ..., mesh_size: tuple[int, int, int] = ..., use_multi_resolution: bool = ..., shrink_factors: Sequence[int] = ..., @@ -320,9 +326,10 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 number_of_iterations: int = ..., convergence_minimum_value: float = ..., convergence_window_size: int = ..., - centering_initialization: Literal["geometry", "moments", "none"] = ..., + initialization: Literal["center_geometry", "center_moments"] + | npt.NDArray[np.floating] + | None = ..., optimizer_weights: list[float] | None = ..., - initial_transform: "npt.NDArray[np.floating] | None" = ..., mesh_size: tuple[int, int, int] = ..., use_multi_resolution: bool = ..., shrink_factors: Sequence[int] = ..., @@ -355,9 +362,10 @@ def register_volume( number_of_iterations: int = 100, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, - centering_initialization: Literal["geometry", "moments", "none"] = "geometry", + initialization: Literal["center_geometry", "center_moments"] + | npt.NDArray[np.floating] + | None = "center_geometry", optimizer_weights: list[float] | None = None, - initial_transform: "npt.NDArray[np.floating] | None" = None, mesh_size: tuple[int, int, int] = (10, 10, 10), use_multi_resolution: bool = False, shrink_factors: Sequence[int] = (6, 2, 1), @@ -422,11 +430,17 @@ def register_volume( convergence_window_size : int, default: 10 Number of values of the similarity metric which are used to estimate the energy profile of the similarity metric. - centering_initialization : {"geometry", "moments", "none"}, default: "geometry" - Transform initializer applied before optimization. `"geometry"` aligns the - image centers (safe default, no assumptions about content). `"moments"` aligns - centers of mass (better when images are offset but share the same content). - `"none"` uses the identity transform. Ignored for `transform_type="bspline"`. + initialization : {"center_geometry", "center_moments"} or (N+1, N+1) numpy.ndarray, default: "center_geometry" + Initial transform applied before optimization: + + - `"center_geometry"`: aligns image centers. + - `"center_moments"`: aligns centers of mass. + - `(N+1, N+1)` homogeneous affine matrix: uses a precomputed affine + transform. + - `None`: uses the identity transform. + + For `transform_type="bspline"`, centering modes are ignored but affine + initialization is supported. optimizer_weights : list of float, optional Per-parameter weights applied on top of the auto-estimated physical shift scales. `None` uses identity weights (all ones). A list is passed directly to @@ -437,14 +451,6 @@ def register_volume( `1` leaves it unchanged. For the 3D Euler transform the parameter order is `[angleX, angleY, angleZ, tx, ty, tz]`; to disable rotations around x and y set weights to `[0, 0, 1, 1, 1, 1]`. - initial_transform : (N+1, N+1) numpy.ndarray, optional - Pre-computed affine matrix (pull/inverse convention, as returned by a previous - call to `register_volume`) used as a warm-start before optimisation. When - provided the optimized transform (`transform_type`) is composed on top of this - pre-alignment. Primarily useful for the affine → B-spline composition - workflow: run an affine registration first, then pass its result here together - with `transform_type="bspline"` to refine the deformation. When not provided - the existing `centering_initialization` behaviour is unchanged. mesh_size : tuple of int, default: (10, 10, 10) Number of B-spline mesh nodes along each spatial dimension. Only used when `transform_type="bspline"`. @@ -531,7 +537,7 @@ def register_volume( coordinates to moving-space coordinates. For `transform_type="bspline"`, returns a DataArray encoding the B-spline control-point grid (see [`confusius.registration.bspline`][confusius.registration.bspline] for the - DataArray schema). When `initial_transform` was also supplied, the DataArray + DataArray schema). When an affine `initialization` was also supplied, the DataArray includes `attrs["affines"]["bspline_initialization"]` so that the full composite (pre-affine + B-spline) can be reconstructed for resampling. diagnostics : confusius.registration.RegistrationDiagnostics @@ -546,7 +552,7 @@ def register_volume( ValueError If `moving` or `fixed` contains NaN values. ValueError - If `transform_type`, `metric`, `centering_initialization`, or + If `transform_type`, `metric`, `initialization`, or `resample_interpolation` is not a recognised value. ValueError If `learning_rate` is not a positive finite float or `"auto"`. @@ -556,8 +562,8 @@ def register_volume( ValueError If `shrink_factors` and `smoothing_sigmas` have different lengths. ValueError - If `initial_transform` is provided and its shape does not match the image - dimensionality. + If an affine `initialization` is provided and its shape does not match the + image dimensionality. TypeError If `fixed_mask` or `moving_mask` is not a boolean DataArray. ValueError @@ -577,7 +583,7 @@ def register_volume( learning_rate=learning_rate, number_of_iterations=number_of_iterations, convergence_window_size=convergence_window_size, - centering_initialization=centering_initialization, + initialization=initialization, shrink_factors=shrink_factors, smoothing_sigmas=smoothing_sigmas, resample_interpolation=resample_interpolation, @@ -600,12 +606,16 @@ def register_volume( ndim = fixed_reg.GetDimension() - # Validate initial_transform shape now that ndim is known. - if initial_transform is not None: + # Validate affine initialization shape now that ndim is known. + initialization_mode = initialization if isinstance(initialization, str) else None + affine_initialization = ( + initialization if isinstance(initialization, np.ndarray) else None + ) + if affine_initialization is not None: expected_shape = (ndim + 1, ndim + 1) - if initial_transform.shape != expected_shape: + if affine_initialization.shape != expected_shape: raise ValueError( - f"initial_transform shape {initial_transform.shape} does not match " + f"initialization shape {affine_initialization.shape} does not match " f"image dimensionality {ndim}D (expected {expected_shape})." ) @@ -688,14 +698,16 @@ def register_volume( # CenteredTransformInitializer requires a transform with a center parameter # (e.g. Euler, Affine). TranslationTransform has no center, so centering # initialization is always skipped for translation. - if centering_initialization == "geometry" and transform_type != "translation": + if initialization_mode == "center_geometry" and transform_type != "translation": sitk_centering_transform = sitk.CenteredTransformInitializer( fixed_reg, moving_reg, sitk_centering_transform, sitk.CenteredTransformInitializerFilter.GEOMETRY, ) - elif centering_initialization == "moments" and transform_type != "translation": + elif ( + initialization_mode == "center_moments" and transform_type != "translation" + ): sitk_centering_transform = sitk.CenteredTransformInitializer( fixed_reg, moving_reg, @@ -705,12 +717,14 @@ def register_volume( else: sitk_centering_transform = sitk_centering_transform - if initial_transform is not None: - pre_tx = affine_to_sitk_linear_transform(initial_transform) - - sitk_initial_transform: sitk.Transform = sitk.CompositeTransform(ndim) - sitk_initial_transform.AddTransform(pre_tx) - sitk_initial_transform.AddTransform(sitk_centering_transform) + if affine_initialization is not None: + pre_tx = affine_to_sitk_linear_transform(affine_initialization) + if transform_type == "bspline": + sitk_initial_transform = sitk.CompositeTransform(ndim) + sitk_initial_transform.AddTransform(pre_tx) + sitk_initial_transform.AddTransform(sitk_centering_transform) + else: + sitk_initial_transform = pre_tx else: sitk_initial_transform = sitk_centering_transform @@ -773,9 +787,9 @@ def _record_iteration() -> None: if effective_abort_event.is_set(): if transform_type == "bspline": sitk_optimized_transform = sitk_initial_transform - elif initial_transform is not None: + elif affine_initialization is not None: sitk_optimized_transform = affine_to_sitk_linear_transform( - initial_transform + affine_initialization ) else: sitk_optimized_transform = sitk.TranslationTransform(ndim) @@ -828,7 +842,7 @@ def _record_iteration() -> None: from confusius.registration.bspline import sitk_bspline_to_dataarray optimized_transform = sitk_bspline_to_dataarray( - sitk_optimized_transform, pre_affine=initial_transform + sitk_optimized_transform, pre_affine=affine_initialization ) else: optimized_transform = sitk_linear_transform_to_affine(sitk_optimized_transform) diff --git a/src/confusius/registration/volumewise.py b/src/confusius/registration/volumewise.py index 34fb0f01..bb1f5e68 100644 --- a/src/confusius/registration/volumewise.py +++ b/src/confusius/registration/volumewise.py @@ -32,7 +32,8 @@ def register_volumewise( number_of_iterations: int = 100, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, - initialization: Literal["geometry", "moments", "none"] = "geometry", + initialization: Literal["center_geometry", "center_moments"] + | None = "center_geometry", optimizer_weights: list[float] | None = None, use_multi_resolution: bool = False, shrink_factors: Sequence[int] = (6, 2, 1), @@ -80,11 +81,12 @@ def register_volumewise( convergence_window_size : int, default: 10 Number of recent metric values used to estimate the energy profile for convergence checking. - initialization : {"geometry", "moments", "none"}, default: "geometry" - Transform initializer applied before optimization. `"geometry"` - aligns the image centers (safe default, no assumptions about content). - `"moments"` aligns centers of mass (better when images are offset - but share the same content). `"none"` uses the identity transform. + initialization : {"center_geometry", "center_moments"}, default: "center_geometry" + Initial transform applied before optimization: + + - `"center_geometry"`: aligns image centers. + - `"center_moments"`: aligns centers of mass. + - `None`: uses the identity transform. optimizer_weights : list of float, optional Per-parameter weights applied on top of the auto-estimated physical shift scales. If not provided, identity weights are used. A list is passed directly to @@ -223,7 +225,7 @@ def _register_one( number_of_iterations=number_of_iterations, convergence_minimum_value=convergence_minimum_value, convergence_window_size=convergence_window_size, - centering_initialization=initialization, + initialization=initialization, optimizer_weights=optimizer_weights, use_multi_resolution=use_multi_resolution, shrink_factors=shrink_factors, diff --git a/src/confusius/xarray/registration.py b/src/confusius/xarray/registration.py index cfbd1206..4484623d 100644 --- a/src/confusius/xarray/registration.py +++ b/src/confusius/xarray/registration.py @@ -41,7 +41,9 @@ def to_volume( number_of_iterations: int = 100, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, - initialization: Literal["geometry", "moments", "none"] = "geometry", + initialization: Literal["center_geometry", "center_moments"] + | npt.NDArray[np.floating] + | None = "center_geometry", optimizer_weights: list[float] | None = None, mesh_size: tuple[int, int, int] = (10, 10, 10), use_multi_resolution: bool = False, @@ -77,8 +79,17 @@ def to_volume( Convergence threshold for early stopping. convergence_window_size : int, default: 10 Window size for convergence check. - initialization : {"geometry", "moments", "none"}, default: "geometry" - Transform initialization strategy. Ignored for bspline transforms. + initialization : {"center_geometry", "center_moments"} or (N+1, N+1) numpy.ndarray, default: "center_geometry" + Initial transform applied before optimization: + + - `"center_geometry"`: aligns image centers. + - `"center_moments"`: aligns centers of mass. + - `(N+1, N+1)` homogeneous affine matrix: uses a precomputed affine + transform. + - `None`: uses the identity transform. + + For `transform="bspline"`, centering modes are ignored but affine + initialization is supported. optimizer_weights : list of float, optional Per-parameter weights applied on top of auto-estimated scales via `SetOptimizerWeights()`. If not provided, no additional weighting is @@ -146,7 +157,7 @@ def to_volume( number_of_iterations=number_of_iterations, convergence_minimum_value=convergence_minimum_value, convergence_window_size=convergence_window_size, - centering_initialization=initialization, + initialization=initialization, optimizer_weights=optimizer_weights, mesh_size=mesh_size, use_multi_resolution=use_multi_resolution, @@ -172,7 +183,8 @@ def volumewise( number_of_iterations: int = 100, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, - initialization: Literal["geometry", "moments", "none"] = "geometry", + initialization: Literal["center_geometry", "center_moments"] + | None = "center_geometry", optimizer_weights: list[float] | None = None, use_multi_resolution: bool = False, shrink_factors: Sequence[int] = (6, 2, 1), @@ -207,8 +219,12 @@ def volumewise( Convergence threshold for early stopping. convergence_window_size : int, default: 10 Window size for convergence check. - initialization : {"geometry", "moments", "none"}, default: "geometry" - Transform initialization strategy. + initialization : {"center_geometry", "center_moments"}, default: "center_geometry" + Initial transform applied before optimization: + + - `"center_geometry"`: aligns image centers. + - `"center_moments"`: aligns centers of mass. + - `None`: uses the identity transform. optimizer_weights : list of float, optional Per-parameter weights applied on top of auto-estimated scales via `SetOptimizerWeights()`. If not provided, no additional weighting is diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 0816247d..a6d848b7 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -183,6 +183,101 @@ def test_mesh_size_is_basic_and_only_visible_for_bspline(self, registration_pane assert registration_panel._mesh_size_row.isHidden() +class TestRunRegistration: + def test_between_scan_run_uses_selected_initial_transform( + self, viewer, registration_panel, monkeypatch + ): + moving = xr.DataArray( + np.zeros((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + fixed = xr.DataArray( + np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + affine = np.array( + [[1.0, 0.0, 0.5], [0.0, 1.0, -0.25], [0.0, 0.0, 1.0]], + dtype=float, + ) + transform_payload = make_affine_transform_payload( + affine, + reference=fixed, + source_layer_name="moving", + target_layer_name="fixed", + operation="register_volume", + transform_model="rigid", + metric="correlation", + diagnostics=_FakeDiagnostics(), + ) + + viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + viewer.add_image(fixed.values, name="fixed", metadata={"xarray": fixed}) + viewer.add_image( + fixed.values, + name="Previous registered", + metadata={"confusius_transform": transform_payload}, + ) + registration_panel._refresh_layers() + registration_panel._refresh_transform_controls() + registration_panel._moving_combo.setCurrentText("moving") + registration_panel._fixed_combo.setCurrentText("fixed") + for i in range(registration_panel._initial_transform_combo.count()): + if registration_panel._initial_transform_combo.itemData(i) == ( + "layer", + "Previous registered", + ): + registration_panel._initial_transform_combo.setCurrentIndex(i) + break + + captured: dict[str, object] = {} + + class _FakeSignal: + def connect(self, _slot): + return None + + class _FakeWorker: + def __init__(self) -> None: + self.returned = _FakeSignal() + self.errored = _FakeSignal() + self.finished = _FakeSignal() + + def start(self) -> None: + return None + + def _fake_thread_worker(func): + def _runner(*args, **kwargs): + captured["func"] = func + captured["args"] = args + captured["kwargs"] = kwargs + return _FakeWorker() + + return _runner + + monkeypatch.setattr( + "confusius._napari._registration._panel.thread_worker", + _fake_thread_worker, + ) + monkeypatch.setattr( + registration_panel, + "_setup_volume_progress", + lambda **_kwargs: None, + ) + + registration_panel._run_registration() + + np.testing.assert_array_equal(captured["kwargs"]["initial_transform"], affine) + assert captured["kwargs"]["initialization"] == "geometry" + assert registration_panel._worker is not None + + class TestAbort: def test_abort_sets_cancellation_event(self, registration_panel): registration_panel._worker = object() @@ -258,6 +353,50 @@ def test_between_scans_accepts_time_series_by_averaging( assert registration_panel._validate_registration_selection() + def test_transform_initialization_requires_selected_affine_transform( + self, viewer, registration_panel + ): + viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") + viewer.add_image(np.ones((4, 6), dtype=np.float32), name="fixed") + registration_panel._refresh_layers() + registration_panel._moving_combo.setCurrentText("moving") + registration_panel._fixed_combo.setCurrentText("fixed") + + assert registration_panel._validate_registration_selection() + + def test_initial_transform_dropdown_lists_available_transforms( + self, viewer, registration_panel + ): + reference = xr.DataArray( + np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + payload = make_affine_transform_payload( + np.eye(3), + reference=reference, + source_layer_name="moving", + target_layer_name="fixed", + operation="register_volume", + transform_model="rigid", + metric="correlation", + diagnostics=_FakeDiagnostics(), + ) + + viewer.add_image(reference.values, name="Registered", metadata={"confusius_transform": payload}) + registration_panel._refresh_transform_controls() + + assert registration_panel._initial_transform_combo.itemText(0) == "None" + assert registration_panel._initial_transform_combo.count() >= 2 + assert any( + registration_panel._initial_transform_combo.itemData(i) + == ("layer", "Registered") + for i in range(registration_panel._initial_transform_combo.count()) + ) + class TestBetweenScanPreparation: def test_prepare_between_scan_data_averages_time(self): diff --git a/tests/unit/test_registration/test_volume.py b/tests/unit/test_registration/test_volume.py index 346a6254..42335293 100644 --- a/tests/unit/test_registration/test_volume.py +++ b/tests/unit/test_registration/test_volume.py @@ -525,38 +525,38 @@ def test_matches_register_volume_resample( assert_allclose(result.values, resampled_direct.values, atol=1e-5) -class TestInitialTransform: - """Tests for the initial_transform parameter of register_volume.""" +class TestInitialization: + """Tests for the initialization parameter of register_volume.""" def test_wrong_shape_raises(self, sample_2d_dataarray_spatial): - """initial_transform with wrong shape raises ValueError.""" - with pytest.raises(ValueError, match="initial_transform shape"): + """Affine initialization with wrong shape raises ValueError.""" + with pytest.raises(ValueError, match="initialization shape"): register_volume( sample_2d_dataarray_spatial, sample_2d_dataarray_spatial, transform_type="bspline", - initial_transform=np.eye(4), # wrong: 3D affine for 2D images + initialization=np.eye(4), # wrong: 3D affine for 2D images ) - def test_bspline_with_initial_transform_stores_pre_affine( + def test_bspline_with_affine_initialization_stores_pre_affine( self, sample_2d_dataarray_spatial ): - """B-spline result DataArray stores the pre-affine in attrs when initial_transform is given.""" + """B-spline result stores the pre-affine when affine initialization is given.""" pre_affine = np.eye(3) _, bspline_tx, _ = register_volume( sample_2d_dataarray_spatial, sample_2d_dataarray_spatial, transform_type="bspline", - initial_transform=pre_affine, + initialization=pre_affine, ) assert isinstance(bspline_tx, xr.DataArray) assert "affines" in bspline_tx.attrs assert "bspline_initialization" in bspline_tx.attrs["affines"] - def test_bspline_without_initial_transform_has_no_pre_affine( + def test_bspline_without_affine_initialization_has_no_pre_affine( self, sample_2d_dataarray_spatial ): - """B-spline result DataArray without initial_transform has no bspline_initialization key.""" + """B-spline result without affine initialization has no bspline_initialization key.""" _, bspline_tx, _ = register_volume( sample_2d_dataarray_spatial, sample_2d_dataarray_spatial, @@ -597,7 +597,7 @@ def test_resample_like_with_bspline_matches_direct_resample( def test_resample_like_with_composite_bspline_matches_direct_resample( self, sample_2d_image, sample_2d_dataarray_spatial ): - """resample_like with composite B-spline (with initial_transform) matches register_volume(resample=True).""" + """resample_like with composite B-spline matches register_volume(resample=True).""" rng = np.random.default_rng(1) shift = rng.integers(2, 4, size=2) shifted = np.roll( @@ -619,7 +619,7 @@ def test_resample_like_with_composite_bspline_matches_direct_resample( moving, sample_2d_dataarray_spatial, transform_type="bspline", - initial_transform=affine_tx, + initialization=affine_tx, resample=True, ) assert isinstance(bspline_tx, xr.DataArray) @@ -756,10 +756,10 @@ def test_matches_register_volume_resample_3d( result = resample_like(moving, sample_3d_dataarray_spatial, affine) assert_allclose(result.values, resampled_direct.values, atol=1e-5) - def test_matches_register_volume_with_initial_transform( + def test_matches_register_volume_with_affine_initialization( self, sample_2d_image, sample_2d_dataarray_spatial ): - """resample_like matches register_volume(resample=True) when initial_transform is used. + """resample_like matches register_volume(resample=True) when affine initialization is used. Regression test for a bug where CompositeTransform sub-transforms were composed in the wrong order in _sitk_linear_transform_to_affine, causing @@ -783,7 +783,7 @@ def test_matches_register_volume_with_initial_transform( moving, sample_2d_dataarray_spatial, transform_type="affine", - initial_transform=affine_init, + initialization=affine_init, resample=True, ) result = resample_like(moving, sample_2d_dataarray_spatial, affine) diff --git a/tests/unit/test_xarray/test_wrapper_calls.py b/tests/unit/test_xarray/test_wrapper_calls.py index 07cd705d..87c1dcf0 100644 --- a/tests/unit/test_xarray/test_wrapper_calls.py +++ b/tests/unit/test_xarray/test_wrapper_calls.py @@ -37,7 +37,9 @@ def _power(data, exponent=0.5): assert calls["power"] == (sample_3dt_volume, 2.0) -def test_extract_wrappers_forward_calls(monkeypatch, sample_3dt_volume, sample_roi_labels): +def test_extract_wrappers_forward_calls( + monkeypatch, sample_3dt_volume, sample_roi_labels +): """Extract accessor methods forward arguments to extraction functions.""" expected = xr.DataArray(np.array([1.0]), dims=["k"]) mask = sample_roi_labels > 0 @@ -59,7 +61,10 @@ def _unmask(data, mask, fill_value=0.0): monkeypatch.setattr("confusius.extract.mask.extract_with_mask", _with_mask) monkeypatch.setattr("confusius.extract.reconstruction.unmask", _unmask) - assert sample_3dt_volume.fusi.extract.with_labels(sample_roi_labels, reduction="sum") is expected + assert ( + sample_3dt_volume.fusi.extract.with_labels(sample_roi_labels, reduction="sum") + is expected + ) assert calls["labels"] == (sample_3dt_volume, sample_roi_labels, "sum") assert sample_3dt_volume.fusi.extract.with_mask(mask) is expected @@ -122,17 +127,20 @@ def _bmode(data, **kwargs): coords={k: sample_3dt_volume_complex.coords[k] for k in ("z", "y", "x")}, ) - assert sample_3dt_volume_complex.fusi.iq.process_to_power_doppler( - clutter_window_width=11, - clutter_window_stride=7, - filter_method="butterworth", - clutter_mask=mask, - low_cutoff=30.5, - high_cutoff=80.0, - butterworth_order=6, - doppler_window_width=9, - doppler_window_stride=3, - ) is expected + assert ( + sample_3dt_volume_complex.fusi.iq.process_to_power_doppler( + clutter_window_width=11, + clutter_window_stride=7, + filter_method="butterworth", + clutter_mask=mask, + low_cutoff=30.5, + high_cutoff=80.0, + butterworth_order=6, + doppler_window_width=9, + doppler_window_stride=3, + ) + is expected + ) assert calls["pwd"] == ( sample_3dt_volume_complex, { @@ -148,21 +156,24 @@ def _bmode(data, **kwargs): }, ) - assert sample_3dt_volume_complex.fusi.iq.process_to_axial_velocity( - clutter_window_width=13, - clutter_window_stride=5, - filter_method="svd_energy", - clutter_mask=mask, - low_cutoff=2, - high_cutoff=12, - butterworth_order=2, - velocity_window_width=8, - velocity_window_stride=4, - lag=2, - absolute_velocity=True, - spatial_kernel=3, - estimation_method="angle_average", - ) is expected + assert ( + sample_3dt_volume_complex.fusi.iq.process_to_axial_velocity( + clutter_window_width=13, + clutter_window_stride=5, + filter_method="svd_energy", + clutter_mask=mask, + low_cutoff=2, + high_cutoff=12, + butterworth_order=2, + velocity_window_width=8, + velocity_window_stride=4, + lag=2, + absolute_velocity=True, + spatial_kernel=3, + estimation_method="angle_average", + ) + is expected + ) assert calls["vel"] == ( sample_3dt_volume_complex, { @@ -210,31 +221,36 @@ def _volumewise(data, **kwargs): return volumewise_result monkeypatch.setattr("confusius.xarray.registration.register_volume", _to_volume) - monkeypatch.setattr("confusius.xarray.registration.register_volumewise", _volumewise) + monkeypatch.setattr( + "confusius.xarray.registration.register_volumewise", _volumewise + ) fixed = sample_3d_volume.copy() - assert sample_3d_volume.fusi.register.to_volume( - fixed, - transform="affine", - metric="mattes_mi", - number_of_histogram_bins=40, - learning_rate=0.2, - number_of_iterations=25, - convergence_minimum_value=1e-4, - convergence_window_size=4, - initialization="moments", - optimizer_weights=[0, 0, 1, 1, 1, 1], - mesh_size=(3, 4, 5), - use_multi_resolution=True, - shrink_factors=(4, 2, 1), - smoothing_sigmas=(3, 1, 0), - resample=True, - resample_interpolation="bspline", - show_progress=True, - plot_metric=False, - plot_composite=False, - fill_value=-1.0, - ) is reg_result + assert ( + sample_3d_volume.fusi.register.to_volume( + fixed, + transform="affine", + metric="mattes_mi", + number_of_histogram_bins=40, + learning_rate=0.2, + number_of_iterations=25, + convergence_minimum_value=1e-4, + convergence_window_size=4, + initialization="center_moments", + optimizer_weights=[0, 0, 1, 1, 1, 1], + mesh_size=(3, 4, 5), + use_multi_resolution=True, + shrink_factors=(4, 2, 1), + smoothing_sigmas=(3, 1, 0), + resample=True, + resample_interpolation="bspline", + show_progress=True, + plot_metric=False, + plot_composite=False, + fill_value=-1.0, + ) + is reg_result + ) assert calls["to_volume"] == ( sample_3d_volume, fixed, @@ -246,7 +262,7 @@ def _volumewise(data, **kwargs): "number_of_iterations": 25, "convergence_minimum_value": 1e-4, "convergence_window_size": 4, - "centering_initialization": "moments", + "initialization": "center_moments", "optimizer_weights": [0, 0, 1, 1, 1, 1], "mesh_size": (3, 4, 5), "use_multi_resolution": True, @@ -261,25 +277,28 @@ def _volumewise(data, **kwargs): }, ) - assert sample_3d_volume.fusi.register.volumewise( - reference_time=2, - n_jobs=1, - transform="translation", - metric="mattes_mi", - number_of_histogram_bins=20, - learning_rate=0.15, - number_of_iterations=30, - convergence_minimum_value=1e-5, - convergence_window_size=5, - initialization="none", - optimizer_weights=[1, 1, 1, 0, 0, 1], - use_multi_resolution=True, - shrink_factors=(3, 1), - smoothing_sigmas=(2, 0), - resample_interpolation="bspline", - show_progress=False, - keep_diagnostics=True, - ) is volumewise_result + assert ( + sample_3d_volume.fusi.register.volumewise( + reference_time=2, + n_jobs=1, + transform="translation", + metric="mattes_mi", + number_of_histogram_bins=20, + learning_rate=0.15, + number_of_iterations=30, + convergence_minimum_value=1e-5, + convergence_window_size=5, + initialization=None, + optimizer_weights=[1, 1, 1, 0, 0, 1], + use_multi_resolution=True, + shrink_factors=(3, 1), + smoothing_sigmas=(2, 0), + resample_interpolation="bspline", + show_progress=False, + keep_diagnostics=True, + ) + is volumewise_result + ) assert calls["volumewise"] == ( sample_3d_volume, { @@ -292,7 +311,7 @@ def _volumewise(data, **kwargs): "number_of_iterations": 30, "convergence_minimum_value": 1e-5, "convergence_window_size": 5, - "initialization": "none", + "initialization": None, "optimizer_weights": [1, 1, 1, 0, 0, 1], "use_multi_resolution": True, "shrink_factors": (3, 1), @@ -455,24 +474,21 @@ def _composite(data, other, **kwargs): assert sample_3d_volume.fusi.plot.labels_from_layer("layer") is sample_roi_labels assert calls["labels"] == ("layer", sample_3d_volume) - assert ( - sample_3d_volume.fusi.plot.carpet( - mask=sample_roi_labels > 0, - detrend_order=1, - standardize=False, - cmap="viridis", - vmin=-1.0, - vmax=2.0, - decimation_threshold=None, - figsize=(6, 4), - title="carpet", - fontsize=12, - bg_color="black", - fg_color="white", - ax="existing_ax", - ) - == ("fig", "ax") - ) + assert sample_3d_volume.fusi.plot.carpet( + mask=sample_roi_labels > 0, + detrend_order=1, + standardize=False, + cmap="viridis", + vmin=-1.0, + vmax=2.0, + decimation_threshold=None, + figsize=(6, 4), + title="carpet", + fontsize=12, + bg_color="black", + fg_color="white", + ax="existing_ax", + ) == ("fig", "ax") assert calls["carpet"][0] is sample_3d_volume assert calls["carpet"][1]["detrend_order"] == 1 From 3d98458895e0e18673bf62ae5654a273d88c7ddd Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Fri, 26 Jun 2026 23:26:38 +0100 Subject: [PATCH 16/72] feat(registration): persist preview and result layers --- src/confusius/_napari/_registration/_panel.py | 323 ++++++++++-------- .../test_napari/test_registration_panel.py | 92 +++-- 2 files changed, 255 insertions(+), 160 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 5c3f6429..58ba3fdb 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -342,7 +342,7 @@ def _run_register_volume_registration_volume( number_of_histogram_bins: int = 50, convergence_minimum_value: float = 1e-6, convergence_window_size: int = 10, - initialization: Literal["center_geometry", "center_moments"] + center_initialization: Literal["center_geometry", "center_moments"] | None = "center_geometry", initial_transform: npt.NDArray[np.floating] | None = None, shrink_factors: Sequence[int] = (6, 2, 1), @@ -381,8 +381,8 @@ def _run_register_volume_registration_volume( Convergence threshold. convergence_window_size : int Window size for convergence estimation. - initialization : {"center_geometry", "center_moments"} or None - Transform initializer. + center_initialization : {"center_geometry", "center_moments"} or None + Center-based transform initializer. initial_transform : numpy.ndarray, optional Pre-computed affine transform used as a warm start before optimization. shrink_factors : sequence of int @@ -419,9 +419,9 @@ def _run_register_volume_registration_volume( number_of_histogram_bins=number_of_histogram_bins, convergence_minimum_value=convergence_minimum_value, convergence_window_size=convergence_window_size, - initialization=initialization - if initial_transform is None - else initial_transform, + initialization=( + center_initialization if initial_transform is None else initial_transform + ), shrink_factors=shrink_factors, smoothing_sigmas=smoothing_sigmas, fill_value=fill_value, @@ -804,41 +804,24 @@ def _setup_ui(self) -> None: ) self._initialization_combo = QComboBox() - self._initialization_combo.addItems( - ["center_geometry", "center_moments", "none"] + self._initialization_combo.setMinimumContentsLength(18) + self._initialization_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._initialization_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self._initialization_combo.setToolTip( - "Transform initializer before optimization. 'center_geometry' aligns centers; " - "'center_moments' aligns centers of mass; 'none' uses identity." + "How to initialize registration before optimization: center geometry, center moments, identity, or an existing affine transform from the Transforms tab." ) params_layout.addRow( self._make_form_label( "Initialization", - tooltip="How to initialize the transform before optimization: center geometry, center moments, or identity.", + tooltip="How to initialize registration before optimization: center geometry, center moments, identity, or an existing affine transform from the Transforms tab.", ), self._initialization_combo, ) - self._initial_transform_combo = QComboBox() - self._initial_transform_combo.setMinimumContentsLength(18) - self._initial_transform_combo.setSizeAdjustPolicy( - QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon - ) - self._initial_transform_combo.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self._initial_transform_combo.setToolTip( - "Optional pre-computed affine transform used as a warm start before optimization." - ) - self._initial_transform_row_label = self._make_form_label( - "Initial transform", - tooltip="Optional pre-computed affine transform from the Transforms tab used as a warm start before optimization.", - ) - params_layout.addRow( - self._initial_transform_row_label, - self._initial_transform_combo, - ) - learning_rate_row = QHBoxLayout() self._learning_rate_auto_check = QCheckBox("Auto") self._learning_rate_auto_check.setChecked(True) @@ -1167,9 +1150,6 @@ def _setup_ui(self) -> None: self._initialization_combo.currentTextChanged.connect( self._validate_registration_selection ) - self._initial_transform_combo.currentTextChanged.connect( - self._validate_registration_selection - ) self._transform_source_combo.currentTextChanged.connect( self._validate_registration_selection ) @@ -1251,19 +1231,46 @@ def _transform_source_label( self, payload: AffineTransformPayload, *, suffix: str | None = None ) -> str: """Return a user-facing label for a transform payload.""" - label = payload["name"] - if suffix: - label = f"{label} — {suffix}" - return label - - def _volume_result_layer_name(self, moving_name: str, fixed_name: str) -> str: + del suffix + return payload["name"] + + def _make_unique_layer_name(self, base_name: str) -> str: + """Return a viewer-unique layer name based on `base_name`.""" + existing_names = {layer.name for layer in self.viewer.layers} + if base_name not in existing_names: + return base_name + index = 1 + while True: + candidate = f"{base_name} [{index}]" + if candidate not in existing_names: + return candidate + index += 1 + + def _make_unique_transform_name(self, base_name: str) -> str: + """Return a viewer-unique transform payload name based on `base_name`.""" + existing_names = { + payload["name"] for payload in self._available_transform_payloads() + } + if base_name not in existing_names: + return base_name + index = 1 + while True: + candidate = f"{base_name} [{index}]" + if candidate not in existing_names: + return candidate + index += 1 + + def _volume_result_layer_name( + self, + moving_name: str, + fixed_name: str, + *, + transform_model: str | None = None, + ) -> str: """Return the napari layer name for between-scan registration output.""" del moving_name, fixed_name - return "Registered" - - def _volume_preview_layer_name(self) -> str: - """Return the napari layer name for between-scan progress preview.""" - return "Resampled moving" + model = transform_model or self._transform_combo.currentText() + return f"Registered ({model})" def _volume_fixed_preview_layer_name(self) -> str: """Return the napari layer name for the fixed preview layer.""" @@ -1282,10 +1289,21 @@ def _volumewise_moving_preview_layer_name(self) -> str: """Return the napari layer name for the within-scan moving preview.""" return "Moving" + def _available_transform_payloads(self) -> list[AffineTransformPayload]: + """Return all affine transform payloads currently available in the UI.""" + payloads: list[AffineTransformPayload] = [] + if self._loaded_transform_payload is not None: + payloads.append(self._loaded_transform_payload) + for layer in self.viewer.layers: + payload = _affine_payload_from_layer(layer) + if payload is not None: + payloads.append(payload) + return payloads + def _refresh_transform_controls(self) -> None: """Refresh transform-related layer selectors.""" source_data = self._transform_source_combo.currentData() - initial_transform_data = self._initial_transform_combo.currentData() + initialization_data = self._initialization_combo.currentData() target_name = self._transform_target_combo.currentText() transform_options: list[tuple[str, tuple[str, str]]] = [] @@ -1316,12 +1334,14 @@ def _refresh_transform_controls(self) -> None: self._transform_source_combo.addItem(label, data) self._transform_source_combo.blockSignals(False) - self._initial_transform_combo.blockSignals(True) - self._initial_transform_combo.clear() - self._initial_transform_combo.addItem("None", None) + self._initialization_combo.blockSignals(True) + self._initialization_combo.clear() + self._initialization_combo.addItem("center_geometry", "center_geometry") + self._initialization_combo.addItem("center_moments", "center_moments") + self._initialization_combo.addItem("none", None) for label, data in transform_options: - self._initial_transform_combo.addItem(label, data) - self._initial_transform_combo.blockSignals(False) + self._initialization_combo.addItem(label, data) + self._initialization_combo.blockSignals(False) self._transform_target_combo.blockSignals(True) self._transform_target_combo.clear() @@ -1336,10 +1356,10 @@ def _refresh_transform_controls(self) -> None: self._transform_source_combo.setCurrentIndex(i) break - if initial_transform_data is not None: - for i in range(self._initial_transform_combo.count()): - if self._initial_transform_combo.itemData(i) == initial_transform_data: - self._initial_transform_combo.setCurrentIndex(i) + if initialization_data is not None: + for i in range(self._initialization_combo.count()): + if self._initialization_combo.itemData(i) == initialization_data: + self._initialization_combo.setCurrentIndex(i) break target_index = self._transform_target_combo.findText(target_name) @@ -1363,11 +1383,18 @@ def _selected_transform_payload(self) -> AffineTransformPayload | None: return None return _affine_payload_from_layer(layer) + def _selected_center_initialization( + self, + ) -> Literal["center_geometry", "center_moments"] | None: + """Return the selected built-in centering initialization, if any.""" + value = self._initialization_combo.currentData() + if value in {"center_geometry", "center_moments"}: + return cast("Literal['center_geometry', 'center_moments']", value) + return None + def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: """Return the transform payload selected for registration initialization.""" - source_data = self._initial_transform_combo.currentData() - if source_data is None: - return None + source_data = self._initialization_combo.currentData() if not isinstance(source_data, tuple) or len(source_data) != 2: return None @@ -1594,8 +1621,7 @@ def _snapshot_mode_parameters(self, *, is_volumewise: bool) -> dict[str, Any]: return { "transform": self._transform_combo.currentText() or "rigid", "metric": self._metric_combo.currentText(), - "initialization": self._initialization_combo.currentText(), - "initial_transform_source": self._initial_transform_combo.currentData(), + "initialization": self._initialization_combo.currentData(), "learning_rate_auto": self._learning_rate_auto_check.isChecked(), "learning_rate_value": self._learning_rate_edit.value(), "number_of_iterations": self._iterations_spin.value(), @@ -1641,16 +1667,11 @@ def _apply_mode_parameters( self._transform_combo.blockSignals(False) self._metric_combo.setCurrentText(cast("str", params["metric"])) - self._initialization_combo.setCurrentText(cast("str", params["initialization"])) - initial_transform_source = params.get("initial_transform_source") - if initial_transform_source is not None: - for i in range(self._initial_transform_combo.count()): - if ( - self._initial_transform_combo.itemData(i) - == initial_transform_source - ): - self._initial_transform_combo.setCurrentIndex(i) - break + initialization_data = params.get("initialization") + for i in range(self._initialization_combo.count()): + if self._initialization_combo.itemData(i) == initialization_data: + self._initialization_combo.setCurrentIndex(i) + break self._learning_rate_auto_check.setChecked( cast("bool", params["learning_rate_auto"]) ) @@ -1705,9 +1726,6 @@ def _on_mode_changed(self) -> None: self._fixed_label.setVisible(not is_volumewise) self._fixed_combo.setVisible(not is_volumewise) self._fixed_combo.setEnabled(not is_volumewise) - self._initial_transform_row_label.setVisible(not is_volumewise) - self._initial_transform_combo.setVisible(not is_volumewise) - self._initial_transform_combo.setEnabled(not is_volumewise) self._reference_time_label.setVisible(is_volumewise) self._reference_time_spin.setVisible(is_volumewise) self._n_jobs_row.setVisible(is_volumewise) @@ -1778,14 +1796,34 @@ def _setup_volumewise_progress( attrs=moving.attrs.copy(), ) - _, moving_preview_layer = plot_napari( - moving, - viewer=self.viewer, - name=self._volumewise_moving_preview_layer_name(), - show_colorbar=False, - contrast_limits=contrast_limits, - **moving_display_kwargs, - ) + try: + moving_preview_layer = cast( + "Image", + self.viewer.layers[self._volumewise_moving_preview_layer_name()], + ) + except KeyError: + _, moving_preview_layer = plot_napari( + moving, + viewer=self.viewer, + name=self._volumewise_moving_preview_layer_name(), + show_colorbar=False, + contrast_limits=contrast_limits, + **moving_display_kwargs, + ) + else: + moving_preview_layer.data = np.asarray(moving.data) # type: ignore[invalid-assignment] + moving_preview_layer.colormap = moving_display_kwargs["colormap"] + moving_preview_layer.contrast_limits = contrast_limits + + try: + fixed_preview_layer = cast( + "Image", self.viewer.layers[self._volume_fixed_preview_layer_name()] + ) + except KeyError: + fixed_preview_layer = None + else: + fixed_preview_layer.visible = False + _, layer = plot_napari( preview, viewer=self.viewer, @@ -1842,19 +1880,15 @@ def _update_volumewise_progress_frame( def _teardown_volumewise_progress(self, *, remove_layer: bool) -> None: """Reset volumewise progress-layer state.""" - if remove_layer: - for attr_name in ( - "_volumewise_progress_layer", - "_volumewise_moving_preview_layer", - ): - layer = cast("Image | None", getattr(self, attr_name)) - if layer is not None: - try: - self.viewer.layers.remove(layer) - except (KeyError, ValueError): - pass - setattr(self, attr_name, None) + if remove_layer and self._volumewise_progress_layer is not None: + try: + self.viewer.layers.remove(self._volumewise_progress_layer) + except (KeyError, ValueError): + pass + self._volumewise_progress_layer = None self._volumewise_progress_bridge = None + if not remove_layer: + self._volumewise_progress_layer = None self._volumewise_progress_time_axis = None self._volumewise_progress_total = None @@ -1953,20 +1987,42 @@ def _setup_volume_progress( ) try: - _, fixed_preview_layer = plot_napari( - fixed, - viewer=self.viewer, - name=self._volume_fixed_preview_layer_name(), - show_colorbar=False, - **fixed_display_kwargs, - ) - _, moving_preview_layer = plot_napari( - moving, - viewer=self.viewer, - name=self._volume_moving_preview_layer_name(), - show_colorbar=False, - **moving_display_kwargs, - ) + try: + fixed_preview_layer = cast( + "Image", self.viewer.layers[self._volume_fixed_preview_layer_name()] + ) + except KeyError: + _, fixed_preview_layer = plot_napari( + fixed, + viewer=self.viewer, + name=self._volume_fixed_preview_layer_name(), + show_colorbar=False, + **fixed_display_kwargs, + ) + else: + fixed_preview_layer.data = np.asarray(fixed.data) # type: ignore[invalid-assignment] + fixed_preview_layer.colormap = fixed_display_kwargs["colormap"] + fixed_preview_layer.visible = True + + try: + moving_preview_layer = cast( + "Image", + self.viewer.layers[self._volume_moving_preview_layer_name()], + ) + except KeyError: + _, moving_preview_layer = plot_napari( + moving, + viewer=self.viewer, + name=self._volume_moving_preview_layer_name(), + show_colorbar=False, + **moving_display_kwargs, + ) + else: + moving_preview_layer.data = np.asarray(moving.data) # type: ignore[invalid-assignment] + moving_preview_layer.colormap = moving_display_kwargs["colormap"] + moving_preview_layer.blending = moving_display_kwargs["blending"] + moving_preview_layer.visible = False + _, layer = plot_napari( preview, viewer=self.viewer, @@ -2030,18 +2086,12 @@ def _teardown_volume_progress(self) -> None: The metric plotter is kept (docked, with its final trace) so the user can inspect the convergence curve after the run. """ - for attr_name in ( - "_progress_layer", - "_progress_fixed_layer", - "_progress_moving_layer", - ): - layer = cast("Image | None", getattr(self, attr_name)) - if layer is not None: - try: - self.viewer.layers.remove(layer) - except (KeyError, ValueError): - pass - setattr(self, attr_name, None) + if self._progress_layer is not None: + try: + self.viewer.layers.remove(self._progress_layer) + except (KeyError, ValueError): + pass + self._progress_layer = None # Drop the bridge reference; the plotter connection becomes inert # when the bridge is garbage-collected. self._progress_bridge = None @@ -2291,7 +2341,7 @@ def _run_registration(self) -> None: "number_of_histogram_bins": self._histogram_bins_spin.value(), "convergence_minimum_value": convergence_minimum_value, "convergence_window_size": self._convergence_window_spin.value(), - "initialization": self._initialization_combo.currentText(), + "initialization": self._initialization_combo.currentData(), "shrink_factors": shrink_factors, "smoothing_sigmas": smoothing_sigmas, "keep_diagnostics": self._keep_diagnostics_check.isChecked(), @@ -2337,7 +2387,13 @@ def _run_registration(self) -> None: fixed_layer=cast("Image", fixed_layer), moving=moving, fixed=fixed, - layer_name=self._volume_preview_layer_name(), + layer_name=self._make_unique_layer_name( + self._volume_result_layer_name( + payload["moving_layer_name"], + payload["fixed_layer_name"], + transform_model=payload["transform"], + ) + ), ) worker = thread_worker(_run_register_volume_registration_volume)( @@ -2358,12 +2414,7 @@ def _run_registration(self) -> None: number_of_histogram_bins=payload["number_of_histogram_bins"], convergence_minimum_value=payload["convergence_minimum_value"], convergence_window_size=payload["convergence_window_size"], - initialization=cast( - "Literal['center_geometry', 'center_moments'] | None", - None - if payload["initialization"] == "none" - else payload["initialization"], - ), + center_initialization=self._selected_center_initialization(), initial_transform=initial_transform, shrink_factors=payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=payload["smoothing_sigmas"] or (6, 2, 1), @@ -2384,8 +2435,8 @@ def _run_registration(self) -> None: progress_reporter = self._setup_volumewise_progress( moving_layer=cast("Image", moving_layer), moving=moving, - layer_name=self._volumewise_result_layer_name( - payload["moving_layer_name"] + layer_name=self._make_unique_layer_name( + self._volumewise_result_layer_name(payload["moving_layer_name"]) ), ) @@ -2406,12 +2457,7 @@ def _run_registration(self) -> None: number_of_histogram_bins=payload["number_of_histogram_bins"], convergence_minimum_value=payload["convergence_minimum_value"], convergence_window_size=payload["convergence_window_size"], - initialization=cast( - "Literal['center_geometry', 'center_moments'] | None", - None - if payload["initialization"] == "none" - else payload["initialization"], - ), + initialization=self._selected_center_initialization(), shrink_factors=payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=payload["smoothing_sigmas"] or (6, 2, 1), keep_diagnostics=payload["keep_diagnostics"], @@ -2454,6 +2500,7 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non layer_name = self._volume_result_layer_name( cast("str", payload["moving_layer_name"]), cast("str", payload["fixed_layer_name"]), + transform_model=cast("str", payload["transform"]), ) metadata: dict[str, Any] = { "registration_transform": transform, @@ -2462,6 +2509,9 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non } if isinstance(transform, np.ndarray): affine_transform = np.asarray(transform, dtype=float) + transform_name = self._make_unique_transform_name( + f"{payload['moving_layer_name']} → {payload['fixed_layer_name']} ({payload['transform']})" + ) metadata["confusius_transform"] = make_affine_transform_payload( affine_transform, reference=registered, @@ -2471,6 +2521,7 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non transform_model=cast(str, payload["transform"]), metric=cast(str, payload["metric"]), diagnostics=diagnostics, + name=transform_name, ) else: registered = cast("xr.DataArray", result).copy(deep=False) @@ -2505,10 +2556,10 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non if operation == "register_volume" and self._progress_layer is not None: layer = self._progress_layer layer.data = np.asarray(registered.data) # type: ignore[invalid-assignment] - layer.name = layer_name if hasattr(layer, "contrast_limits"): layer.contrast_limits = contrast_limits self._progress_bridge = None + self._progress_layer = None elif ( operation == "register_volumewise" and self._volumewise_progress_layer is not None diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index a6d848b7..4f62794a 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -229,12 +229,12 @@ def test_between_scan_run_uses_selected_initial_transform( registration_panel._refresh_transform_controls() registration_panel._moving_combo.setCurrentText("moving") registration_panel._fixed_combo.setCurrentText("fixed") - for i in range(registration_panel._initial_transform_combo.count()): - if registration_panel._initial_transform_combo.itemData(i) == ( + for i in range(registration_panel._initialization_combo.count()): + if registration_panel._initialization_combo.itemData(i) == ( "layer", "Previous registered", ): - registration_panel._initial_transform_combo.setCurrentIndex(i) + registration_panel._initialization_combo.setCurrentIndex(i) break captured: dict[str, object] = {} @@ -274,7 +274,7 @@ def _runner(*args, **kwargs): registration_panel._run_registration() np.testing.assert_array_equal(captured["kwargs"]["initial_transform"], affine) - assert captured["kwargs"]["initialization"] == "geometry" + assert captured["kwargs"]["center_initialization"] is None assert registration_panel._worker is not None @@ -389,12 +389,12 @@ def test_initial_transform_dropdown_lists_available_transforms( viewer.add_image(reference.values, name="Registered", metadata={"confusius_transform": payload}) registration_panel._refresh_transform_controls() - assert registration_panel._initial_transform_combo.itemText(0) == "None" - assert registration_panel._initial_transform_combo.count() >= 2 + assert registration_panel._initialization_combo.itemText(0) == "center_geometry" + assert registration_panel._initialization_combo.count() >= 4 assert any( - registration_panel._initial_transform_combo.itemData(i) + registration_panel._initialization_combo.itemData(i) == ("layer", "Registered") - for i in range(registration_panel._initial_transform_combo.count()) + for i in range(registration_panel._initialization_combo.count()) ) @@ -624,7 +624,7 @@ def test_volume_result_adds_new_layer_with_transform_metadata( (registered, transform, diagnostics), ) - layer = viewer.layers["Registered"] + layer = viewer.layers["Registered (rigid)"] assert layer.metadata["registration_transform"] is transform assert layer.metadata["registration_diagnostics"] is diagnostics assert layer.metadata["registration_status"] == "completed" @@ -667,10 +667,10 @@ def test_volume_result_replaces_preview_layer( fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, - layer_name="Resampled moving", + layer_name="Registered (rigid)", ) assert factory is not None - assert {"Fixed", "Moving", "Resampled moving"}.issubset( + assert {"Fixed", "Moving", "Registered (rigid)"}.issubset( {layer.name for layer in viewer.layers} ) assert registration_panel._progress_layer is not None @@ -685,7 +685,7 @@ def test_volume_result_replaces_preview_layer( # Dedicated preview layers carry the registration styling. fixed_preview = viewer.layers["Fixed"] moving_preview = viewer.layers["Moving"] - preview_layer = viewer.layers["Resampled moving"] + preview_layer = viewer.layers["Registered (rigid)"] assert fixed_preview.colormap.name == "red" assert moving_preview.colormap.name == "cyan" assert moving_preview.blending == "additive" @@ -721,13 +721,13 @@ def test_volume_result_replaces_preview_layer( # The resampled preview is kept and promoted to the final registered # layer so the user can keep reviewing the fixed / moving / result # stack after the run. - assert registration_panel._progress_layer is viewer.layers["Registered"] + assert registration_panel._progress_layer is None assert registration_panel._progress_bridge is None - assert {"Fixed", "Moving", "Registered"}.issubset( + assert {"Fixed", "Moving", "Registered (rigid)"}.issubset( {layer.name for layer in viewer.layers} ) assert not viewer.layers["Moving"].visible - result_layer = viewer.layers["Registered"] + result_layer = viewer.layers["Registered (rigid)"] assert result_layer.colormap.name == "cyan" assert result_layer.blending == "additive" # Original source layers remain untouched. @@ -769,33 +769,34 @@ def test_progress_layer_data_updates_on_iteration( fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, - layer_name="Resampled moving", + layer_name="Registered (rigid)", ) # The preview is seeded with the moving image resampled onto the # fixed grid, so it's visible and meaningful from the start. - preview_layer = viewer.layers["Resampled moving"] + preview_layer = viewer.layers["Registered (rigid)"] assert preview_layer.visible next_arr = np.full((4, 6), 0.5, dtype=np.float32) registration_panel._update_progress_layer(next_arr) np.testing.assert_array_equal( - np.asarray(viewer.layers["Resampled moving"].data), next_arr + np.asarray(viewer.layers["Registered (rigid)"].data), next_arr ) # Shape mismatch is silently ignored. registration_panel._update_progress_layer(np.zeros((3, 6), dtype=np.float32)) np.testing.assert_array_equal( - np.asarray(viewer.layers["Resampled moving"].data), next_arr + np.asarray(viewer.layers["Registered (rigid)"].data), next_arr ) - # Teardown removes the preview layers while leaving the originals untouched. + # Teardown removes only the in-flight registered layer while leaving + # the reusable fixed / moving previews and originals untouched. registration_panel._teardown_volume_progress() assert registration_panel._progress_layer is None assert registration_panel._progress_bridge is None - assert "Resampled moving" not in {layer.name for layer in viewer.layers} - assert "Fixed" not in {layer.name for layer in viewer.layers} - assert "Moving" not in {layer.name for layer in viewer.layers} + assert "Registered (rigid)" not in {layer.name for layer in viewer.layers} + assert "Fixed" in {layer.name for layer in viewer.layers} + assert "Moving" in {layer.name for layer in viewer.layers} assert moving.visible assert moving.colormap.name != "cyan" assert moving.blending != "additive" @@ -829,7 +830,7 @@ def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, - layer_name="Resampled moving", + layer_name="Registered (rigid)", ) assert registration_panel._metric_plotter is not None @@ -934,3 +935,46 @@ def test_volumewise_finished_keeps_preview_layers( ) assert viewer.layers["series"].colormap.name != "red" assert viewer.layers["Moving"].colormap.name == "red" + + def test_unique_transform_and_result_names(self, viewer, registration_panel): + fixed = xr.DataArray( + np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + payload = { + "operation": "register_volume", + "moving_layer_name": "moving", + "fixed_layer_name": "fixed", + "transform": "rigid", + "metric": "correlation", + "learning_rate": "auto", + "number_of_iterations": 100, + "use_multi_resolution": False, + "resample_interpolation": "linear", + } + transform = np.eye(3) + diagnostics = _FakeDiagnostics() + + registration_panel._on_registration_finished( + payload, + (fixed.copy(), transform, diagnostics), + ) + registration_panel._on_registration_finished( + payload, + (fixed.copy(), transform, diagnostics), + ) + + assert "Registered (rigid)" in {layer.name for layer in viewer.layers} + assert "Registered (rigid) [1]" in {layer.name for layer in viewer.layers} + names = [ + viewer.layers[name].metadata["confusius_transform"]["name"] + for name in ("Registered (rigid)", "Registered (rigid) [1]") + ] + assert names == [ + "moving → fixed (rigid)", + "moving → fixed (rigid) [1]", + ] From 7929a9e2c835ee89c3039e2f9c746b469feec031 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sat, 27 Jun 2026 11:22:15 +0200 Subject: [PATCH 17/72] feat(registration): support manual napari transforms --- AGENTS.md | 1 + src/confusius/_napari/_registration/_panel.py | 344 +++++++++++++++-- .../test_napari/test_registration_panel.py | 358 +++++++++++++++++- 3 files changed, 660 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1acb7657..2fb5ccad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,7 @@ Coverage reports are generated automatically (terminal, HTML in `htmlcov/`, and ### Naming Conventions - Functions/methods: `snake_case` +- Prefer imperative verb phrases for function names (for example, `get_source_dataarray`, `reconstruct_layer_dataarray`, `validate_inputs`), not noun phrases. - Classes: `PascalCase` - Constants: `UPPER_CASE` - Private functions/methods: leading underscore `_function_name` diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 58ba3fdb..d77f1add 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -123,8 +123,8 @@ def _normalize_layer_sequence(values: Any, ndim: int, fill: Any) -> list[Any]: return seq -def _layer_to_dataarray(layer: "Layer") -> xr.DataArray: - """Return an `xarray.DataArray` view of a napari layer. +def _reconstruct_layer_dataarray(layer: "Layer") -> xr.DataArray: + """Reconstruct a DataArray from the current napari layer state. Parameters ---------- @@ -134,13 +134,9 @@ def _layer_to_dataarray(layer: "Layer") -> xr.DataArray: Returns ------- xarray.DataArray - Original ConfUSIus DataArray when present in `layer.metadata`, - otherwise a reconstructed DataArray derived from the layer state. + DataArray reconstructed from the layer's current axis labels, + scale, translate, and units. """ - existing = layer.metadata.get("xarray") - if existing is not None: - return cast("xr.DataArray", existing) - data = np.asarray(layer.data) ndim = data.ndim @@ -179,6 +175,34 @@ def _layer_to_dataarray(layer: "Layer") -> xr.DataArray: return xr.DataArray(data, dims=axis_labels, coords=coords) +def _get_source_dataarray(layer: "Layer") -> xr.DataArray: + """Return the stable source DataArray for a napari layer. + + Parameters + ---------- + layer : napari.layers.Layer + Napari layer to convert. + + Returns + ------- + xarray.DataArray + Original ConfUSIus DataArray when present in `layer.metadata`, + otherwise a cached reconstruction captured before later manual napari + transforms mutate the layer pose. + """ + existing = layer.metadata.get("xarray") + if existing is not None: + return cast("xr.DataArray", existing) + + cached = layer.metadata.get("confusius_original_xarray") + if cached is not None: + return cast("xr.DataArray", cached) + + reconstructed = _reconstruct_layer_dataarray(layer) + layer.metadata["confusius_original_xarray"] = reconstructed + return reconstructed + + def _prepare_between_scan_data(data: xr.DataArray) -> xr.DataArray: """Return a spatial-only DataArray for between-scan registration. @@ -328,7 +352,7 @@ def stepBy(self, steps: int) -> None: self.setValue(self.value() + (steps * self.singleStep())) -def _run_register_volume_registration_volume( +def _run_register_volume( moving: xr.DataArray, fixed: xr.DataArray, *, @@ -353,7 +377,7 @@ def _run_register_volume_registration_volume( ) -> tuple[ xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics ]: - """Run `register_volume` with GUI-friendly defaults. + """Run `register_volume` from the GUI. Parameters ---------- @@ -451,6 +475,125 @@ def _affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: return cast("AffineTransformPayload", payload) +def _spatial_manual_affine_from_layer( + layer: "Layer", *, spatial_dims: Sequence[str] +) -> npt.NDArray[np.float64]: + """Return the spatial sub-affine from a napari layer's manual transform. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose manual napari affine should be extracted. + spatial_dims : sequence of str + Spatial dimension names, in the exact order expected by registration. + + Returns + ------- + (N+1, N+1) numpy.ndarray + Spatial homogeneous affine in world coordinates. + + Raises + ------ + ValueError + If the layer does not contain the requested spatial dimensions. + ValueError + If the layer affine has an unexpected shape. + ValueError + If the manual affine mixes selected spatial axes with ignored axes. + """ + data = _get_source_dataarray(layer) + layer_dims = [str(dim) for dim in data.dims] + missing_dims = [dim for dim in spatial_dims if dim not in layer_dims] + if missing_dims: + raise ValueError( + "Selected manual napari transform does not contain spatial dims " + f"{missing_dims}." + ) + + affine = np.asarray(layer.affine.affine_matrix, dtype=float) + expected_shape = (len(layer_dims) + 1, len(layer_dims) + 1) + if affine.shape != expected_shape: + raise ValueError( + f"Selected manual napari transform has shape {affine.shape}, " + f"but layer '{layer.name}' expects {expected_shape}." + ) + + spatial_indices = [layer_dims.index(dim) for dim in spatial_dims] + ignored_indices = [i for i in range(len(layer_dims)) if i not in spatial_indices] + linear = affine[:-1, :-1] + + if ignored_indices: + spatial_to_ignored = linear[np.ix_(spatial_indices, ignored_indices)] + ignored_to_spatial = linear[np.ix_(ignored_indices, spatial_indices)] + if not np.allclose(spatial_to_ignored, 0.0) or not np.allclose( + ignored_to_spatial, 0.0 + ): + raise ValueError( + "Selected manual napari transform mixes spatial axes with ignored " + "non-spatial axes, so it cannot be used as a registration " + "initialization." + ) + + spatial_affine = np.eye(len(spatial_dims) + 1, dtype=float) + spatial_affine[:-1, :-1] = linear[np.ix_(spatial_indices, spatial_indices)] + spatial_affine[:-1, -1] = affine[np.ix_(spatial_indices, [-1])].ravel() + return spatial_affine + + +def _make_manual_transform_payload(layer: "Layer") -> AffineTransformPayload: + """Build an affine payload from a layer's manual napari transform. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose current manual napari transform should be serialized. + + Returns + ------- + AffineTransformPayload + JSON-serializable affine payload representing the visible manual layer + transform on the layer's own spatial output grid. + """ + data = _get_source_dataarray(layer) + spatial_data = _prepare_between_scan_data(data) + spatial_dims = [str(dim) for dim in spatial_data.dims if dim in SPATIAL_DIMS] + manual_affine = _spatial_manual_affine_from_layer(layer, spatial_dims=spatial_dims) + pull_affine = np.linalg.inv(manual_affine) + return { + "kind": "affine", + "name": f"{layer.name} (manual)", + "affine": pull_affine.tolist(), + "source_layer_name": layer.name, + "target_layer_name": layer.name, + "operation": "manual_napari_transform", + "transform_model": "affine", + "metric": "manual", + "output_grid": { + "dims": [str(dim) for dim in spatial_data.dims], + "shape": [int(spatial_data.sizes[dim]) for dim in spatial_data.dims], + "spacing": [ + float(spatial_data.fusi.spacing[dim]) for dim in spatial_data.dims + ], + "origin": [ + float(spatial_data.fusi.origin[dim]) for dim in spatial_data.dims + ], + "units": [ + cast("str | None", spatial_data.coords[dim].attrs.get("units")) + if dim in spatial_data.coords + else None + for dim in spatial_data.dims + ], + }, + "diagnostics": { + "metric": "manual", + "final_metric_value": 0.0, + "n_iterations": 0, + "stop_condition": "Saved from manual napari layer transform.", + "status": "completed", + }, + } + + def _run_register_volumewise( data: xr.DataArray, *, @@ -473,7 +616,7 @@ def _run_register_volumewise( abort_event: Event | None = None, progress_reporter: NapariVolumewiseProgress | None = None, ) -> xr.DataArray: - """Run `register_volumewise` with GUI-friendly defaults. + """Run `register_volumewise` from the GUI. Parameters ---------- @@ -562,6 +705,7 @@ def __init__(self, viewer: napari.Viewer) -> None: self._progress_layer: Image | None = None self._progress_fixed_layer: Image | None = None self._progress_moving_layer: Image | None = None + self._manual_transform_event_layers: list[Layer] = [] self._volumewise_progress_bridge: NapariVolumewiseProgressBridge | None = None self._volumewise_progress_layer: Image | None = None self._volumewise_moving_preview_layer: Image | None = None @@ -1081,7 +1225,7 @@ def _setup_ui(self) -> None: transforms_group = QGroupBox("Transforms") transforms_group.setToolTip( - "Save, load, and reapply affine transforms estimated from between-scan registration." + "Save, load, and reapply affine transforms from registration results or manual napari layer transforms." ) transforms_layout = QFormLayout(transforms_group) transforms_layout.setSpacing(6) @@ -1108,7 +1252,10 @@ def _setup_ui(self) -> None: self._transform_target_combo.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - transforms_layout.addRow("Input layer", self._transform_target_combo) + self._transform_target_combo.setToolTip( + "Layer to resample when applying the selected transform." + ) + transforms_layout.addRow("Apply to", self._transform_target_combo) transform_buttons = QHBoxLayout() self._save_transform_btn = QPushButton("Save") @@ -1173,6 +1320,20 @@ def _setup_ui(self) -> None: self._on_panel_changed() self._on_mode_changed() + def _sync_manual_transform_event_connections(self) -> None: + """Keep manual-transform refresh hooks in sync with viewer layers.""" + for layer in self._manual_transform_event_layers: + try: + layer.events.affine.disconnect(self._refresh_transform_controls) + except (TypeError, RuntimeError): + pass + self._manual_transform_event_layers = [] + + for layer in self.viewer.layers: + _get_source_dataarray(cast("Layer", layer)) + layer.events.affine.connect(self._refresh_transform_controls) + self._manual_transform_event_layers.append(cast("Layer", layer)) + def _refresh_layers(self) -> None: """Repopulate the layer selectors from the viewer.""" moving_name = self._moving_combo.currentText() @@ -1203,6 +1364,7 @@ def _refresh_layers(self) -> None: self._fixed_combo.setCurrentIndex(1) self._update_reference_time_bounds() + self._sync_manual_transform_event_connections() self._refresh_transform_controls() self._validate_registration_selection() @@ -1328,10 +1490,32 @@ def _refresh_transform_controls(self) -> None: ) ) + manual_transform_options: list[tuple[str, tuple[str, str]]] = [] + manual_initialization_options: list[tuple[str, tuple[str, str]]] = [] + for layer in self.viewer.layers: + try: + data = _get_source_dataarray(layer) + spatial_dims = [str(dim) for dim in data.dims if dim in SPATIAL_DIMS] + if not spatial_dims: + continue + manual_affine = _spatial_manual_affine_from_layer( + layer, + spatial_dims=spatial_dims, + ) + except Exception: # noqa: BLE001 + continue + if np.allclose(manual_affine, np.eye(len(spatial_dims) + 1)): + continue + manual_option = (f"{layer.name} (manual)", ("manual", layer.name)) + manual_transform_options.append(manual_option) + manual_initialization_options.append(manual_option) + self._transform_source_combo.blockSignals(True) self._transform_source_combo.clear() for label, data in transform_options: self._transform_source_combo.addItem(label, data) + for label, data in manual_transform_options: + self._transform_source_combo.addItem(label, data) self._transform_source_combo.blockSignals(False) self._initialization_combo.blockSignals(True) @@ -1341,6 +1525,8 @@ def _refresh_transform_controls(self) -> None: self._initialization_combo.addItem("none", None) for label, data in transform_options: self._initialization_combo.addItem(label, data) + for label, data in manual_initialization_options: + self._initialization_combo.addItem(label, data) self._initialization_combo.blockSignals(False) self._transform_target_combo.blockSignals(True) @@ -1375,13 +1561,17 @@ def _selected_transform_payload(self) -> AffineTransformPayload | None: source_kind, source_name = source_data if source_kind == "loaded": return self._loaded_transform_payload - if source_kind != "layer" or not source_name: + if not source_name: return None try: layer = cast("Layer", self.viewer.layers[source_name]) except KeyError: return None - return _affine_payload_from_layer(layer) + if source_kind == "layer": + return _affine_payload_from_layer(layer) + if source_kind == "manual": + return _make_manual_transform_payload(layer) + return None def _selected_center_initialization( self, @@ -1393,7 +1583,7 @@ def _selected_center_initialization( return None def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: - """Return the transform payload selected for registration initialization.""" + """Return the payload selected for registration initialization, if any.""" source_data = self._initialization_combo.currentData() if not isinstance(source_data, tuple) or len(source_data) != 2: return None @@ -1409,6 +1599,55 @@ def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: return None return _affine_payload_from_layer(layer) + def _selected_manual_initialization_layer(self) -> Layer | None: + """Return the layer selected for manual napari initialization, if any.""" + source_data = self._initialization_combo.currentData() + if not isinstance(source_data, tuple) or len(source_data) != 2: + return None + + source_kind, source_name = source_data + if source_kind != "manual" or not source_name: + return None + try: + return cast("Layer", self.viewer.layers[source_name]) + except KeyError: + return None + + def _selected_initial_transform( + self, + moving: xr.DataArray, + *, + moving_layer: Layer | None = None, + fixed_layer: Layer | None = None, + ) -> tuple[npt.NDArray[np.float64] | None, str | None]: + """Return the selected initialization affine and its source label.""" + payload = self._selected_initial_transform_payload() + if payload is not None: + return affine_transform_from_payload(payload), payload["name"] + + layer = self._selected_manual_initialization_layer() + if layer is None: + return None, None + if moving_layer is None or fixed_layer is None: + raise ValueError("Select moving and fixed layers.") + if layer not in {moving_layer, fixed_layer}: + raise ValueError( + "Selected manual initialization must come from the current moving " + "or fixed layer." + ) + + spatial_dims = [str(dim) for dim in moving.dims if dim in SPATIAL_DIMS] + moving_affine = _spatial_manual_affine_from_layer( + moving_layer, + spatial_dims=spatial_dims, + ) + fixed_affine = _spatial_manual_affine_from_layer( + fixed_layer, + spatial_dims=spatial_dims, + ) + affine = np.linalg.inv(moving_affine) @ fixed_affine + return affine, f"{layer.name} (manual)" + def _validate_initial_transform_selection( self, *, @@ -1417,17 +1656,31 @@ def _validate_initial_transform_selection( fixed: xr.DataArray | None = None, ) -> str | None: """Return an inline validation message for transform initialization.""" - payload = self._selected_initial_transform_payload() - if payload is None or operation != "register_volume": + if operation != "register_volume": + return None + if ( + self._selected_initial_transform_payload() is None + and self._selected_manual_initialization_layer() is None + ): return None if fixed is None: return "Select a fixed layer." + moving_layer = self._selected_layer(self._moving_combo) + fixed_layer = self._selected_layer(self._fixed_combo) + try: - affine = affine_transform_from_payload(payload) + affine, _ = self._selected_initial_transform( + moving, + moving_layer=moving_layer, + fixed_layer=fixed_layer, + ) except Exception as exc: # noqa: BLE001 return str(exc) + if affine is None: + return None + expected_shape = (moving.ndim + 1, moving.ndim + 1) if affine.shape != expected_shape: return ( @@ -1444,7 +1697,7 @@ def _update_reference_time_bounds(self) -> None: self._reference_time_spin.setValue(0) return - data = _layer_to_dataarray(moving_layer) + data = _get_source_dataarray(moving_layer) if TIME_DIM not in data.dims: self._reference_time_spin.setMaximum(0) self._reference_time_spin.setValue(0) @@ -1508,7 +1761,7 @@ def _validate_registration_selection(self) -> bool: return False try: - moving = _layer_to_dataarray(moving_layer) + moving = _get_source_dataarray(moving_layer) except Exception: self._set_layer_validation_style( moving_invalid=True, @@ -1547,7 +1800,7 @@ def _validate_registration_selection(self) -> bool: return False try: - fixed = _layer_to_dataarray(fixed_layer) + fixed = _get_source_dataarray(fixed_layer) except Exception: self._set_layer_validation_style( fixed_invalid=True, @@ -1580,6 +1833,7 @@ def _validate_registration_selection(self) -> bool: def _on_moving_layer_changed(self, _name: str) -> None: """Update dependent widgets when the moving layer changes.""" self._update_reference_time_bounds() + self._refresh_transform_controls() self._validate_registration_selection() def _operation(self) -> Literal["register_volume", "register_volumewise"]: @@ -1900,6 +2154,7 @@ def _setup_volume_progress( moving: xr.DataArray, fixed: xr.DataArray, layer_name: str, + initial_transform: npt.NDArray[np.floating] | None = None, ) -> "Callable[..., RegistrationProgress] | None": """Build a napari progress bridge and preview layer for register_volume. @@ -1926,6 +2181,9 @@ def _setup_volume_progress( empty preview grid. layer_name : str Name for the preview (and later final) layer. + initial_transform : numpy.ndarray, optional + Explicit affine initialization used to seed the preview layers. If + not provided, the preview starts from the identity transform. Returns ------- @@ -1965,11 +2223,15 @@ def _setup_volume_progress( # would flash a full-FOV tint. The SimpleITK iteration events then # overwrite the data in place as the registration progresses. try: - identity = np.eye(fixed.ndim + 1, dtype=float) + seed_transform = ( + np.asarray(initial_transform, dtype=float) + if initial_transform is not None + else np.eye(fixed.ndim + 1, dtype=float) + ) preview = resample_like( moving, fixed, - identity, + seed_transform, interpolation=cast( "Literal['linear', 'bspline']", "linear", @@ -2011,14 +2273,14 @@ def _setup_volume_progress( ) except KeyError: _, moving_preview_layer = plot_napari( - moving, + preview, viewer=self.viewer, name=self._volume_moving_preview_layer_name(), show_colorbar=False, **moving_display_kwargs, ) else: - moving_preview_layer.data = np.asarray(moving.data) # type: ignore[invalid-assignment] + moving_preview_layer.data = np.asarray(preview.data) # type: ignore[invalid-assignment] moving_preview_layer.colormap = moving_display_kwargs["colormap"] moving_preview_layer.blending = moving_display_kwargs["blending"] moving_preview_layer.visible = False @@ -2259,7 +2521,7 @@ def _apply_transform(self) -> None: return try: - moving = _layer_to_dataarray(moving_layer) + moving = _get_source_dataarray(moving_layer) affine = affine_transform_from_payload(payload) output_grid = output_grid_from_payload(payload) except Exception as exc: # noqa: BLE001 @@ -2309,7 +2571,7 @@ def _run_registration(self) -> None: learning_rate = "auto" else: learning_rate = self._learning_rate_edit.value() - moving = _layer_to_dataarray(moving_layer) + moving = _get_source_dataarray(moving_layer) except Exception as exc: # noqa: BLE001 self._set_error(str(exc)) return @@ -2360,7 +2622,7 @@ def _run_registration(self) -> None: return try: - fixed = _layer_to_dataarray(fixed_layer) + fixed = _get_source_dataarray(fixed_layer) except Exception as exc: # noqa: BLE001 self._set_error(str(exc)) return @@ -2368,17 +2630,20 @@ def _run_registration(self) -> None: moving = _prepare_between_scan_data(moving) fixed = _prepare_between_scan_data(fixed) - initial_transform_payload = self._selected_initial_transform_payload() initial_transform: npt.NDArray[np.floating] | None = None - if initial_transform_payload is not None: - try: - initial_transform = affine_transform_from_payload( - initial_transform_payload + try: + initial_transform, initial_transform_source = ( + self._selected_initial_transform( + moving, + moving_layer=moving_layer, + fixed_layer=fixed_layer, ) - except Exception as exc: # noqa: BLE001 - self._set_error(str(exc)) - return - payload["initial_transform_source"] = initial_transform_payload["name"] + ) + except Exception as exc: # noqa: BLE001 + self._set_error(str(exc)) + return + if initial_transform_source is not None: + payload["initial_transform_source"] = initial_transform_source payload["fixed_layer_name"] = fixed_layer.name @@ -2394,9 +2659,10 @@ def _run_registration(self) -> None: transform_model=payload["transform"], ) ), + initial_transform=initial_transform, ) - worker = thread_worker(_run_register_volume_registration_volume)( + worker = thread_worker(_run_register_volume)( moving, fixed, transform_type=cast( diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 4f62794a..a6b458d2 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -8,6 +8,7 @@ import numpy as np import pytest import xarray as xr +from qtpy.QtWidgets import QApplication from confusius._napari._registration._transforms import ( affine_transform_from_payload, @@ -16,6 +17,7 @@ output_grid_from_payload, save_affine_transform_payload, ) +from confusius.registration import resample_like @pytest.fixture @@ -72,6 +74,13 @@ def test_parallel_jobs_is_in_advanced_parameters(self, registration_panel): assert not registration_panel._n_jobs_row.isHidden() assert registration_panel._n_jobs_spin.parent() is not None + def test_transform_target_label_is_apply_to(self, registration_panel): + label = registration_panel._transforms_panel.layout().labelForField( + registration_panel._transform_target_combo + ) + assert label is not None + assert label.text() == "Apply to" + def test_volume_shows_fixed_selector(self, registration_panel): registration_panel._time_series_radio.setChecked(True) registration_panel._single_volume_radio.setChecked(True) @@ -277,6 +286,101 @@ def _runner(*args, **kwargs): assert captured["kwargs"]["center_initialization"] is None assert registration_panel._worker is not None + def test_between_scan_run_uses_selected_manual_napari_transform( + self, viewer, registration_panel, monkeypatch + ): + moving = xr.DataArray( + np.zeros((2, 4, 6, 8), dtype=np.float32), + dims=["time", "z", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(2), dims=["time"]), + "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), + "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), + }, + ) + fixed = xr.DataArray( + np.ones((2, 4, 6, 8), dtype=np.float32), + dims=["time", "z", "y", "x"], + coords=moving.coords, + ) + + moving_layer = viewer.add_image( + moving.values, + name="moving", + metadata={"xarray": moving}, + ) + viewer.add_image(fixed.values, name="fixed", metadata={"xarray": fixed}) + + manual_affine = np.eye(5) + manual_affine[0, 4] = 9.0 + manual_affine[1, 4] = 0.5 + manual_affine[2, 4] = -0.25 + manual_affine[3, 3] = 1.1 + manual_affine[3, 4] = 0.75 + moving_layer.affine = manual_affine + + registration_panel._refresh_layers() + registration_panel._refresh_transform_controls() + registration_panel._moving_combo.setCurrentText("moving") + registration_panel._fixed_combo.setCurrentText("fixed") + for i in range(registration_panel._initialization_combo.count()): + if registration_panel._initialization_combo.itemData(i) == ( + "manual", + "moving", + ): + registration_panel._initialization_combo.setCurrentIndex(i) + break + + captured: dict[str, object] = {} + + class _FakeSignal: + def connect(self, _slot): + return None + + class _FakeWorker: + def __init__(self) -> None: + self.returned = _FakeSignal() + self.errored = _FakeSignal() + self.finished = _FakeSignal() + + def start(self) -> None: + return None + + def _fake_thread_worker(func): + def _runner(*args, **kwargs): + captured["func"] = func + captured["args"] = args + captured["kwargs"] = kwargs + return _FakeWorker() + + return _runner + + monkeypatch.setattr( + "confusius._napari._registration._panel.thread_worker", + _fake_thread_worker, + ) + monkeypatch.setattr( + registration_panel, + "_setup_volume_progress", + lambda **_kwargs: None, + ) + + registration_panel._run_registration() + + expected = np.array( + [ + [1.0, 0.0, 0.0, -0.5], + [0.0, 1.0, 0.0, 0.25], + [0.0, 0.0, 1.0 / 1.1, -0.75 / 1.1], + [0.0, 0.0, 0.0, 1.0], + ] + ) + np.testing.assert_allclose(captured["kwargs"]["initial_transform"], expected) + assert captured["kwargs"]["center_initialization"] is None + assert captured["args"][0].dims == ("z", "y", "x") + assert registration_panel._worker is not None + class TestAbort: def test_abort_sets_cancellation_event(self, registration_panel): @@ -397,6 +501,88 @@ def test_initial_transform_dropdown_lists_available_transforms( for i in range(registration_panel._initialization_combo.count()) ) + def test_initial_transform_dropdown_lists_manual_napari_transforms( + self, viewer, registration_panel + ): + moving = xr.DataArray( + np.zeros((2, 4, 6), dtype=np.float32), + dims=["z", "y", "x"], + coords={ + "z": xr.DataArray(np.arange(2), dims=["z"]), + "y": xr.DataArray(np.arange(4), dims=["y"]), + "x": xr.DataArray(np.arange(6), dims=["x"]), + }, + ) + layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + manual_affine = np.eye(4) + manual_affine[0, 3] = 1.0 + layer.affine = manual_affine + + registration_panel._refresh_transform_controls() + + assert any( + registration_panel._initialization_combo.itemData(i) + == ("manual", "moving") + for i in range(registration_panel._initialization_combo.count()) + ) + + def test_transform_source_dropdown_lists_manual_napari_transforms( + self, viewer, registration_panel + ): + moving = xr.DataArray( + np.zeros((2, 4, 6), dtype=np.float32), + dims=["z", "y", "x"], + coords={ + "z": xr.DataArray(np.arange(2), dims=["z"]), + "y": xr.DataArray(np.arange(4), dims=["y"]), + "x": xr.DataArray(np.arange(6), dims=["x"]), + }, + ) + layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + manual_affine = np.eye(4) + manual_affine[0, 3] = 1.0 + layer.affine = manual_affine + + registration_panel._refresh_transform_controls() + + assert any( + registration_panel._transform_source_combo.itemData(i) + == ("manual", "moving") + for i in range(registration_panel._transform_source_combo.count()) + ) + + def test_initial_transform_dropdown_updates_when_manual_transform_changes( + self, viewer, registration_panel + ): + moving = xr.DataArray( + np.zeros((2, 4, 6), dtype=np.float32), + dims=["z", "y", "x"], + coords={ + "z": xr.DataArray(np.arange(2), dims=["z"]), + "y": xr.DataArray(np.arange(4), dims=["y"]), + "x": xr.DataArray(np.arange(6), dims=["x"]), + }, + ) + layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + registration_panel._refresh_layers() + + assert not any( + registration_panel._initialization_combo.itemData(i) + == ("manual", "moving") + for i in range(registration_panel._initialization_combo.count()) + ) + + manual_affine = np.eye(4) + manual_affine[0, 3] = 1.0 + layer.affine = manual_affine + QApplication.processEvents() + + assert any( + registration_panel._initialization_combo.itemData(i) + == ("manual", "moving") + for i in range(registration_panel._initialization_combo.count()) + ) + class TestBetweenScanPreparation: def test_prepare_between_scan_data_averages_time(self): @@ -426,9 +612,69 @@ def test_prepare_between_scan_data_averages_time(self): np.testing.assert_allclose(averaged.values, 0.5) +class TestManualNapariInitialization: + def test_spatial_manual_affine_ignores_time_axis(self, viewer): + from confusius._napari._registration._panel import ( + _spatial_manual_affine_from_layer, + ) + + moving = xr.DataArray( + np.zeros((2, 4, 6, 8), dtype=np.float32), + dims=["time", "z", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(2), dims=["time"]), + "z": xr.DataArray(np.arange(4), dims=["z"]), + "y": xr.DataArray(np.arange(6), dims=["y"]), + "x": xr.DataArray(np.arange(8), dims=["x"]), + }, + ) + layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + manual_affine = np.eye(5) + manual_affine[0, 4] = 3.0 + manual_affine[1, 4] = 0.5 + manual_affine[2, 4] = -0.25 + manual_affine[3, 4] = 1.25 + layer.affine = manual_affine + + affine = _spatial_manual_affine_from_layer(layer, spatial_dims=["z", "y", "x"]) + + expected = np.array( + [ + [1.0, 0.0, 0.0, 0.5], + [0.0, 1.0, 0.0, -0.25], + [0.0, 0.0, 1.0, 1.25], + [0.0, 0.0, 0.0, 1.0], + ] + ) + np.testing.assert_allclose(affine, expected) + + def test_spatial_manual_affine_rejects_time_spatial_mixing(self, viewer): + from confusius._napari._registration._panel import ( + _spatial_manual_affine_from_layer, + ) + + moving = xr.DataArray( + np.zeros((2, 4, 6), dtype=np.float32), + dims=["time", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(2), dims=["time"]), + "y": xr.DataArray(np.arange(4), dims=["y"]), + "x": xr.DataArray(np.arange(6), dims=["x"]), + }, + ) + layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + manual_affine = np.eye(4) + manual_affine[1, 0] = 0.5 + with pytest.warns(UserWarning, match="Non-orthogonal slicing"): + layer.affine = manual_affine + + with pytest.raises(ValueError, match="mixes spatial axes"): + _spatial_manual_affine_from_layer(layer, spatial_dims=["y", "x"]) + + class TestLayerToDataArray: def test_reconstructs_dataarray_from_generic_layer(self, viewer): - from confusius._napari._registration._panel import _layer_to_dataarray + from confusius._napari._registration._panel import _get_source_dataarray layer = viewer.add_image( np.zeros((3, 5, 7), dtype=np.float32), @@ -439,7 +685,7 @@ def test_reconstructs_dataarray_from_generic_layer(self, viewer): layer.axis_labels = ("z", "y", "x") layer.units = ("mm", "mm", "mm") - da = _layer_to_dataarray(layer) + da = _get_source_dataarray(layer) assert da.dims == ("z", "y", "x") assert da.coords["z"][0] == pytest.approx(1.0) @@ -447,8 +693,66 @@ def test_reconstructs_dataarray_from_generic_layer(self, viewer): assert da.coords["x"][2] == pytest.approx(3.2) assert da.coords["x"].attrs["units"] in {"mm", "millimeter"} + def test_generic_layer_snapshot_ignores_later_manual_translate(self, viewer): + from confusius._napari._registration._panel import _get_source_dataarray + + layer = viewer.add_image( + np.zeros((3, 5, 7), dtype=np.float32), + name="plain", + scale=(0.3, 0.2, 0.1), + translate=(1.0, 2.0, 3.0), + ) + layer.axis_labels = ("z", "y", "x") + + original = _get_source_dataarray(layer) + layer.translate = (9.0, 8.0, 7.0) + after_move = _get_source_dataarray(layer) + + assert after_move is original + assert after_move.coords["z"][0] == pytest.approx(1.0) + assert after_move.coords["y"][0] == pytest.approx(2.0) + assert after_move.coords["x"][0] == pytest.approx(3.0) + class TestTransforms: + def test_selected_manual_transform_payload_matches_visible_layer_transform( + self, viewer, registration_panel + ): + moving = xr.DataArray( + np.zeros((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + manual_affine = np.array( + [[1.0, 0.0, 0.3], [0.0, 1.0, -0.4], [0.0, 0.0, 1.0]], + dtype=float, + ) + layer.affine = manual_affine + + registration_panel._refresh_transform_controls() + for i in range(registration_panel._transform_source_combo.count()): + if registration_panel._transform_source_combo.itemData(i) == ( + "manual", + "moving", + ): + registration_panel._transform_source_combo.setCurrentIndex(i) + break + + payload = registration_panel._selected_transform_payload() + + assert payload is not None + np.testing.assert_allclose( + affine_transform_from_payload(payload), + np.linalg.inv(manual_affine), + ) + assert payload["name"] == "moving (manual)" + assert payload["source_layer_name"] == "moving" + assert payload["target_layer_name"] == "moving" + def test_affine_payload_roundtrip(self, tmp_path): reference = xr.DataArray( np.ones((4, 6), dtype=np.float32), @@ -492,7 +796,7 @@ class TestVolumewiseProgress: def test_setup_updates_progress_bar_and_output_layer( self, viewer, registration_panel ): - from confusius._napari._registration._panel import _layer_to_dataarray + from confusius._napari._registration._panel import _get_source_dataarray moving = xr.DataArray( np.linspace(-2.0, 3.0, 3 * 4 * 6, dtype=np.float32).reshape(3, 4, 6), @@ -508,7 +812,7 @@ def test_setup_updates_progress_bar_and_output_layer( name="series", metadata={"xarray": moving}, ) - moving = _layer_to_dataarray(moving_layer) + moving = _get_source_dataarray(moving_layer) progress = registration_panel._setup_volumewise_progress( moving_layer=moving_layer, @@ -697,6 +1001,10 @@ def test_volume_result_replaces_preview_layer( np.asarray(preview_layer.data), np.asarray(moving.data), ) + np.testing.assert_array_equal( + np.asarray(moving_preview.data), + np.asarray(preview_layer.data), + ) registered = fixed.copy() transform = np.eye(3) @@ -740,6 +1048,48 @@ def test_volume_result_replaces_preview_layer( np.asarray(registered.values), ) + def test_setup_volume_progress_applies_initial_transform_to_preview_layers( + self, viewer, registration_panel + ): + moving_data = xr.DataArray( + np.arange(24, dtype=np.float32).reshape(4, 6), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + moving = viewer.add_image(moving_data.values, name="moving") + fixed = xr.DataArray( + np.zeros((4, 6), dtype=np.float32), + dims=["y", "x"], + coords=moving_data.coords, + ) + fixed_layer = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="fixed") + initial_transform = np.array( + [[1.0, 0.0, 0.2], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + dtype=float, + ) + + registration_panel._setup_volume_progress( + moving_layer=moving, + fixed_layer=fixed_layer, + moving=moving_data, + fixed=fixed, + layer_name="Registered (rigid)", + initial_transform=initial_transform, + ) + + expected = resample_like(moving_data, fixed, initial_transform) + np.testing.assert_allclose( + np.asarray(viewer.layers["Moving"].data), + np.asarray(expected.data), + ) + np.testing.assert_allclose( + np.asarray(viewer.layers["Registered (rigid)"].data), + np.asarray(expected.data), + ) + def test_progress_layer_data_updates_on_iteration( self, viewer, registration_panel, qtbot ): From d3a4ad5f576b4a28e3eff7339b668ba56306b15a Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sat, 27 Jun 2026 12:47:06 +0200 Subject: [PATCH 18/72] feat(registration): add preview scaling controls --- src/confusius/_napari/_registration/_panel.py | 118 +++++++++++++++++- .../test_napari/test_registration_panel.py | 74 +++++++++++ 2 files changed, 187 insertions(+), 5 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index d77f1add..85580807 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -63,6 +63,7 @@ resample_like, resample_volume, ) +from confusius.xarray.scale import db_scale, power_scale if TYPE_CHECKING: import napari @@ -224,6 +225,37 @@ def _prepare_between_scan_data(data: xr.DataArray) -> xr.DataArray: return averaged +def _apply_registration_scale( + data: xr.DataArray, scale_mode: Literal["off", "dB", "sqrt"] +) -> xr.DataArray: + """Apply optional intensity preprocessing for registration. + + Parameters + ---------- + data : xarray.DataArray + Input data. + scale_mode : {"off", "dB", "sqrt"} + Intensity scaling mode used before registration. + + Returns + ------- + xarray.DataArray + Preprocessed data. + + Raises + ------ + ValueError + If `scale_mode` is not recognized. + """ + if scale_mode == "off": + return data + if scale_mode == "dB": + return db_scale(data) + if scale_mode == "sqrt": + return power_scale(data, exponent=0.5) + raise ValueError(f"Unknown registration scale mode: {scale_mode}.") + + def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: """Return image-display kwargs copied from an existing napari layer. @@ -244,6 +276,22 @@ def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: return kwargs +def _should_reset_gamma(scale_mode: str) -> bool: + """Return whether registration preview/result gamma should be reset. + + Parameters + ---------- + scale_mode : str + Registration intensity scaling mode. + + Returns + ------- + bool + Whether preview/result layers should force `gamma=1.0`. + """ + return scale_mode != "off" + + def _parse_sequence(text: str, expected_len: int = 3) -> tuple[int, ...]: """Parse comma-separated integers from a text field.""" parts = [p.strip() for p in text.split(",") if p.strip()] @@ -947,6 +995,28 @@ def _setup_ui(self) -> None: self._metric_combo, ) + self._scale_combo = QComboBox() + self._scale_combo.setMinimumContentsLength(10) + self._scale_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._scale_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._scale_combo.addItem("decibel", "dB") + self._scale_combo.addItem("square root", "sqrt") + self._scale_combo.addItem("none", "off") + self._scale_combo.setToolTip( + "Optional intensity preprocessing applied before registration and used for registration preview layers." + ) + params_layout.addRow( + self._make_form_label( + "Scale", + tooltip="Optional intensity preprocessing applied before registration and used for registration preview layers.", + ), + self._scale_combo, + ) + self._initialization_combo = QComboBox() self._initialization_combo.setMinimumContentsLength(18) self._initialization_combo.setSizeAdjustPolicy( @@ -1875,6 +1945,7 @@ def _snapshot_mode_parameters(self, *, is_volumewise: bool) -> dict[str, Any]: return { "transform": self._transform_combo.currentText() or "rigid", "metric": self._metric_combo.currentText(), + "scale": cast("str", self._scale_combo.currentData()), "initialization": self._initialization_combo.currentData(), "learning_rate_auto": self._learning_rate_auto_check.isChecked(), "learning_rate_value": self._learning_rate_edit.value(), @@ -1921,6 +1992,10 @@ def _apply_mode_parameters( self._transform_combo.blockSignals(False) self._metric_combo.setCurrentText(cast("str", params["metric"])) + scale_mode = cast("str", params.get("scale", "dB")) + scale_index = self._scale_combo.findData(scale_mode) + if scale_index >= 0: + self._scale_combo.setCurrentIndex(scale_index) initialization_data = params.get("initialization") for i in range(self._initialization_combo.count()): if self._initialization_combo.itemData(i) == initialization_data: @@ -2027,12 +2102,15 @@ def _setup_volumewise_progress( moving_layer: "Image", moving: xr.DataArray, layer_name: str, + scale_mode: str = "off", ) -> NapariVolumewiseProgress: """Create a progress bridge and output layer for volumewise registration.""" self._teardown_volumewise_progress(remove_layer=True) moving_display_kwargs = _image_display_kwargs_from_layer(moving_layer) moving_display_kwargs["colormap"] = "red" + if _should_reset_gamma(scale_mode): + moving_display_kwargs["gamma"] = 1.0 display_kwargs = dict(moving_display_kwargs) display_kwargs["colormap"] = "cyan" @@ -2067,6 +2145,9 @@ def _setup_volumewise_progress( else: moving_preview_layer.data = np.asarray(moving.data) # type: ignore[invalid-assignment] moving_preview_layer.colormap = moving_display_kwargs["colormap"] + moving_preview_layer.gamma = cast( + "float", moving_display_kwargs.get("gamma", 1.0) + ) moving_preview_layer.contrast_limits = contrast_limits try: @@ -2155,6 +2236,7 @@ def _setup_volume_progress( fixed: xr.DataArray, layer_name: str, initial_transform: npt.NDArray[np.floating] | None = None, + scale_mode: str = "off", ) -> "Callable[..., RegistrationProgress] | None": """Build a napari progress bridge and preview layer for register_volume. @@ -2200,13 +2282,11 @@ def _setup_volume_progress( moving_display_kwargs = _image_display_kwargs_from_layer(moving_layer) moving_display_kwargs["colormap"] = "cyan" moving_display_kwargs["blending"] = "additive" + if _should_reset_gamma(scale_mode): + fixed_display_kwargs["gamma"] = 1.0 + moving_display_kwargs["gamma"] = 1.0 display_kwargs = dict(moving_display_kwargs) - # Seed contrast limits with the moving layer so the preview is shown in - # the same intensity space as the final resampled volume. - moving_contrast = getattr(moving_layer, "contrast_limits", None) - if moving_contrast is not None: - display_kwargs.setdefault("contrast_limits", tuple(moving_contrast)) # Render the preview in cyan with additive blending. napari sums the # RGB channels of the two layers, so red+cyan highlights @@ -2237,6 +2317,7 @@ def _setup_volume_progress( "linear", ), ) + preview_contrast_limits = tuple(calc_data_range(preview.data)) except Exception as exc: # noqa: BLE001 # Fall back to a zero-valued seed if the initial resample fails # for any reason. The first iteration will populate the preview. @@ -2247,6 +2328,7 @@ def _setup_volume_progress( dims=fixed.dims, attrs=fixed.attrs.copy(), ) + preview_contrast_limits = tuple(calc_data_range(preview.data)) try: try: @@ -2264,6 +2346,9 @@ def _setup_volume_progress( else: fixed_preview_layer.data = np.asarray(fixed.data) # type: ignore[invalid-assignment] fixed_preview_layer.colormap = fixed_display_kwargs["colormap"] + fixed_preview_layer.gamma = cast( + "float", fixed_display_kwargs.get("gamma", 1.0) + ) fixed_preview_layer.visible = True try: @@ -2277,12 +2362,17 @@ def _setup_volume_progress( viewer=self.viewer, name=self._volume_moving_preview_layer_name(), show_colorbar=False, + contrast_limits=preview_contrast_limits, **moving_display_kwargs, ) else: moving_preview_layer.data = np.asarray(preview.data) # type: ignore[invalid-assignment] moving_preview_layer.colormap = moving_display_kwargs["colormap"] moving_preview_layer.blending = moving_display_kwargs["blending"] + moving_preview_layer.gamma = cast( + "float", moving_display_kwargs.get("gamma", 1.0) + ) + moving_preview_layer.contrast_limits = preview_contrast_limits moving_preview_layer.visible = False _, layer = plot_napari( @@ -2290,6 +2380,7 @@ def _setup_volume_progress( viewer=self.viewer, name=layer_name, show_colorbar=False, + contrast_limits=preview_contrast_limits, **display_kwargs, ) except Exception as exc: # noqa: BLE001 @@ -2591,6 +2682,7 @@ def _run_registration(self) -> None: "moving_layer_name": moving_layer.name, "transform": self._transform_combo.currentText(), "metric": self._metric_combo.currentText(), + "scale": cast("str", self._scale_combo.currentData()), "learning_rate": learning_rate, "number_of_iterations": self._iterations_spin.value(), "use_multi_resolution": use_multi_res, @@ -2629,6 +2721,14 @@ def _run_registration(self) -> None: moving = _prepare_between_scan_data(moving) fixed = _prepare_between_scan_data(fixed) + moving = _apply_registration_scale( + moving, + cast("Literal['off', 'dB', 'sqrt']", payload["scale"]), + ) + fixed = _apply_registration_scale( + fixed, + cast("Literal['off', 'dB', 'sqrt']", payload["scale"]), + ) initial_transform: npt.NDArray[np.floating] | None = None try: @@ -2660,6 +2760,7 @@ def _run_registration(self) -> None: ) ), initial_transform=initial_transform, + scale_mode=cast("str", payload["scale"]), ) worker = thread_worker(_run_register_volume)( @@ -2697,6 +2798,10 @@ def _run_registration(self) -> None: payload["reference_time"] = self._reference_time_spin.value() payload["n_jobs"] = self._n_jobs_spin.value() + moving = _apply_registration_scale( + moving, + cast("Literal['off', 'dB', 'sqrt']", payload["scale"]), + ) progress_reporter = self._setup_volumewise_progress( moving_layer=cast("Image", moving_layer), @@ -2704,6 +2809,7 @@ def _run_registration(self) -> None: layer_name=self._make_unique_layer_name( self._volumewise_result_layer_name(payload["moving_layer_name"]) ), + scale_mode=cast("str", payload["scale"]), ) worker = thread_worker(_run_register_volumewise)( @@ -2811,6 +2917,8 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non display_kwargs: dict[str, Any] = {} else: display_kwargs = _image_display_kwargs_from_layer(source_layer) + if _should_reset_gamma(cast("str", payload.get("scale", "off"))): + display_kwargs["gamma"] = 1.0 # The result layer is the registered stand-in for the moving layer: # it must use the same cyan + additive styling so the red/cyan # overlay persists after the run. diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index a6b458d2..e870e9eb 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -111,14 +111,17 @@ def test_mode_switch_preserves_session_parameters(self, registration_panel): registration_panel._learning_rate_auto_check.setChecked(True) registration_panel._learning_rate_edit.setValue(0.23) registration_panel._n_jobs_spin.setValue(3) + registration_panel._scale_combo.setCurrentText("square root") registration_panel._single_volume_radio.setChecked(True) registration_panel._learning_rate_edit.setValue(0.42) + registration_panel._scale_combo.setCurrentText("none") registration_panel._time_series_radio.setChecked(True) assert registration_panel._learning_rate_auto_check.isChecked() assert registration_panel._learning_rate_edit.value() == pytest.approx(0.23) assert registration_panel._n_jobs_spin.value() == 3 + assert registration_panel._scale_combo.currentText() == "square root" def test_advanced_group_is_collapsed_by_default(self, registration_panel): assert not registration_panel._advanced_toggle.isChecked() @@ -146,6 +149,53 @@ def test_spinbox_defaults_and_minima(self, registration_panel): assert registration_panel._convergence_min_edit.value() == pytest.approx(1e-6) assert registration_panel._iterations_spin.singleStep() == 100 + def test_scale_defaults_to_db(self, registration_panel): + assert registration_panel._scale_combo.currentText() == "decibel" + + def test_scale_preprocessing_resets_gamma_for_previews(self, viewer, registration_panel): + moving_data = xr.DataArray( + np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + fixed = xr.DataArray( + 2 * np.ones((4, 6), dtype=np.float32), + dims=["y", "x"], + coords=moving_data.coords, + ) + moving = viewer.add_image(moving_data.values, name="moving") + fixed_layer = viewer.add_image(fixed.values, name="fixed") + moving.gamma = 0.4 + fixed_layer.gamma = 0.6 + + registration_panel._setup_volume_progress( + moving_layer=moving, + fixed_layer=fixed_layer, + moving=moving_data, + fixed=fixed, + layer_name="Registered (rigid)", + scale_mode="sqrt", + ) + assert viewer.layers["Fixed"].gamma == pytest.approx(1.0) + assert viewer.layers["Moving"].gamma == pytest.approx(1.0) + assert viewer.layers["Registered (rigid)"].gamma == pytest.approx(1.0) + + registration_panel._teardown_volume_progress() + registration_panel._setup_volume_progress( + moving_layer=moving, + fixed_layer=fixed_layer, + moving=moving_data, + fixed=fixed, + layer_name="Registered (rigid)", + scale_mode="off", + ) + assert viewer.layers["Fixed"].gamma == pytest.approx(0.6) + assert viewer.layers["Moving"].gamma == pytest.approx(0.4) + assert viewer.layers["Registered (rigid)"].gamma == pytest.approx(0.4) + def test_metric_specific_rows_follow_metric(self, registration_panel): registration_panel._advanced_toggle.setChecked(True) assert registration_panel._metric_combo.currentText() == "correlation" @@ -238,6 +288,7 @@ def test_between_scan_run_uses_selected_initial_transform( registration_panel._refresh_transform_controls() registration_panel._moving_combo.setCurrentText("moving") registration_panel._fixed_combo.setCurrentText("fixed") + registration_panel._scale_combo.setCurrentText("square root") for i in range(registration_panel._initialization_combo.count()): if registration_panel._initialization_combo.itemData(i) == ( "layer", @@ -284,6 +335,8 @@ def _runner(*args, **kwargs): np.testing.assert_array_equal(captured["kwargs"]["initial_transform"], affine) assert captured["kwargs"]["center_initialization"] is None + np.testing.assert_allclose(captured["args"][0].values, np.sqrt(moving.values)) + np.testing.assert_allclose(captured["args"][1].values, np.sqrt(fixed.values)) assert registration_panel._worker is not None def test_between_scan_run_uses_selected_manual_napari_transform( @@ -612,6 +665,24 @@ def test_prepare_between_scan_data_averages_time(self): np.testing.assert_allclose(averaged.values, 0.5) +class TestScalePreprocessing: + def test_apply_registration_scale_db(self): + from confusius._napari._registration._panel import _apply_registration_scale + + data = xr.DataArray([1.0, 10.0, 100.0], dims=["x"]) + scaled = _apply_registration_scale(data, "dB") + + np.testing.assert_allclose(scaled.values, [-20.0, -10.0, 0.0]) + + def test_apply_registration_scale_sqrt(self): + from confusius._napari._registration._panel import _apply_registration_scale + + data = xr.DataArray([1.0, 4.0, 9.0], dims=["x"]) + scaled = _apply_registration_scale(data, "sqrt") + + np.testing.assert_allclose(scaled.values, [1.0, 2.0, 3.0]) + + class TestManualNapariInitialization: def test_spatial_manual_affine_ignores_time_axis(self, viewer): from confusius._napari._registration._panel import ( @@ -818,6 +889,7 @@ def test_setup_updates_progress_bar_and_output_layer( moving_layer=moving_layer, moving=moving, layer_name="Motion corrected", + scale_mode="off", ) assert registration_panel._volumewise_progress_layer is not None @@ -877,6 +949,7 @@ def test_frame_completion_updates_frame_progress( moving_layer=moving_layer, moving=moving, layer_name="Motion corrected", + scale_mode="off", ) frame = xr.DataArray( @@ -1258,6 +1331,7 @@ def test_volumewise_finished_keeps_preview_layers( moving_layer=moving_layer, moving=moving, layer_name="Motion corrected", + scale_mode="off", ) registered = xr.DataArray( From 1036457dd433a35bdf1e820668ca95c71c090a89 Mon Sep 17 00:00:00 2001 From: Felipe Cybis Pereira Date: Sat, 27 Jun 2026 17:29:50 +0200 Subject: [PATCH 19/72] add `(aborted)` to the layer name of an aborted registered layer --- src/confusius/_napari/_registration/_panel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 85580807..5a5f7b2c 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2975,6 +2975,7 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non self._progress.setValue(self._progress.maximum()) if registration_status == "aborted": + layer.name = f"{layer.name} (aborted)" self._set_error("Registration aborted; added partial result.") show_info(f"Registration aborted; added partial layer: {layer.name}") else: From dde3c92f372441580999db202c0f911de9c5f525 Mon Sep 17 00:00:00 2001 From: Felipe Cybis Pereira Date: Sat, 27 Jun 2026 17:37:42 +0200 Subject: [PATCH 20/72] fix(registration): make register_volumewise abort responsive and honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking abort during within-scan (volumewise) registration froze the GUI on Windows and never actually cancelled the remaining work. After the abort event was set, every remaining frame was still dispatched through register_volume, which only skips SimpleITK's Execute() — the one step that releases the GIL. The leftover pure-Python/numpy setup and a wasted resample, multiplied across joblib threads, monopolised the GIL and starved the Qt main thread (worse on Windows, where GIL hand-off to a waiting thread is far less fair than on Linux), so the GUI only unfroze once all frames had been computed. - Short-circuit _register_one before calling register_volume once aborted, returning the original frame with a zero-iteration "aborted" diagnostic. - Stop dispatching new frames to joblib once the abort event is set. - Fill aborted/un-started frames with the data minimum (background) instead of the unregistered input, so the partial result shows which frames were skipped, matching the live preview. --- src/confusius/registration/volumewise.py | 41 ++++++++++++++++++------ 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/confusius/registration/volumewise.py b/src/confusius/registration/volumewise.py index bb1f5e68..11a533a6 100644 --- a/src/confusius/registration/volumewise.py +++ b/src/confusius/registration/volumewise.py @@ -127,12 +127,11 @@ def register_volumewise( (`final_metric_value`, `n_iterations`) are always added to `motion_params` regardless of this flag. abort_event : threading.Event, optional - Cooperative cancellation flag shared across frames. If set before or - during execution, in-flight frame registrations stop at the next - optimiser iteration boundary and this function returns the partial - dataset collected so far. Frames that were not started keep their - original values, and per-frame `motion_params` rows are marked via the - diagnostics status. + Cooperative cancellation flag shared across frames. If set before or during + execution, in-flight frame registrations stop at the next optimiser iteration + boundary and this function returns the partial dataset collected so far. Frames + that were not started are left blank (filled with the data minimum), and + per-frame `motion_params` rows are marked via the diagnostics status. progress_reporter : VolumewiseProgressReporter, optional Thread-safe reporter notified whenever one frame completes. Useful for GUI progress bars or progressively filling an output layer while frames @@ -215,6 +214,24 @@ def _register_one( ) -> tuple[ int, xr.DataArray, npt.NDArray[np.floating] | None, RegistrationDiagnostics ]: + # Once aborted, skip cheaply: building SimpleITK images and resampling is + # pure-Python/GIL-bound work that, multiplied across joblib threads, starves the + # GUI thread. Return the original frame with a zero-iteration "aborted" + # diagnostic instead. + if abort_event is not None and abort_event.is_set(): + return ( + frame_index, + volume, + None, + RegistrationDiagnostics( + metric=metric, + metric_values=np.empty(0, dtype=float), + final_metric_value=float("nan"), + n_iterations=0, + stop_condition="Registration aborted before frame started.", + status="aborted", + ), + ) registered_da, frame_affine, frame_diag = register_volume( volume, ref_da, @@ -241,7 +258,10 @@ def _register_one( return frame_index, registered_da, frame_affine, frame_diag arr = data_moved.values - output = np.array(arr, copy=True) + # Aborted/un-started frames are left blank (filled with the data minimum, + # i.e. background) rather than copying the unregistered input, so the partial + # result visibly shows which frames were skipped. + output = np.full_like(arr, arr.min()) affines: list[npt.NDArray[np.floating] | None] = [None] * n_frames final_metric_values = [float("nan")] * n_frames n_iterations_per_frame = [0] * n_frames @@ -251,13 +271,16 @@ def _register_one( try: with progress_context: results = Parallel(return_as="generator_unordered", **parallel_kwargs)( - delayed(_register_one)(t, volume) for t, volume in enumerate(data_moved) + delayed(_register_one)(t, volume) + for t, volume in enumerate(data_moved) + if abort_event is None or not abort_event.is_set() ) for t, registered_da, frame_affine, frame_diag in results: skipped = ( frame_diag.status == "aborted" and frame_diag.n_iterations == 0 ) - output[t] = arr[t] if skipped else registered_da.values + if not skipped: + output[t] = registered_da.values affines[t] = None if skipped else frame_affine final_metric_values[t] = frame_diag.final_metric_value n_iterations_per_frame[t] = frame_diag.n_iterations From cb73dbb66aefed1ab6a268ddd0a6e807ed442506 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sun, 28 Jun 2026 16:09:24 +0200 Subject: [PATCH 21/72] feat(registration): save bspline transforms as zarr --- src/confusius/_napari/_registration/_panel.py | 132 +++++-- .../_napari/_registration/_transforms.py | 342 ++++++++++++++++-- src/confusius/registration/bspline.py | 8 +- src/confusius/registration/volume.py | 15 +- .../test_napari/test_registration_panel.py | 207 +++++++++++ tests/unit/test_registration/test_volume.py | 149 ++++++++ 6 files changed, 780 insertions(+), 73 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 5a5f7b2c..9dc87883 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -50,11 +50,14 @@ ) from confusius._napari._registration._transforms import ( AffineTransformPayload, + TransformPayload, affine_transform_from_payload, - load_affine_transform_payload, + bspline_transform_from_payload, + load_transform_payload, make_affine_transform_payload, + make_bspline_transform_payload, output_grid_from_payload, - save_affine_transform_payload, + save_transform_payload, ) from confusius.plotting.napari import plot_napari from confusius.registration import ( @@ -747,7 +750,7 @@ def __init__(self, viewer: napari.Viewer) -> None: self.viewer = viewer self._worker = None self._abort_event: Event | None = None - self._loaded_transform_payload: AffineTransformPayload | None = None + self._loaded_transform_payload: TransformPayload | None = None # Per-run progress state. Set on the GUI thread before the worker starts. self._progress_bridge: NapariProgressBridge | None = None self._progress_layer: Image | None = None @@ -1460,7 +1463,7 @@ def _selected_layer(self, combo: QComboBox) -> Layer | None: return None def _transform_source_label( - self, payload: AffineTransformPayload, *, suffix: str | None = None + self, payload: TransformPayload, *, suffix: str | None = None ) -> str: """Return a user-facing label for a transform payload.""" del suffix @@ -1521,15 +1524,21 @@ def _volumewise_moving_preview_layer_name(self) -> str: """Return the napari layer name for the within-scan moving preview.""" return "Moving" - def _available_transform_payloads(self) -> list[AffineTransformPayload]: - """Return all affine transform payloads currently available in the UI.""" - payloads: list[AffineTransformPayload] = [] + def _available_transform_payloads(self) -> list[TransformPayload]: + """Return all transform payloads currently available in the UI.""" + payloads: list[TransformPayload] = [] if self._loaded_transform_payload is not None: payloads.append(self._loaded_transform_payload) for layer in self.viewer.layers: - payload = _affine_payload_from_layer(layer) - if payload is not None: - payloads.append(payload) + payload = layer.metadata.get("confusius_transform") + if isinstance(payload, dict): + kind = payload.get("kind") + if kind == "affine": + affine_transform_from_payload(payload) + payloads.append(cast("TransformPayload", payload)) + elif kind == "bspline": + bspline_transform_from_payload(payload) + payloads.append(cast("TransformPayload", payload)) return payloads def _refresh_transform_controls(self) -> None: @@ -1550,12 +1559,21 @@ def _refresh_transform_controls(self) -> None: ) ) for layer in self.viewer.layers: - payload = _affine_payload_from_layer(layer) - if payload is None: + payload = layer.metadata.get("confusius_transform") + if not isinstance(payload, dict): + continue + kind = payload.get("kind") + if kind == "affine": + affine_transform_from_payload(payload) + elif kind == "bspline": + bspline_transform_from_payload(payload) + else: continue transform_options.append( ( - self._transform_source_label(payload, suffix=layer.name), + self._transform_source_label( + cast("TransformPayload", payload), suffix=layer.name + ), ("layer", layer.name), ) ) @@ -1594,6 +1612,19 @@ def _refresh_transform_controls(self) -> None: self._initialization_combo.addItem("center_moments", "center_moments") self._initialization_combo.addItem("none", None) for label, data in transform_options: + source_kind, source_name = data + if source_kind == "loaded": + if self._loaded_transform_payload is None: + continue + if self._loaded_transform_payload["kind"] != "affine": + continue + elif source_kind == "layer": + try: + layer = cast("Layer", self.viewer.layers[source_name]) + except KeyError: + continue + if _affine_payload_from_layer(layer) is None: + continue self._initialization_combo.addItem(label, data) for label, data in manual_initialization_options: self._initialization_combo.addItem(label, data) @@ -1622,8 +1653,8 @@ def _refresh_transform_controls(self) -> None: if target_index >= 0: self._transform_target_combo.setCurrentIndex(target_index) - def _selected_transform_payload(self) -> AffineTransformPayload | None: - """Return the currently selected affine transform payload.""" + def _selected_transform_payload(self) -> TransformPayload | None: + """Return the currently selected transform payload.""" source_data = self._transform_source_combo.currentData() if not isinstance(source_data, tuple) or len(source_data) != 2: return None @@ -1638,7 +1669,16 @@ def _selected_transform_payload(self) -> AffineTransformPayload | None: except KeyError: return None if source_kind == "layer": - return _affine_payload_from_layer(layer) + payload = layer.metadata.get("confusius_transform") + if isinstance(payload, dict): + kind = payload.get("kind") + if kind == "affine": + affine_transform_from_payload(payload) + return cast("TransformPayload", payload) + if kind == "bspline": + bspline_transform_from_payload(payload) + return cast("TransformPayload", payload) + return None if source_kind == "manual": return _make_manual_transform_payload(layer) return None @@ -1660,7 +1700,12 @@ def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: source_kind, source_name = source_data if source_kind == "loaded": - return self._loaded_transform_payload + if ( + self._loaded_transform_payload is not None + and self._loaded_transform_payload["kind"] == "affine" + ): + return self._loaded_transform_payload + return None if source_kind != "layer" or not source_name: return None try: @@ -2553,40 +2598,46 @@ def _set_error(self, message: str) -> None: self._status.show() def _save_transform(self) -> None: - """Save the selected affine transform payload to JSON.""" + """Save the selected transform payload to disk.""" payload = self._selected_transform_payload() if payload is None: - self._set_error("Select an affine transform to save.") + self._set_error("Select a transform to save.") return default_name = payload["name"].replace("/", "-") - start = str(Path.home() / f"{default_name}.json") + suffix = ".json" if payload["kind"] == "affine" else ".zarr" + file_filter = ( + "JSON files (*.json)" + if payload["kind"] == "affine" + else "Zarr stores (*.zarr)" + ) + start = str(Path.home() / f"{default_name}{suffix}") path_str, _ = QFileDialog.getSaveFileName( self, "Save transform", start, - "JSON files (*.json)", + file_filter, ) if not path_str: return - save_affine_transform_payload(path_str, payload) + save_transform_payload(path_str, payload) show_info(f"Saved transform: {path_str}") def _load_transform(self) -> None: - """Load an affine transform payload from JSON.""" + """Load a transform payload from disk.""" start = str(Path.home()) path_str, _ = QFileDialog.getOpenFileName( self, "Load transform", start, - "JSON files (*.json)", + "Transform files (*.json *.zarr)", ) if not path_str: return try: - self._loaded_transform_payload = load_affine_transform_payload(path_str) + self._loaded_transform_payload = load_transform_payload(path_str) except Exception as exc: # noqa: BLE001 self._set_error(str(exc)) show_error(str(exc)) @@ -2603,7 +2654,7 @@ def _apply_transform(self) -> None: """Apply the selected affine transform to a layer.""" payload = self._selected_transform_payload() if payload is None: - self._set_error("Select an affine transform to apply.") + self._set_error("Select a transform to apply.") return moving_layer = self._selected_layer(self._transform_target_combo) @@ -2613,7 +2664,10 @@ def _apply_transform(self) -> None: try: moving = _get_source_dataarray(moving_layer) - affine = affine_transform_from_payload(payload) + if payload["kind"] == "affine": + transform = affine_transform_from_payload(payload) + else: + transform = bspline_transform_from_payload(payload) output_grid = output_grid_from_payload(payload) except Exception as exc: # noqa: BLE001 self._set_error(str(exc)) @@ -2621,7 +2675,7 @@ def _apply_transform(self) -> None: worker = thread_worker(resample_volume)( moving, - affine, + transform, shape=output_grid["shape"], spacing=output_grid["spacing"], origin=output_grid["origin"], @@ -2760,7 +2814,7 @@ def _run_registration(self) -> None: ) ), initial_transform=initial_transform, - scale_mode=cast("str", payload["scale"]), + scale_mode=payload["scale"], ) worker = thread_worker(_run_register_volume)( @@ -2809,7 +2863,7 @@ def _run_registration(self) -> None: layer_name=self._make_unique_layer_name( self._volumewise_result_layer_name(payload["moving_layer_name"]) ), - scale_mode=cast("str", payload["scale"]), + scale_mode=payload["scale"], ) worker = thread_worker(_run_register_volumewise)( @@ -2879,11 +2933,11 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non "registration_diagnostics": diagnostics, "registration_status": diagnostics.status, } + transform_name = self._make_unique_transform_name( + f"{payload['moving_layer_name']} → {payload['fixed_layer_name']} ({payload['transform']})" + ) if isinstance(transform, np.ndarray): affine_transform = np.asarray(transform, dtype=float) - transform_name = self._make_unique_transform_name( - f"{payload['moving_layer_name']} → {payload['fixed_layer_name']} ({payload['transform']})" - ) metadata["confusius_transform"] = make_affine_transform_payload( affine_transform, reference=registered, @@ -2895,6 +2949,18 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non diagnostics=diagnostics, name=transform_name, ) + else: + metadata["confusius_transform"] = make_bspline_transform_payload( + transform, + reference=registered, + source_layer_name=cast(str, payload["moving_layer_name"]), + target_layer_name=cast(str, payload["fixed_layer_name"]), + operation=operation, + transform_model=cast(str, payload["transform"]), + metric=cast(str, payload["metric"]), + diagnostics=diagnostics, + name=transform_name, + ) else: registered = cast("xr.DataArray", result).copy(deep=False) registered.attrs = registered.attrs.copy() diff --git a/src/confusius/_napari/_registration/_transforms.py b/src/confusius/_napari/_registration/_transforms.py index b2eb3806..f9819a97 100644 --- a/src/confusius/_napari/_registration/_transforms.py +++ b/src/confusius/_napari/_registration/_transforms.py @@ -1,4 +1,4 @@ -"""Affine transform helpers for the napari registration panel.""" +"""Transform payload helpers for the napari registration panel.""" from __future__ import annotations @@ -8,12 +8,11 @@ import numpy as np import numpy.typing as npt +import xarray as xr if TYPE_CHECKING: from collections.abc import Mapping - import xarray as xr - from confusius.registration import RegistrationDiagnostics @@ -37,6 +36,15 @@ class OutputGridPayload(TypedDict): units: list[str | None] +class BSplineDataArrayPayload(TypedDict): + """JSON-serializable B-spline control-point DataArray.""" + + dims: list[str] + data: list[object] + coords: dict[str, list[float]] + attrs: dict[str, object] + + class AffineTransformPayload(TypedDict): """JSON-serializable affine transform payload used by the napari plugin.""" @@ -52,6 +60,24 @@ class AffineTransformPayload(TypedDict): diagnostics: TransformDiagnosticsPayload +class BSplineTransformPayload(TypedDict): + """B-spline transform payload used by the napari plugin.""" + + kind: Literal["bspline"] + name: str + bspline: BSplineDataArrayPayload + source_layer_name: str + target_layer_name: str + operation: str + transform_model: str + metric: str + output_grid: OutputGridPayload + diagnostics: TransformDiagnosticsPayload + + +TransformPayload = AffineTransformPayload | BSplineTransformPayload + + def make_output_grid_payload(reference: "xr.DataArray") -> OutputGridPayload: """Return the resampling grid defined by a reference DataArray. @@ -80,6 +106,19 @@ def make_output_grid_payload(reference: "xr.DataArray") -> OutputGridPayload: } +def _make_diagnostics_payload( + diagnostics: "RegistrationDiagnostics", +) -> TransformDiagnosticsPayload: + """Return a JSON-serializable diagnostics summary.""" + return { + "metric": diagnostics.metric, + "final_metric_value": float(diagnostics.final_metric_value), + "n_iterations": int(diagnostics.n_iterations), + "stop_condition": diagnostics.stop_condition, + "status": diagnostics.status, + } + + def make_affine_transform_payload( affine: npt.NDArray[np.floating], *, @@ -118,7 +157,7 @@ def make_affine_transform_payload( Returns ------- AffineTransformPayload - JSON-serializable transform payload. + JSON-serializable affine transform payload. """ affine = np.asarray(affine, dtype=float) payload_name = ( @@ -134,13 +173,96 @@ def make_affine_transform_payload( "transform_model": transform_model, "metric": metric, "output_grid": make_output_grid_payload(reference), - "diagnostics": { - "metric": diagnostics.metric, - "final_metric_value": float(diagnostics.final_metric_value), - "n_iterations": int(diagnostics.n_iterations), - "stop_condition": diagnostics.stop_condition, - "status": diagnostics.status, + "diagnostics": _make_diagnostics_payload(diagnostics), + } + + +def _serialize_bspline_dataarray(transform: "xr.DataArray") -> BSplineDataArrayPayload: + """Return a JSON-serializable B-spline DataArray payload.""" + _validate_bspline_dataarray(transform) + return { + "dims": [str(dim) for dim in transform.dims], + "data": np.asarray(transform, dtype=float).tolist(), + "coords": { + str(dim): np.asarray(transform.coords[dim], dtype=float).tolist() + for dim in transform.dims + if dim in transform.coords }, + "attrs": json.loads(json.dumps(transform.attrs)), + } + + +def _deserialize_bspline_dataarray(payload: BSplineDataArrayPayload) -> xr.DataArray: + """Reconstruct a B-spline DataArray from its JSON payload.""" + dims = [str(dim) for dim in payload["dims"]] + coords = { + str(dim): xr.DataArray(np.asarray(values, dtype=float), dims=[str(dim)]) + for dim, values in payload["coords"].items() + } + transform = xr.DataArray( + np.asarray(payload["data"], dtype=float), + dims=dims, + coords=coords, + attrs=dict(payload["attrs"]), + ) + _validate_bspline_dataarray(transform) + return transform + + +def make_bspline_transform_payload( + transform: "xr.DataArray", + *, + reference: "xr.DataArray", + source_layer_name: str, + target_layer_name: str, + operation: str, + transform_model: str, + metric: str, + diagnostics: "RegistrationDiagnostics", + name: str | None = None, +) -> BSplineTransformPayload: + """Build a JSON-serializable payload for a registered B-spline transform. + + Parameters + ---------- + transform : xarray.DataArray + B-spline control-point grid. + reference : xarray.DataArray + Fixed/reference DataArray defining the output resampling grid. + source_layer_name : str + Name of the moving/source layer used when estimating the transform. + target_layer_name : str + Name of the fixed/target layer used when estimating the transform. + operation : str + Registration operation that produced the transform. + transform_model : str + Transform model used during registration. + metric : str + Similarity metric used during registration. + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics. + name : str, optional + Human-friendly transform name. If not provided, a default name is generated. + + Returns + ------- + BSplineTransformPayload + JSON-serializable B-spline transform payload. + """ + payload_name = ( + name or f"{source_layer_name} → {target_layer_name} ({transform_model})" + ) + return { + "kind": "bspline", + "name": payload_name, + "bspline": _serialize_bspline_dataarray(transform), + "source_layer_name": source_layer_name, + "target_layer_name": target_layer_name, + "operation": operation, + "transform_model": transform_model, + "metric": metric, + "output_grid": make_output_grid_payload(reference), + "diagnostics": _make_diagnostics_payload(diagnostics), } @@ -152,17 +274,12 @@ def affine_transform_from_payload( Parameters ---------- payload : mapping - Transform payload loaded from metadata or JSON. + Transform payload loaded from metadata or disk. Returns ------- (N+1, N+1) numpy.ndarray Affine matrix. - - Raises - ------ - ValueError - If the payload is not an affine transform payload. """ if payload.get("kind") != "affine": raise ValueError("Transform payload is not an affine transform.") @@ -176,23 +293,40 @@ def affine_transform_from_payload( return affine +def bspline_transform_from_payload(payload: "Mapping[str, object]") -> xr.DataArray: + """Return the B-spline transform stored in a payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + xarray.DataArray + B-spline control-point grid. + """ + if payload.get("kind") != "bspline": + raise ValueError("Transform payload is not a B-spline transform.") + + bspline = payload.get("bspline") + if not isinstance(bspline, dict): + raise ValueError("B-spline payload must contain a serialized DataArray.") + return _deserialize_bspline_dataarray(cast("BSplineDataArrayPayload", bspline)) + + def output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: """Return the output grid stored in a transform payload. Parameters ---------- payload : mapping - Transform payload loaded from metadata or JSON. + Transform payload loaded from metadata or disk. Returns ------- OutputGridPayload Output-grid description stored in the payload. - - Raises - ------ - ValueError - If the payload does not carry a valid output grid. """ grid = payload.get("output_grid") if not isinstance(grid, dict): @@ -222,6 +356,130 @@ def output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPaylo } +def _save_bspline_transform_payload( + path: str | Path, payload: BSplineTransformPayload +) -> None: + """Save a B-spline transform payload as Zarr. + + Parameters + ---------- + path : str or pathlib.Path + Output Zarr path. + payload : BSplineTransformPayload + Transform payload to save. + """ + path = Path(path) + if path.suffix != ".zarr": + raise ValueError("B-spline transform files must have .zarr extension.") + + transform = bspline_transform_from_payload(payload) + ds = transform.to_dataset(name="bspline_transform") + payload_metadata = { + key: value for key, value in payload.items() if key not in {"kind", "bspline"} + } + ds.attrs["confusius_transform_kind"] = "bspline" + ds.attrs["confusius_transform_payload_json"] = json.dumps(payload_metadata) + ds.to_zarr(path, mode="w") + + +def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload: + """Load a B-spline transform payload from Zarr. + + Parameters + ---------- + path : str or pathlib.Path + Input Zarr path. + + Returns + ------- + BSplineTransformPayload + Loaded B-spline transform payload. + """ + ds = xr.open_zarr(path) + try: + if ds.attrs.get("confusius_transform_kind") != "bspline": + raise ValueError( + "Zarr transform store does not contain a ConfUSIus B-spline transform." + ) + payload_metadata = json.loads( + cast("str", ds.attrs["confusius_transform_payload_json"]) + ) + if not isinstance(payload_metadata, dict): + raise ValueError("Stored transform payload metadata is malformed.") + transform = ds["bspline_transform"].load() + finally: + ds.close() + + _validate_bspline_dataarray(transform) + payload: BSplineTransformPayload = { + "kind": "bspline", + "bspline": _serialize_bspline_dataarray(transform), + "name": str(payload_metadata["name"]), + "source_layer_name": str(payload_metadata["source_layer_name"]), + "target_layer_name": str(payload_metadata["target_layer_name"]), + "operation": str(payload_metadata["operation"]), + "transform_model": str(payload_metadata["transform_model"]), + "metric": str(payload_metadata["metric"]), + "output_grid": output_grid_from_payload(payload_metadata), + "diagnostics": cast( + "TransformDiagnosticsPayload", payload_metadata["diagnostics"] + ), + } + return payload + + +def save_transform_payload(path: str | Path, payload: TransformPayload) -> None: + """Save a transform payload to disk. + + Parameters + ---------- + path : str or pathlib.Path + Output path. + payload : TransformPayload + Transform payload to save. + + Notes + ----- + Affine payloads are saved as JSON. B-spline payloads are saved as Zarr. + """ + if payload["kind"] == "affine": + Path(path).write_text(json.dumps(payload, indent=2) + "\n") + return + _save_bspline_transform_payload(path, payload) + + +def load_transform_payload(path: str | Path) -> TransformPayload: + """Load an affine or B-spline transform payload from disk. + + Parameters + ---------- + path : str or pathlib.Path + Input path. + + Returns + ------- + TransformPayload + Loaded transform payload. + """ + path = Path(path) + if path.suffix == ".zarr": + return _load_bspline_transform_payload(path) + + payload = json.loads(path.read_text()) + if not isinstance(payload, dict): + raise ValueError("Transform file must contain a JSON object.") + + kind = payload.get("kind") + if kind != "affine": + raise ValueError( + "JSON transform files currently support affine payloads only. " + "Use .zarr for B-spline transforms." + ) + affine_transform_from_payload(payload) + output_grid_from_payload(payload) + return cast("TransformPayload", payload) + + def save_affine_transform_payload( path: str | Path, payload: AffineTransformPayload ) -> None: @@ -234,7 +492,7 @@ def save_affine_transform_payload( payload : AffineTransformPayload Transform payload to save. """ - Path(path).write_text(json.dumps(payload, indent=2) + "\n") + save_transform_payload(path, payload) def load_affine_transform_payload(path: str | Path) -> AffineTransformPayload: @@ -248,16 +506,34 @@ def load_affine_transform_payload(path: str | Path) -> AffineTransformPayload: Returns ------- AffineTransformPayload - Loaded payload. - - Raises - ------ - ValueError - If the file does not contain an affine transform payload. + Loaded affine transform payload. """ - payload = json.loads(Path(path).read_text()) - if not isinstance(payload, dict): - raise ValueError("Transform file must contain a JSON object.") + payload = load_transform_payload(path) affine_transform_from_payload(payload) - output_grid_from_payload(payload) return cast("AffineTransformPayload", payload) + + +def _validate_bspline_dataarray(da: xr.DataArray) -> None: + """Raise ValueError if *da* does not look like a valid B-spline transform.""" + if da.attrs.get("type") != "bspline_transform": + raise ValueError( + f"Expected a DataArray with attrs['type'] == 'bspline_transform'; " + f"got {da.attrs.get('type')!r}." + ) + for key in ("order", "direction"): + if key not in da.attrs: + raise ValueError( + f"B-spline transform DataArray is missing required attribute {key!r}." + ) + if not da.dims or da.dims[0] != "component": + raise ValueError( + "B-spline transform DataArray must have 'component' as its first " + f"dimension; got {da.dims[0] if da.dims else None!r}." + ) + ndim = da.ndim - 1 + if da.sizes["component"] != ndim: + raise ValueError( + "B-spline transform DataArray component axis must match the number of " + f"spatial dimensions; got {da.sizes['component']} components for {ndim} " + "spatial dims." + ) diff --git a/src/confusius/registration/bspline.py b/src/confusius/registration/bspline.py index c2c8b65e..651752bf 100644 --- a/src/confusius/registration/bspline.py +++ b/src/confusius/registration/bspline.py @@ -1,6 +1,6 @@ """B-spline transform helpers for fUSI registration. -A B-spline deformation field is represented as a DataArray with: +A B-spline control-point grid is represented as a DataArray with: - **dims**: `("component", )` — e.g. `("component", "z", "y", "x")`. - **coords**: physical mm positions of the control-point grid along each spatial axis. @@ -22,8 +22,12 @@ When a pre-affine is stored in `attrs["affines"]["bspline_initialization"]`, the full transform is a `CompositeTransform(pre_affine, bspline)` — i.e. the pre-affine is applied *first* (coarse global alignment) and the B-spline is applied *second* (local -deformation refinement). This mirrors the `inPlace=True` composite that SimpleITK +deformation refinement). This mirrors the `inPlace=True` composite that SimpleITK optimises during registration. + +This object is not a dense deformation field sampled on every voxel of the moving or +fixed image. Instead, it stores the sparse B-spline coefficient lattice that defines +the smooth deformation. """ from typing import TYPE_CHECKING diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index 2fe67d01..59d3d4ca 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -541,11 +541,16 @@ def register_volume( N+1)` in physical space, where `N` is the spatial dimensionality (2 or 3). Follows SimpleITK's pull/inverse convention: the matrix maps fixed-space coordinates to moving-space coordinates. For `transform_type="bspline"`, - returns a DataArray encoding the B-spline control-point grid (see - [`confusius.registration.bspline`][confusius.registration.bspline] for the - DataArray schema). When an affine `initialization` was also supplied, the DataArray - includes `attrs["affines"]["bspline_initialization"]` so that the full composite - (pre-affine + B-spline) can be reconstructed for resampling. + returns an `xarray.DataArray` containing the B-spline control-point grid, not a + dense deformation field. The first dimension is `component` with length `N`, + followed by spatial dimensions in ConfUSIus order (`("y", "x")` in 2D or + `("z", "y", "x")` in 3D). The coordinate values along each spatial axis are + the physical positions of the control points. Attributes include `type = + "bspline_transform"`, the spline `order`, and the control-grid `direction` + matrix. When an affine `initialization` was also supplied, the DataArray also + includes `attrs["affines"]["bspline_initialization"]` so that the full + composite transform (pre-affine + B-spline) can be reconstructed for later + resampling. diagnostics : confusius.registration.RegistrationDiagnostics Per-iteration metric values, final metric value, iteration count, and the optimizer stop condition. Useful for plotting convergence curves, comparing diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index e870e9eb..94faf135 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -12,10 +12,14 @@ from confusius._napari._registration._transforms import ( affine_transform_from_payload, + bspline_transform_from_payload, load_affine_transform_payload, + load_transform_payload, make_affine_transform_payload, + make_bspline_transform_payload, output_grid_from_payload, save_affine_transform_payload, + save_transform_payload, ) from confusius.registration import resample_like @@ -42,6 +46,24 @@ class _FakeDiagnostics: status: str = "completed" +def _make_bspline_transform() -> xr.DataArray: + return xr.DataArray( + np.arange(2 * 3 * 4, dtype=float).reshape(2, 3, 4), + dims=["component", "y", "x"], + coords={ + "component": xr.DataArray([0, 1], dims=["component"]), + "y": xr.DataArray(np.arange(3) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(4) * 0.1, dims=["x"]), + }, + attrs={ + "type": "bspline_transform", + "order": 3, + "direction": [[1.0, 0.0], [0.0, 1.0]], + "affines": {"bspline_initialization": np.eye(3).tolist()}, + }, + ) + + class TestRefreshLayers: def test_combo_populated_on_layer_add(self, viewer, registration_panel): assert registration_panel._moving_combo.count() == 0 @@ -853,6 +875,151 @@ def test_affine_payload_roundtrip(self, tmp_path): assert output_grid_from_payload(loaded)["shape"] == [4, 6] np.testing.assert_array_equal(affine_transform_from_payload(loaded), np.eye(3)) + def test_bspline_payload_roundtrip(self, tmp_path): + reference = xr.DataArray( + np.ones((3, 4), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(3) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(4) * 0.1, dims=["x"]), + }, + ) + transform = _make_bspline_transform() + payload = make_bspline_transform_payload( + transform, + reference=reference, + source_layer_name="moving", + target_layer_name="fixed", + operation="register_volume", + transform_model="bspline", + metric="correlation", + diagnostics=_FakeDiagnostics(), + ) + + path = tmp_path / "bspline.zarr" + save_transform_payload(path, payload) + loaded = load_transform_payload(path) + + assert loaded["name"] == "moving → fixed (bspline)" + assert loaded["kind"] == "bspline" + assert output_grid_from_payload(loaded)["shape"] == [3, 4] + xr.testing.assert_identical( + bspline_transform_from_payload(loaded), + transform.astype(float), + ) + + def test_bspline_transform_is_not_offered_for_initialization( + self, viewer, registration_panel + ): + moving = xr.DataArray( + np.zeros((3, 4), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(3) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(4) * 0.1, dims=["x"]), + }, + ) + payload = make_bspline_transform_payload( + _make_bspline_transform(), + reference=moving, + source_layer_name="moving", + target_layer_name="fixed", + operation="register_volume", + transform_model="bspline", + metric="correlation", + diagnostics=_FakeDiagnostics(), + ) + viewer.add_image( + moving.values, + name="Registered (bspline)", + metadata={"xarray": moving, "confusius_transform": payload}, + ) + + registration_panel._refresh_transform_controls() + + transform_items = [ + registration_panel._transform_source_combo.itemText(i) + for i in range(registration_panel._transform_source_combo.count()) + ] + initialization_items = [ + registration_panel._initialization_combo.itemText(i) + for i in range(registration_panel._initialization_combo.count()) + ] + + assert "moving → fixed (bspline)" in transform_items + assert "moving → fixed (bspline)" not in initialization_items + + def test_apply_transform_uses_bspline_payload(self, viewer, registration_panel, monkeypatch): + moving = xr.DataArray( + np.zeros((3, 4), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(3) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(4) * 0.1, dims=["x"]), + }, + ) + payload = make_bspline_transform_payload( + _make_bspline_transform(), + reference=moving, + source_layer_name="moving", + target_layer_name="fixed", + operation="register_volume", + transform_model="bspline", + metric="correlation", + diagnostics=_FakeDiagnostics(), + ) + viewer.add_image( + moving.values, + name="moving", + metadata={"xarray": moving}, + ) + viewer.add_image( + moving.values, + name="Registered (bspline)", + metadata={"xarray": moving, "confusius_transform": payload}, + ) + registration_panel._refresh_transform_controls() + registration_panel._transform_source_combo.setCurrentText("moving → fixed (bspline)") + registration_panel._transform_target_combo.setCurrentText("moving") + + captured: dict[str, object] = {} + + class _FakeSignal: + def connect(self, _slot): + return None + + class _FakeWorker: + def __init__(self) -> None: + self.returned = _FakeSignal() + self.errored = _FakeSignal() + self.finished = _FakeSignal() + + def start(self) -> None: + return None + + def _fake_thread_worker(func): + def _runner(*args, **kwargs): + captured["func"] = func + captured["args"] = args + captured["kwargs"] = kwargs + return _FakeWorker() + + return _runner + + monkeypatch.setattr( + "confusius._napari._registration._panel.thread_worker", + _fake_thread_worker, + ) + + registration_panel._apply_transform() + + assert captured["func"].__name__ == "resample_volume" + xr.testing.assert_identical( + captured["args"][1], + _make_bspline_transform().astype(float), + ) + assert registration_panel._worker is not None + class TestPluginWidget: def test_registration_panel_is_present_in_main_widget(self, viewer): @@ -1014,6 +1181,46 @@ def test_volume_result_adds_new_layer_with_transform_metadata( == "register_volume" ) + def test_volume_result_adds_bspline_transform_metadata( + self, viewer, registration_panel + ): + fixed = xr.DataArray( + np.ones((3, 4), dtype=np.float32), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(3) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(4) * 0.1, dims=["x"]), + }, + ) + registered = fixed.copy() + transform = _make_bspline_transform() + diagnostics = _FakeDiagnostics() + + payload = { + "operation": "register_volume", + "moving_layer_name": "moving", + "fixed_layer_name": "fixed", + "transform": "bspline", + "metric": "correlation", + "learning_rate": "auto", + "number_of_iterations": 100, + "use_multi_resolution": False, + "resample_interpolation": "linear", + } + + registration_panel._on_registration_finished( + payload, + (registered, transform, diagnostics), + ) + + layer = viewer.layers["Registered (bspline)"] + assert layer.metadata["registration_status"] == "completed" + assert layer.metadata["confusius_transform"]["kind"] == "bspline" + xr.testing.assert_identical( + bspline_transform_from_payload(layer.metadata["confusius_transform"]), + transform.astype(float), + ) + def test_volume_result_replaces_preview_layer( self, viewer, registration_panel, qtbot ): diff --git a/tests/unit/test_registration/test_volume.py b/tests/unit/test_registration/test_volume.py index 9282345e..47856e77 100644 --- a/tests/unit/test_registration/test_volume.py +++ b/tests/unit/test_registration/test_volume.py @@ -959,3 +959,152 @@ def test_default_fill_value_is_moving_min(self): assert float(result.values[0, 0]) == pytest.approx( float(moving.min()), abs=1e-5 ) + + +class TestRegisterVolumeIterationCallback: + """The `iteration_callback` is invoked at every optimizer iteration.""" + + def test_callback_receives_one_indexed_iteration_and_metric( + self, sample_2d_dataarray_spatial + ): + """Each callback call is (iteration, metric) with iteration starting at 1.""" + calls: list[tuple[int, float]] = [] + + def callback(iteration: int, metric: float) -> None: + calls.append((iteration, metric)) + + register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="translation", + number_of_iterations=5, + iteration_callback=callback, + ) + + assert len(calls) == 5 + assert [c[0] for c in calls] == [1, 2, 3, 4, 5] + for _, metric in calls: + assert np.isfinite(metric) + + +class TestRegisterVolumePreSetAbort: + """Pre-set abort_event short-circuits before SimpleITK Execute is called.""" + + def test_bspline_abort_returns_initial_bspline_transform( + self, sample_2d_dataarray_spatial + ): + """Pre-aborted bspline returns a DataArray without forcing a bspline fit.""" + abort_event = Event() + abort_event.set() + + _, transform, diagnostics = register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="bspline", + abort_event=abort_event, + ) + + assert diagnostics.status == "aborted" + assert diagnostics.n_iterations == 0 + assert ( + diagnostics.stop_condition + == "Registration aborted before optimisation started." + ) + # The returned DataArray wraps the initial (unoptimised) bspline — its + # coefficients differ from a real registration only in that no iterations ran. + assert isinstance(transform, xr.DataArray) + assert transform.attrs.get("type") == "bspline_transform" + + def test_affine_initialization_abort_returns_initialization_affine( + self, sample_2d_dataarray_spatial + ): + """Pre-aborted linear registration returns the provided affine initialization. + + The transform must match the initialization matrix — not the default + identity/TranslationTransform fallback used when no initialization is set — + so downstream consumers can rely on a coherent aborted transform. + """ + pre_affine = np.array([[1.0, 0.0, 0.5], [0.0, 1.0, -0.25], [0.0, 0.0, 1.0]]) + + abort_event = Event() + abort_event.set() + + _, transform, diagnostics = register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="rigid", + initialization=pre_affine, + abort_event=abort_event, + ) + + assert diagnostics.status == "aborted" + assert diagnostics.n_iterations == 0 + assert_allclose(transform, pre_affine) + + +class TestRegisterVolumeConvergesBeforeFirstIteration: + """`final_metric_value` falls back to the optimizer's metric when no iteration event fires.""" + + def test_final_metric_value_pulled_from_optimizer_when_no_iterations( + self, sample_2d_dataarray_spatial + ): + """When SimpleITK converges before any iteration event, final_metric_value is + the optimizer's current metric, not NaN. + + Achieved by raising `convergence_minimum_value` above the metric for identical + images and shrinking the window to 1, so the convergence checker passes at + iteration 0 before any iteration event fires. + """ + _, _, diagnostics = register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="translation", + number_of_iterations=100, + convergence_minimum_value=1.0, + convergence_window_size=1, + ) + + assert diagnostics.n_iterations == 0 + assert diagnostics.status == "completed" + assert np.isfinite(diagnostics.final_metric_value) + assert "Convergence checker passed at iteration 0" in diagnostics.stop_condition + + +class TestRegisterVolumeFromWorkerThread: + """`register_volume` works when called from a non-main thread.""" + + def test_register_volume_runs_in_non_main_thread(self, sample_2d_dataarray_spatial): + """Calling `register_volume` from a worker thread bypasses SIGINT wiring. + + The non-main-thread branch of `abort_on_sigint` skips installing a SIGINT + handler and simply yields the abort event, so registration runs to + completion without trying to mutate the main thread's signal handlers. + """ + import threading + + from confusius.registration.diagnostics import RegistrationDiagnostics + + result_holder: dict[str, object] = {} + + def worker() -> None: + assert threading.current_thread() is not threading.main_thread() + result, transform, diagnostics = register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="translation", + number_of_iterations=2, + ) + result_holder["result"] = result + result_holder["transform"] = transform + result_holder["diagnostics"] = diagnostics + + thread = threading.Thread(target=worker) + thread.start() + thread.join() + + result = result_holder["result"] + diagnostics = result_holder["diagnostics"] + assert isinstance(result, xr.DataArray) + assert isinstance(diagnostics, RegistrationDiagnostics) + assert result.shape == sample_2d_dataarray_spatial.shape + assert diagnostics.status == "completed" From d95e229c44d9ac6b66358b5fbcdd1ec75bcdfe08 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sun, 28 Jun 2026 22:33:16 +0200 Subject: [PATCH 22/72] refactor(registration): simplify running panel state --- src/confusius/_napari/_registration/_panel.py | 8 ++++---- .../unit/test_napari/test_registration_panel.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 9dc87883..d36510f2 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2118,8 +2118,7 @@ def _on_mode_changed(self) -> None: def _begin_work(self) -> None: """Put the panel into its busy state.""" - self._run_btn.setEnabled(False) - self._run_btn.setText("Registering…") + self._run_btn.hide() self._abort_btn.setEnabled(True) self._abort_btn.setText("Abort") self._abort_btn.show() @@ -2495,7 +2494,7 @@ def _teardown_volume_progress(self) -> None: self._progress_bridge = None def _ensure_metric_plotter(self) -> RegistrationMetricPlotter | None: - """Return the bottom-dock metric plotter, creating and docking it on first use. + """Return the right-dock metric plotter, creating and docking it on first use. Mirrors the lazy-dock pattern used by `SignalPanel`. The plotter widget is reused across runs; `_setup_volume_progress` resets its data buffer @@ -2510,7 +2509,7 @@ def _ensure_metric_plotter(self) -> RegistrationMetricPlotter | None: dock = self.viewer.window.add_dock_widget( self._metric_plotter, name="Registration Metric", - area="bottom", + area="right", ) self._metric_dock = cast("QDockWidget", dock) @@ -2577,6 +2576,7 @@ def _end_work(self) -> None: """Restore the idle UI state after background work.""" self._worker = None self._abort_event = None + self._run_btn.show() self._run_btn.setEnabled(True) self._run_btn.setText("Run registration") self._abort_btn.setEnabled(True) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 94faf135..b650ebab 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -8,6 +8,7 @@ import numpy as np import pytest import xarray as xr +from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from confusius._napari._registration._transforms import ( @@ -463,6 +464,9 @@ def test_abort_sets_cancellation_event(self, registration_panel): registration_panel._abort_event = Event() registration_panel._begin_work() + assert registration_panel._run_btn.isHidden() + assert not registration_panel._abort_btn.isHidden() + registration_panel._abort_registration() assert registration_panel._abort_event.is_set() @@ -1135,6 +1139,18 @@ def test_frame_completion_updates_frame_progress( assert registration_panel._progress.value() == 3 +class TestMetricPlotter: + def test_metric_plotter_docks_on_the_right(self, registration_panel): + plotter = registration_panel._ensure_metric_plotter() + + assert plotter is not None + dock = registration_panel._metric_dock + assert dock is not None + main_window = registration_panel._find_main_window(dock) + assert main_window is not None + assert main_window.dockWidgetArea(dock) == Qt.DockWidgetArea.RightDockWidgetArea + + class TestFinishedCallbacks: def test_volume_result_adds_new_layer_with_transform_metadata( self, viewer, registration_panel From 8fc5c9a6e12522f75fcd029558af0d6f9a7a0df6 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sun, 28 Jun 2026 22:35:31 +0200 Subject: [PATCH 23/72] style(registration): enlarge metric plot --- .../_napari/_registration/_metric_plotter.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/confusius/_napari/_registration/_metric_plotter.py b/src/confusius/_napari/_registration/_metric_plotter.py index c39a0ea9..c06865fb 100644 --- a/src/confusius/_napari/_registration/_metric_plotter.py +++ b/src/confusius/_napari/_registration/_metric_plotter.py @@ -1,10 +1,10 @@ """Bottom-dock widget that plots the registration optimizer metric. Mirrors the [`SignalPlotter`][confusius._napari._signals._plotter.SignalPlotter] -layout — a small matplotlib figure in the bottom dock — but stays deliberately -simple: a single line chart of the per-iteration metric value. The widget is -created lazily by `RegistrationPanel` when a registration starts, and torn down -on completion so the dock returns to its pre-run layout. +layout—a small matplotlib figure in the bottom dock—but stays deliberately simple: a +single line chart of the per-iteration metric value. The widget is created lazily by +`RegistrationPanel` when a registration starts, and torn down on completion so the dock +returns to its pre-run layout. """ from __future__ import annotations @@ -14,6 +14,8 @@ import numpy as np from matplotlib.backends.backend_qtagg import ( FigureCanvasQTAgg as FigureCanvas, +) +from matplotlib.backends.backend_qtagg import ( NavigationToolbar2QT as NavigationToolbar, ) from matplotlib.figure import Figure @@ -29,11 +31,11 @@ class RegistrationMetricPlotter(QWidget): """Bottom-dock widget that plots the per-iteration optimizer metric. - The widget is intentionally minimal: a single matplotlib axes, a - navigation toolbar, and a thin status footer. Layout decisions (e.g. - y-axis limits, line width) follow the same conventions as - [`SignalPlotter`][confusius._napari._signals._plotter.SignalPlotter] for - visual consistency between the two bottom-dock tabs. + The widget is intentionally minimal: a single matplotlib axes, a navigation toolbar, + and a thin status footer. Layout decisions (e.g. y-axis limits, line width) follow + the same conventions as + [`SignalPlotter`][confusius._napari._signals._plotter.SignalPlotter] for visual + consistency between the two bottom-dock tabs. Parameters ---------- @@ -56,7 +58,7 @@ def __init__(self, viewer: "Viewer") -> None: QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding, ) - self.setMinimumHeight(160) + self.setMinimumHeight(300) self._setup_ui() self._apply_theme() self._viewer.events.theme.connect(self._on_theme_changed) @@ -67,9 +69,9 @@ def sizeHint(self) -> QSize: Returns ------- QSize - Preferred initial size of 800 x 240 pixels. + Preferred initial size of 800 x 370 pixels. """ - return QSize(800, 240) + return QSize(800, 370) def _setup_ui(self) -> None: """Build the matplotlib canvas and toolbar.""" From ecb30a11505a521367d40c2fe7c85b30d550d47e Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sun, 28 Jun 2026 22:38:42 +0200 Subject: [PATCH 24/72] chore: delete napari registration plan --- NAPARI_REGISTRATION_PLAN.md | 223 ------------------------------------ 1 file changed, 223 deletions(-) delete mode 100644 NAPARI_REGISTRATION_PLAN.md diff --git a/NAPARI_REGISTRATION_PLAN.md b/NAPARI_REGISTRATION_PLAN.md deleted file mode 100644 index a21fec62..00000000 --- a/NAPARI_REGISTRATION_PLAN.md +++ /dev/null @@ -1,223 +0,0 @@ -# Napari registration panel plan - -## Goal - -Add a first **Registration** tab to the ConfUSIus napari plugin so users can run the main registration workflows directly from the viewer. - -## Phase 1 scope - -Deliver a thin but usable panel focused on running registrations and adding the resampled output back to napari. - -### Included - -- New **Registration** accordion tab in the napari plugin. -- Use the same Lucide icon as the docs registration page: `images`. -- Support: - - `register_volume` - - `register_volumewise` -- Always resample in the GUI. - - No `resample=True/False` toggle. - - The resampled result is always added as a **new layer**. -- Background-thread execution so the napari UI stays responsive. -- Result-layer metadata carries the original `xarray.DataArray`, parameters, diagnostics, and transform provenance. -- Save / load / apply affine transform UI. -- Cancellation / abort support with partial-result return semantics. -- In-napari live progress for both workflows. - - `register_volume`: live red/cyan overlay + metric plot. - - `register_volumewise`: determinate progress bar + progressively filled output layer. -- Per-frame / per-iteration progress callbacks for `register_volumewise`. - -### Not yet included - -- Manual initialization transforms from direct napari interaction. -- Standalone `resample_like` / `resample_volume` actions in the panel. -- Registration masks in the panel. -- Unified payload support for arbitrary manual napari-created transforms. -- Non-affine transform payload support. - -## UX decisions - -### `register_volume` - -- Requires a moving layer and a fixed layer. -- Both must be spatial-only volumes. -- Result layer name should clearly indicate the fixed target. -- Keep the estimated transform and diagnostics in metadata for later reuse. -- Future between-scan previews should use dedicated temporary napari layers - (Fixed / Moving / Resampled moving) instead of mutating the original - viewer layers in place. -- When time-series inputs are selected in between-scan mode, they should be - reduced to a time-averaged spatial volume before registration. -- Registration should support an optional intensity-scale preprocessing step - so fixed and moving previews live in the same display space. - -### `register_volumewise` - -- Operates on one time-series layer. -- Uses a selected `reference_time`. -- Adds the registered time series as a new layer. -- Preserve motion metadata already returned by `register_volumewise`. -- Volumewise progress should also move toward separate napari layer objects - for preview/result state, while reusing the same underlying data whenever - possible to avoid unnecessary copies. - -## Implementation notes - -### Layer → DataArray conversion - -The panel should prefer `layer.metadata["xarray"]` when available. -For generic napari layers without ConfUSIus metadata, reconstruct a simple `xarray.DataArray` from: - -- `layer.data` -- `layer.scale` -- `layer.translate` -- `layer.axis_labels` -- `layer.units` - -This keeps manual/foreign napari layers usable in the panel. - -### Provenance - -Store a small provenance payload on the result layer metadata, including: - -- operation name -- moving layer name -- fixed layer name when applicable -- transform model -- metric -- interpolation -- transform object for `register_volume` -- diagnostics - -## Follow-up phases - -### Phase 2 - -Transform management. - -#### Implemented - -- Save/load/apply affine transforms from the registration panel. -- Stable ConfUSIus-owned JSON payload for affine transforms. -- Human-friendly transform names in the payload. -- Output-grid metadata stored with the transform so a saved transform can be - reapplied later without reloading the original fixed/reference layer. -- Affine registration results store a reusable transform payload in layer metadata. - -#### Remaining polish - -- Better internal layout for the registration tab as it grows. -- Unified payload support for manual napari-created transforms. -- Optional support for non-affine transform payloads in the future. -- Decide whether volumewise should also hide / retint the source layer after - completion, mirroring the single-volume workflow more closely. -- Support B-spline transform payloads in the Transforms tab if we want to save - and reload non-affine registrations. -- Make it possible to use an existing saved / computed transform as the - initialization transform for a new registration run. -- Make it possible to use the current napari layer transform as the - initialization transform. - -### Phase 3 - -Manual initialization: - -- capture napari layer transforms as initialization affines -- apply saved affines back onto layers -- reset/apply current transform actions - -### Phase 4 - -Progress integration. - -#### Implemented - -- `progress_plotter` factory argument on `register_volume`; defaults to the - matplotlib plotter, the napari plugin injects a Qt-signal bridge. -- Napari-side bridge + `NapariVolumeProgress` reporter resamples the moving - image at every SimpleITK iteration and streams the array into a live - `Image` layer (the "resampled" overlay). -- The fixed layer is tinted red, the moving layer is tinted cyan + additive - and hidden during the run; the preview is seeded with the moving image - resampled onto the fixed grid (identity transform) so the first frame is - a meaningful "unaligned moving on fixed" view. -- Bottom-dock `RegistrationMetricPlotter` widget renders the per-iteration - optimizer metric curve. Coalesces redraws through a 16 ms `QTimer` so - rapid iteration events don't flood the GUI thread. -- `register_volumewise` exposes a public progress-reporter hook. -- Napari volumewise registration uses a determinate progress bar. -- Napari volumewise registration pre-creates the output layer, fills it with - the moving-layer minimum value, then writes frames in as they finish. -- During volumewise progress, the original layer is tinted red and the - in-progress output layer is tinted cyan + additive for visual comparison. - -#### Remaining polish - -- Fix the volumewise progress bar so it reliably reaches 100% on completion. -- Investigate and fix abort support on Windows. - -### Phase 5 - -Panel polish. - -#### Implemented - -- Sidebar widened so the "Moving layer" label and dropdown align with the - rest of the form rows. -- Run button is disabled (greyed out, visibly non-clickable) when the - current layer selection is invalid (no moving layer, missing fixed - layer, moving == fixed, time-dim mismatch, etc.). Re-evaluated on every - selection / mode / param change. -- Learning rate spinbox lower bound lowered below `1e-6` so bspline and - fine-scale transforms can use the small rates they need. -- Missing `register_volume` / `register_volumewise` parameters exposed in - the panel: `number_of_histogram_bins` (mattes MI), convergence - (`convergence_minimum_value`, `convergence_window_size`), - `centering_initialization`, `shrink_factors` / `smoothing_sigmas`, - `fill_value`, `keep_diagnostics`, and `n_jobs` / **Parallel jobs**. - Grouped into a basic section plus a foldable in-panel "Advanced" section. -- Advanced-row visibility is context-sensitive: - - histogram bins only show for `mattes_mi` - - shrink factors / smoothing sigmas only show when multi-resolution is on - - parallel jobs only show for within-scan registration -- The whole "Advanced" header is clickable, not just the disclosure triangle. -- Volumewise mode defaults to a fixed learning rate of `0.01` with `Auto` - unticked; between-scan mode keeps `Auto` on. -- Mode-specific parameter state is preserved while the panel stays open: - switching between between-scan and within-scan restores the last values - used in that mode instead of resetting them. -- Thicker determinate progress bar so the percentage text remains visible. -- **Abort button** for both `register_volume` and `register_volumewise`. - Aborting stops at the next cooperative checkpoint and returns the current - partial result instead of failing the worker. - -#### Remaining polish - -- Better internal layout for the registration tab as it grows. -- Unified payload support for manual napari-created transforms. -- Optional support for non-affine transform payloads in the future. -- Add the B-spline `mesh_size` parameter to the basic parameters area and show - it only when the selected transform is `bspline`. -- Shorten and clarify the names of the temporary / output registration layers. -- Add an intensity-scale control (for example `dB`, `sqrt`, or off) to - registration previews and preprocessing. The default should be enabled and - use `dB`. -- Rework between-scan preview layers so fixed, moving, and registered-moving - are separate dedicated layers with appropriate contrast handling. -- Set the iterations spinbox step size to 100. - -### Phase 6 - -CLI / Python UX polish. - -#### Implemented - -- `register_volume` is now Ctrl+C-aware in Python usage: on the main thread, - the first Ctrl+C is converted into cooperative cancellation via the shared - abort event, and the current partial result is returned with - `diagnostics.status="aborted"`. - -#### Remaining polish - -- Consider extending the same Ctrl+C wrapper to higher-level workflows beyond - direct `register_volume` calls if needed. From 1212470834b8b8bc6deee1081be7353a1991d099 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 09:38:10 +0200 Subject: [PATCH 25/72] test(registration): trim panel helper coverage --- AGENTS.md | 11 + .../test_napari/test_registration_panel.py | 210 ------------------ tests/unit/test_registration/test_volume.py | 9 - 3 files changed, 11 insertions(+), 219 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fb5ccad..968590de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -332,6 +332,17 @@ request, e.g. `([#123](https://github.com/confusius-tools/confusius/pull/123))`. - Keep tests fast by using small array sizes. - Use seeded random number generators for reproducibility. +### Napari Plugin Tests +- Follow napari's plugin testing guidelines: + . +- Main message: prefer **small unit tests** over full GUI/integration tests. Trust napari + to deliver callbacks/events correctly; test our plugin logic and observable widget/viewer + state directly instead of trying to simulate every napari interaction end-to-end. +- Use napari pytest fixtures such as `make_napari_viewer` / `make_napari_viewer_proxy` + rather than building custom viewer setup/teardown by hand. +- For ConfUSIus napari tests, prefer assertions on public/observable behavior + (widget state, layer state, metadata, outputs) over module-private helper return values. + ### Visual Regression Tests - Use `@pytest.mark.mpl_image_compare` for plot output tests. - Run `just generate-baselines` to regenerate baseline images after intentional plot changes. diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index b650ebab..b626ed49 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -8,7 +8,6 @@ import numpy as np import pytest import xarray as xr -from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication from confusius._napari._registration._transforms import ( @@ -536,17 +535,6 @@ def test_between_scans_accepts_time_series_by_averaging( assert registration_panel._validate_registration_selection() - def test_transform_initialization_requires_selected_affine_transform( - self, viewer, registration_panel - ): - viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") - viewer.add_image(np.ones((4, 6), dtype=np.float32), name="fixed") - registration_panel._refresh_layers() - registration_panel._moving_combo.setCurrentText("moving") - registration_panel._fixed_combo.setCurrentText("fixed") - - assert registration_panel._validate_registration_selection() - def test_initial_transform_dropdown_lists_available_transforms( self, viewer, registration_panel ): @@ -663,193 +651,7 @@ def test_initial_transform_dropdown_updates_when_manual_transform_changes( ) -class TestBetweenScanPreparation: - def test_prepare_between_scan_data_averages_time(self): - from confusius._napari._registration._panel import _prepare_between_scan_data - - data = xr.DataArray( - np.stack( - [ - np.zeros((4, 6), dtype=np.float32), - np.ones((4, 6), dtype=np.float32), - ], - axis=0, - ), - dims=["time", "y", "x"], - coords={ - "time": xr.DataArray(np.arange(2), dims=["time"]), - "y": xr.DataArray(np.arange(4), dims=["y"]), - "x": xr.DataArray(np.arange(6), dims=["x"]), - }, - attrs={"foo": "bar"}, - ) - - averaged = _prepare_between_scan_data(data) - - assert averaged.dims == ("y", "x") - assert averaged.attrs["foo"] == "bar" - np.testing.assert_allclose(averaged.values, 0.5) - - -class TestScalePreprocessing: - def test_apply_registration_scale_db(self): - from confusius._napari._registration._panel import _apply_registration_scale - - data = xr.DataArray([1.0, 10.0, 100.0], dims=["x"]) - scaled = _apply_registration_scale(data, "dB") - - np.testing.assert_allclose(scaled.values, [-20.0, -10.0, 0.0]) - - def test_apply_registration_scale_sqrt(self): - from confusius._napari._registration._panel import _apply_registration_scale - - data = xr.DataArray([1.0, 4.0, 9.0], dims=["x"]) - scaled = _apply_registration_scale(data, "sqrt") - - np.testing.assert_allclose(scaled.values, [1.0, 2.0, 3.0]) - - -class TestManualNapariInitialization: - def test_spatial_manual_affine_ignores_time_axis(self, viewer): - from confusius._napari._registration._panel import ( - _spatial_manual_affine_from_layer, - ) - - moving = xr.DataArray( - np.zeros((2, 4, 6, 8), dtype=np.float32), - dims=["time", "z", "y", "x"], - coords={ - "time": xr.DataArray(np.arange(2), dims=["time"]), - "z": xr.DataArray(np.arange(4), dims=["z"]), - "y": xr.DataArray(np.arange(6), dims=["y"]), - "x": xr.DataArray(np.arange(8), dims=["x"]), - }, - ) - layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) - manual_affine = np.eye(5) - manual_affine[0, 4] = 3.0 - manual_affine[1, 4] = 0.5 - manual_affine[2, 4] = -0.25 - manual_affine[3, 4] = 1.25 - layer.affine = manual_affine - - affine = _spatial_manual_affine_from_layer(layer, spatial_dims=["z", "y", "x"]) - - expected = np.array( - [ - [1.0, 0.0, 0.0, 0.5], - [0.0, 1.0, 0.0, -0.25], - [0.0, 0.0, 1.0, 1.25], - [0.0, 0.0, 0.0, 1.0], - ] - ) - np.testing.assert_allclose(affine, expected) - - def test_spatial_manual_affine_rejects_time_spatial_mixing(self, viewer): - from confusius._napari._registration._panel import ( - _spatial_manual_affine_from_layer, - ) - - moving = xr.DataArray( - np.zeros((2, 4, 6), dtype=np.float32), - dims=["time", "y", "x"], - coords={ - "time": xr.DataArray(np.arange(2), dims=["time"]), - "y": xr.DataArray(np.arange(4), dims=["y"]), - "x": xr.DataArray(np.arange(6), dims=["x"]), - }, - ) - layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) - manual_affine = np.eye(4) - manual_affine[1, 0] = 0.5 - with pytest.warns(UserWarning, match="Non-orthogonal slicing"): - layer.affine = manual_affine - - with pytest.raises(ValueError, match="mixes spatial axes"): - _spatial_manual_affine_from_layer(layer, spatial_dims=["y", "x"]) - - -class TestLayerToDataArray: - def test_reconstructs_dataarray_from_generic_layer(self, viewer): - from confusius._napari._registration._panel import _get_source_dataarray - - layer = viewer.add_image( - np.zeros((3, 5, 7), dtype=np.float32), - name="plain", - scale=(0.3, 0.2, 0.1), - translate=(1.0, 2.0, 3.0), - ) - layer.axis_labels = ("z", "y", "x") - layer.units = ("mm", "mm", "mm") - - da = _get_source_dataarray(layer) - - assert da.dims == ("z", "y", "x") - assert da.coords["z"][0] == pytest.approx(1.0) - assert da.coords["y"][1] == pytest.approx(2.2) - assert da.coords["x"][2] == pytest.approx(3.2) - assert da.coords["x"].attrs["units"] in {"mm", "millimeter"} - - def test_generic_layer_snapshot_ignores_later_manual_translate(self, viewer): - from confusius._napari._registration._panel import _get_source_dataarray - - layer = viewer.add_image( - np.zeros((3, 5, 7), dtype=np.float32), - name="plain", - scale=(0.3, 0.2, 0.1), - translate=(1.0, 2.0, 3.0), - ) - layer.axis_labels = ("z", "y", "x") - - original = _get_source_dataarray(layer) - layer.translate = (9.0, 8.0, 7.0) - after_move = _get_source_dataarray(layer) - - assert after_move is original - assert after_move.coords["z"][0] == pytest.approx(1.0) - assert after_move.coords["y"][0] == pytest.approx(2.0) - assert after_move.coords["x"][0] == pytest.approx(3.0) - - class TestTransforms: - def test_selected_manual_transform_payload_matches_visible_layer_transform( - self, viewer, registration_panel - ): - moving = xr.DataArray( - np.zeros((4, 6), dtype=np.float32), - dims=["y", "x"], - coords={ - "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), - }, - ) - layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) - manual_affine = np.array( - [[1.0, 0.0, 0.3], [0.0, 1.0, -0.4], [0.0, 0.0, 1.0]], - dtype=float, - ) - layer.affine = manual_affine - - registration_panel._refresh_transform_controls() - for i in range(registration_panel._transform_source_combo.count()): - if registration_panel._transform_source_combo.itemData(i) == ( - "manual", - "moving", - ): - registration_panel._transform_source_combo.setCurrentIndex(i) - break - - payload = registration_panel._selected_transform_payload() - - assert payload is not None - np.testing.assert_allclose( - affine_transform_from_payload(payload), - np.linalg.inv(manual_affine), - ) - assert payload["name"] == "moving (manual)" - assert payload["source_layer_name"] == "moving" - assert payload["target_layer_name"] == "moving" - def test_affine_payload_roundtrip(self, tmp_path): reference = xr.DataArray( np.ones((4, 6), dtype=np.float32), @@ -1139,18 +941,6 @@ def test_frame_completion_updates_frame_progress( assert registration_panel._progress.value() == 3 -class TestMetricPlotter: - def test_metric_plotter_docks_on_the_right(self, registration_panel): - plotter = registration_panel._ensure_metric_plotter() - - assert plotter is not None - dock = registration_panel._metric_dock - assert dock is not None - main_window = registration_panel._find_main_window(dock) - assert main_window is not None - assert main_window.dockWidgetArea(dock) == Qt.DockWidgetArea.RightDockWidgetArea - - class TestFinishedCallbacks: def test_volume_result_adds_new_layer_with_transform_metadata( self, viewer, registration_panel diff --git a/tests/unit/test_registration/test_volume.py b/tests/unit/test_registration/test_volume.py index 47856e77..8002a311 100644 --- a/tests/unit/test_registration/test_volume.py +++ b/tests/unit/test_registration/test_volume.py @@ -1,6 +1,5 @@ """Unit tests for single-volume registration.""" -import signal from threading import Event import numpy as np @@ -8,7 +7,6 @@ import xarray as xr from numpy.testing import assert_allclose, assert_array_equal -from confusius.registration._utils import abort_on_sigint from confusius.registration.diagnostics import RegistrationDiagnostics from confusius.registration.resampling import resample_like, resample_volume from confusius.registration.volume import register_volume @@ -101,13 +99,6 @@ def test_abort_event_returns_partial_result(self, sample_2d_dataarray_spatial): assert diagnostics.status == "aborted" assert diagnostics.n_iterations == 0 - def test_abort_on_sigint_sets_abort_event(self): - """First Ctrl+C is converted into cooperative cancellation.""" - with abort_on_sigint(None) as abort_event: - handler = signal.getsignal(signal.SIGINT) - assert callable(handler) - handler(signal.SIGINT, None) - assert abort_event.is_set() class TestRegisterVolumeOutput: From 914d8ad8bdd4e98ebec456bea69d12b4c54f7a8b Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 09:39:59 +0200 Subject: [PATCH 26/72] refactor(registration): inline metric theme refresh --- src/confusius/_napari/_registration/_metric_plotter.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/confusius/_napari/_registration/_metric_plotter.py b/src/confusius/_napari/_registration/_metric_plotter.py index c06865fb..95b9612d 100644 --- a/src/confusius/_napari/_registration/_metric_plotter.py +++ b/src/confusius/_napari/_registration/_metric_plotter.py @@ -61,7 +61,7 @@ def __init__(self, viewer: "Viewer") -> None: self.setMinimumHeight(300) self._setup_ui() self._apply_theme() - self._viewer.events.theme.connect(self._on_theme_changed) + self._viewer.events.theme.connect(lambda *_: self._apply_theme()) def sizeHint(self) -> QSize: """Return the preferred initial size of the widget. @@ -112,10 +112,6 @@ def _apply_theme(self) -> None: style_plot_toolbar(self._toolbar, colors) self._canvas.draw_idle() - def _on_theme_changed(self) -> None: - """Handle napari theme change by re-applying the matplotlib style.""" - self._apply_theme() - def add_metric(self, value: float) -> None: """Append a metric value and schedule a redraw. From 53840ce1ab15d67569f70dd2285f3c09063824f0 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 09:50:47 +0200 Subject: [PATCH 27/72] test(registration): cover sigint through public api --- tests/unit/test_registration/test_volume.py | 125 ++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/unit/test_registration/test_volume.py b/tests/unit/test_registration/test_volume.py index 8002a311..36792676 100644 --- a/tests/unit/test_registration/test_volume.py +++ b/tests/unit/test_registration/test_volume.py @@ -1,5 +1,6 @@ """Unit tests for single-volume registration.""" +import signal from threading import Event import numpy as np @@ -12,6 +13,130 @@ from confusius.registration.volume import register_volume +class TestRegisterVolumeSigint: + """Ctrl+C handling exposed through the public `register_volume` API.""" + + def test_first_ctrl_c_returns_aborted_result_and_restores_handler( + self, sample_2d_dataarray_spatial, monkeypatch + ): + """First Ctrl+C sets the cooperative abort event and restores SIGINT afterwards.""" + import SimpleITK as sitk + + previous_handler = signal.getsignal(signal.SIGINT) + + def fake_execute(self, fixed, moving): + del self, fixed, moving + handler = signal.getsignal(signal.SIGINT) + assert callable(handler) + handler(signal.SIGINT, None) + return sitk.TranslationTransform(2) + + monkeypatch.setattr(sitk.ImageRegistrationMethod, "Execute", fake_execute) + + _result, _transform, diagnostics = register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="translation", + ) + + assert diagnostics.status == "aborted" + assert signal.getsignal(signal.SIGINT) is previous_handler + + def test_second_ctrl_c_raises_keyboardinterrupt( + self, sample_2d_dataarray_spatial, monkeypatch + ): + """Second Ctrl+C falls back to the previous default SIGINT handler.""" + import SimpleITK as sitk + + previous_handler = signal.getsignal(signal.SIGINT) + + def fake_execute(self, fixed, moving): + del self, fixed, moving + handler = signal.getsignal(signal.SIGINT) + assert callable(handler) + handler(signal.SIGINT, None) + handler(signal.SIGINT, None) + return sitk.TranslationTransform(2) + + monkeypatch.setattr(sitk.ImageRegistrationMethod, "Execute", fake_execute) + + with pytest.raises(KeyboardInterrupt): + register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="translation", + ) + + assert signal.getsignal(signal.SIGINT) is previous_handler + + def test_second_ctrl_c_ignores_when_previous_handler_ignores( + self, sample_2d_dataarray_spatial, monkeypatch + ): + """Second Ctrl+C is ignored when the previous SIGINT handler ignored it.""" + import SimpleITK as sitk + + previous_handler = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, signal.SIG_IGN) + + def fake_execute(self, fixed, moving): + del self, fixed, moving + handler = signal.getsignal(signal.SIGINT) + assert callable(handler) + handler(signal.SIGINT, None) + handler(signal.SIGINT, None) + return sitk.TranslationTransform(2) + + monkeypatch.setattr(sitk.ImageRegistrationMethod, "Execute", fake_execute) + + try: + _result, _transform, diagnostics = register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="translation", + ) + finally: + signal.signal(signal.SIGINT, previous_handler) + + assert diagnostics.status == "aborted" + + def test_second_ctrl_c_calls_previous_custom_handler( + self, sample_2d_dataarray_spatial, monkeypatch + ): + """Second Ctrl+C delegates to a previous custom handler when one is installed.""" + import SimpleITK as sitk + + previous_handler = signal.getsignal(signal.SIGINT) + calls: list[tuple[int, object]] = [] + + def custom_handler(signum: int, frame: object) -> None: + calls.append((signum, frame)) + + signal.signal(signal.SIGINT, custom_handler) + + def fake_execute(self, fixed, moving): + del self, fixed, moving + handler = signal.getsignal(signal.SIGINT) + assert callable(handler) + handler(signal.SIGINT, None) + handler(signal.SIGINT, None) + return sitk.TranslationTransform(2) + + monkeypatch.setattr(sitk.ImageRegistrationMethod, "Execute", fake_execute) + + try: + _result, _transform, diagnostics = register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="translation", + ) + finally: + signal.signal(signal.SIGINT, previous_handler) + + assert diagnostics.status == "aborted" + assert len(calls) == 1 + assert calls[0][0] == signal.SIGINT + + class TestRegisterVolumeValidation: """Input validation for register_volume.""" From a08909bd5b986535bdb7695ecf0d649ffba9e8d7 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 10:28:31 +0200 Subject: [PATCH 28/72] fix(napari): align plugin header --- src/confusius/_napari/_widget.py | 42 ++++++++++++++------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/confusius/_napari/_widget.py b/src/confusius/_napari/_widget.py index 315b82b1..5eba4c42 100644 --- a/src/confusius/_napari/_widget.py +++ b/src/confusius/_napari/_widget.py @@ -370,8 +370,8 @@ def _make_header(self) -> QWidget: header.setObjectName("confusius_header") layout = QVBoxLayout(header) - layout.setContentsMargins(4, 6, 12, 14) - layout.setSpacing(2) + layout.setContentsMargins(4, 12, 12, 14) + layout.setSpacing(0) tour_btn = QPushButton("Take a Tour") tour_btn.setObjectName("tour_btn") @@ -381,11 +381,11 @@ def _make_header(self) -> QWidget: tour_btn.adjustSize() logo_widget = self._load_logo() - logo_row = QHBoxLayout() - logo_row.setContentsMargins(0, 0, 0, 6) - logo_row.setSpacing(10) + header_row = QHBoxLayout() + header_row.setContentsMargins(0, 0, 0, 6) + header_row.setSpacing(10) if logo_widget is not None: - logo_row.addWidget(logo_widget) + header_row.addWidget(logo_widget, alignment=Qt.AlignmentFlag.AlignTop) title = QLabel("ConfUSIus") title.setObjectName("confusius_title") @@ -395,24 +395,18 @@ def _make_header(self) -> QWidget: subtitle.setObjectName("confusius_subtitle") subtitle.setIndent(0) - tour_btn_title_and_subtitle = QWidget() - tour_btn_title_and_subtitle_layout = QVBoxLayout(tour_btn_title_and_subtitle) - tour_btn_title_and_subtitle_layout.setContentsMargins(0, 0, 0, 0) - tour_btn_title_and_subtitle_layout.setSpacing(0) - - tour_btn_title_and_subtitle_layout.addStretch() - tour_btn_title_and_subtitle_layout.addWidget( - tour_btn, alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight - ) - tour_btn_title_and_subtitle_layout.addWidget( - title, alignment=Qt.AlignmentFlag.AlignLeft - ) - tour_btn_title_and_subtitle_layout.addWidget( - subtitle, alignment=Qt.AlignmentFlag.AlignLeft - ) - - logo_row.addWidget(tour_btn_title_and_subtitle) - layout.addLayout(logo_row) + title_block = QWidget() + title_block_layout = QVBoxLayout(title_block) + title_block_layout.setContentsMargins(0, 4, 0, 0) + title_block_layout.setSpacing(0) + title_block_layout.addWidget(title, alignment=Qt.AlignmentFlag.AlignLeft) + title_block_layout.addWidget(subtitle, alignment=Qt.AlignmentFlag.AlignLeft) + title_block_layout.addStretch() + + header_row.addWidget(title_block, alignment=Qt.AlignmentFlag.AlignTop) + header_row.addStretch() + header_row.addWidget(tour_btn, alignment=Qt.AlignmentFlag.AlignTop) + layout.addLayout(header_row) return header From aff2e57fc5d4734c4c5d0f6b576ca1b7686f6298 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 10:29:32 +0200 Subject: [PATCH 29/72] fix(napari): stabilize docs GUI screenshots --- docs/images/gui/generate.py | 67 +++++++++++-------- src/confusius/_napari/_registration/_panel.py | 40 ++++++++++- .../test_napari/test_registration_panel.py | 11 +++ 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/docs/images/gui/generate.py b/docs/images/gui/generate.py index 69772a3a..0e9ce144 100644 --- a/docs/images/gui/generate.py +++ b/docs/images/gui/generate.py @@ -414,6 +414,19 @@ def _open_accordion(widget, idx: int) -> None: get_qapp().processEvents() +def _open_accordion_panel(widget, title: str): + """Open accordion panel *title* and return its widget. + + This avoids hard-coding panel indices in the screenshot script, which is + brittle when the plugin adds or reorders sections. + """ + for idx, (btn, _) in enumerate(widget._accordion_btns): + if btn.text() == title: + _open_accordion(widget, idx) + return widget._accordion_panels[title] + raise KeyError(f"Accordion panel not found: {title}") + + # --------------------------------------------------------------------------- # 1. Data I/O panel — file loaded, save section visible # --------------------------------------------------------------------------- @@ -462,12 +475,7 @@ def _open_accordion(widget, idx: int) -> None: viewer2.window.add_dock_widget(widget2, name="ConfUSIus", area="right") _qt_sleep(200) - # Open Signals panel (index 2). - _open_accordion(widget2, 2) - - # Retrieve the Signals panel from the accordion container layout. - _container2 = widget2._accordion_btns[0][0].parent() - ts_panel = _container2.layout().itemAt(2 * 2 + 1).widget() + ts_panel = _open_accordion_panel(widget2, "Signals") # Open the bottom dock with the signals plotter. plotter = ts_panel._ensure_plotter() @@ -511,13 +519,7 @@ def _open_accordion(widget, idx: int) -> None: viewer3.window.add_dock_widget(widget3, name="ConfUSIus", area="right") _qt_sleep(200) - # Open QC panel (index 3). - _open_accordion(widget3, 3) - - # Retrieve the QCPanel widget from the accordion container layout. - # Layout interleaves buttons and panels: btn0, panel0, btn1, panel1, … - _container3 = widget3._accordion_btns[0][0].parent() - qc_panel = _container3.layout().itemAt(2 * 3 + 1).widget() + qc_panel = _open_accordion_panel(widget3, "Quality Control") # Select the layer in the QC panel. idx = qc_panel._layer_combo.findText(layer_name) @@ -567,10 +569,7 @@ def _open_accordion(widget, idx: int) -> None: viewer4.window.add_dock_widget(widget4, name="ConfUSIus", area="right") _qt_sleep(200) - # Open Signals panel (index 2). - _open_accordion(widget4, 2) - _container4 = widget4._accordion_btns[0][0].parent() - ts_panel4 = _container4.layout().itemAt(2 * 2 + 1).widget() + ts_panel4 = _open_accordion_panel(widget4, "Signals") layer4 = viewer4.layers[0] shape4 = layer4.data.shape[1:] # (z, y, x) @@ -633,10 +632,7 @@ def _open_accordion(widget, idx: int) -> None: viewer5.window.add_dock_widget(widget5, name="ConfUSIus", area="right") _qt_sleep(200) - # Open Signals panel (index 2). - _open_accordion(widget5, 2) - _container5 = widget5._accordion_btns[0][0].parent() - ts_panel5 = _container5.layout().itemAt(2 * 2 + 1).widget() + ts_panel5 = _open_accordion_panel(widget5, "Signals") layer5 = viewer5.layers[0] shape5 = layer5.data.shape[1:] # (z, y, x) @@ -722,8 +718,13 @@ def _open_accordion(widget, idx: int) -> None: video_panel._load_from_path() _qt_sleep(200) - # Open the Video accordion section (index 1). - _open_accordion(widget6, 1) + video_layer = next( + layer + for layer in viewer6.layers + if layer is not fusi_layer and layer.name.startswith("Video:") + ) + + _open_accordion_panel(widget6, "Video") # Size the window, then refit camera to layers (napari "home" button). win6 = viewer6.window._qt_window @@ -740,18 +741,26 @@ def _open_accordion(widget, idx: int) -> None: N_GIF_FRAMES = 60 GIF_FPS = 12 GIF_WIDTH = 1100 - # Scrub from 2 s to 17 s of scan world time. Use `set_point` (world - # coordinate) instead of `set_current_step` (index), because fUSI and - # video layers have different time scales in the shared grid. - GIF_T_START_S, GIF_T_STOP_S = 2.0, 17.0 - step_times = np.linspace(GIF_T_START_S, GIF_T_STOP_S, N_GIF_FRAMES) + # Scrub inside the actual time overlap between the fUSI and video layers. + # Use `set_point` (world coordinate) instead of `set_current_step` (index), + # because the layers have different time scales in the shared grid. + fusi_min, fusi_max = fusi_layer.extent.world[:, VIDEO_TIME_AXIS] + video_min, video_max = video_layer.extent.world[:, VIDEO_TIME_AXIS] + gif_t_start_s = max(float(fusi_min), float(video_min)) + 0.5 + gif_t_stop_s = min(float(fusi_max), float(video_max)) - 0.5 + if gif_t_stop_s <= gif_t_start_s: + raise RuntimeError("No overlapping fUSI/video time range available for GIF") + step_times = np.linspace(gif_t_start_s, gif_t_stop_s, N_GIF_FRAMES) frames_pil: list = [] for t in step_times: viewer6.dims.set_point(VIDEO_TIME_AXIS, float(t)) get_qapp().processEvents() get_qapp().processEvents() - raw = viewer6.screenshot(canvas_only=False)[..., :3] + raw = viewer6.screenshot(canvas_only=False) + if raw.size == 0: + raise RuntimeError("napari returned an empty screenshot frame") + raw = raw[..., :3] h, w = raw.shape[:2] scale = GIF_WIDTH / w frames_pil.append( diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index d36510f2..904e32f3 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -179,6 +179,22 @@ def _reconstruct_layer_dataarray(layer: "Layer") -> xr.DataArray: return xr.DataArray(data, dims=axis_labels, coords=coords) +def _layer_supports_registration_source(layer: "Layer") -> bool: + """Return whether `layer` can be converted to a registration source. + + ConfUSIus-managed layers carry the original `xarray.DataArray` in metadata. + For plain napari image layers we can reconstruct one from eager NumPy data. + Lazy non-NumPy layers (for example the video panel's frame-on-demand array) + are intentionally excluded: forcing `np.asarray` on them can trigger expensive + decoding or backend errors while the registration panel is merely refreshing. + """ + if layer.metadata.get("xarray") is not None: + return True + if layer.metadata.get("confusius_cached_registration_xarray") is not None: + return True + return isinstance(layer.data, np.ndarray) + + def _get_source_dataarray(layer: "Layer") -> xr.DataArray: """Return the stable source DataArray for a napari layer. @@ -193,17 +209,29 @@ def _get_source_dataarray(layer: "Layer") -> xr.DataArray: Original ConfUSIus DataArray when present in `layer.metadata`, otherwise a cached reconstruction captured before later manual napari transforms mutate the layer pose. + + Raises + ------ + TypeError + If the layer is backed by a lazy non-NumPy array that the registration + panel should ignore. """ existing = layer.metadata.get("xarray") if existing is not None: return cast("xr.DataArray", existing) - cached = layer.metadata.get("confusius_original_xarray") + cached = layer.metadata.get("confusius_cached_registration_xarray") if cached is not None: return cast("xr.DataArray", cached) + if not isinstance(layer.data, np.ndarray): + raise TypeError( + f"Layer {layer.name!r} is not backed by eager NumPy data and cannot be used " + "for registration." + ) + reconstructed = _reconstruct_layer_dataarray(layer) - layer.metadata["confusius_original_xarray"] = reconstructed + layer.metadata["confusius_cached_registration_xarray"] = reconstructed return reconstructed @@ -1403,6 +1431,8 @@ def _sync_manual_transform_event_connections(self) -> None: self._manual_transform_event_layers = [] for layer in self.viewer.layers: + if not _layer_supports_registration_source(cast("Layer", layer)): + continue _get_source_dataarray(cast("Layer", layer)) layer.events.affine.connect(self._refresh_transform_controls) self._manual_transform_event_layers.append(cast("Layer", layer)) @@ -1412,7 +1442,11 @@ def _refresh_layers(self) -> None: moving_name = self._moving_combo.currentText() fixed_name = self._fixed_combo.currentText() - layer_names = [layer.name for layer in self.viewer.layers] + layer_names = [ + layer.name + for layer in self.viewer.layers + if _layer_supports_registration_source(cast("Layer", layer)) + ] self._moving_combo.blockSignals(True) self._fixed_combo.blockSignals(True) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index b626ed49..e15176dc 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -71,6 +71,17 @@ def test_combo_populated_on_layer_add(self, viewer, registration_panel): assert registration_panel._moving_combo.count() == 1 assert registration_panel._moving_combo.itemText(0) == "vol" + def test_ignores_lazy_non_numpy_layers(self, viewer, registration_panel): + import dask.array as da + + viewer.add_image(np.zeros((4, 6, 8)), name="vol") + viewer.add_image(da.zeros((5, 4, 6), chunks=(1, 4, 6)), name="video") + + registration_panel._refresh_layers() + + assert registration_panel._moving_combo.count() == 1 + assert registration_panel._moving_combo.itemText(0) == "vol" + class TestOperationMode: def test_panel_switch_shows_one_subpanel(self, registration_panel): From 9a562e144b44748620d191123cc80eb653c8c3f2 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 10:46:03 +0200 Subject: [PATCH 30/72] test(registration): cover volumewise progress bridge --- .../_napari/_registration/_progress.py | 89 +++++++++---------- .../test_napari/test_registration_progress.py | 73 +++++++++++++++ 2 files changed, 114 insertions(+), 48 deletions(-) diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py index 6d96c830..7159144e 100644 --- a/src/confusius/_napari/_registration/_progress.py +++ b/src/confusius/_napari/_registration/_progress.py @@ -1,37 +1,34 @@ -"""Napari-layer-backed progress reporting for ``register_volume``. +"""Napari-layer-backed progress reporting for `register_volume`. This module provides a progress reporter that mirrors the matplotlib-based -[`RegistrationProgressPlotter`][confusius.registration.RegistrationProgressPlotter] -but streams the intermediate resampled volume into a napari Image layer instead -of a matplotlib figure. +[`RegistrationProgressPlotter`][confusius.registration.RegistrationProgressPlotter] but +streams the intermediate resampled volume into a napari Image layer instead of a +matplotlib figure. The reporter is intentionally split into two pieces so that napari layers can be -constructed and signal slots connected on the GUI thread before the -registration worker thread starts: +constructed and signal slots connected on the GUI thread before the registration worker +thread starts: - [`NapariProgressBridge`][confusius._napari._registration._progress.NapariProgressBridge] - is a lightweight `QObject` that lives on the GUI thread and exposes Qt - signals. The worker thread calls `emit` on it; Qt marshals the slot - invocations back to the GUI thread via an automatically-detected queued - connection. + is a lightweight `QObject` that lives on the GUI thread and exposes Qt signals. The + worker thread calls `emit` on it; Qt marshals the slot invocations back to the GUI + thread via an automatically-detected queued connection. - [`NapariVolumeProgress`][confusius._napari._registration._progress.NapariVolumeProgress] - implements the - [`RegistrationProgress`][confusius.registration.RegistrationProgress] - protocol. It is constructed inside `register_volume` (i.e. on the worker - thread) and resamples the moving image at every iteration using the current - tentative transform, forwarding the resulting array to the bridge. + implements the [`RegistrationProgress`][confusius.registration.RegistrationProgress] + protocol. It is constructed inside `register_volume` (i.e. on the worker thread) and + resamples the moving image at every iteration using the current tentative transform, + forwarding the resulting array to the bridge. Connection lifecycle: -1. The panel constructs a `NapariProgressBridge` on the GUI thread and connects - its `iterated` signal to a slot that writes the array into the resampled - napari layer. +1. The panel constructs a `NapariProgressBridge` on the GUI thread and connects its + `iterated` signal to a slot that writes the array into the resampled napari layer. 2. The panel builds a factory (via - [`make_napari_progress_factory`][confusius._napari._registration._progress.make_napari_progress_factory]) - that closes over the bridge and returns a `NapariVolumeProgress` instance - when called by `register_volume`. -3. `register_volume` instantiates the progress inside the worker thread and - wires it to SimpleITK's iteration and end events as usual. + [`make_napari_progress_factory`][confusius._napari._registration._progress.make_napari_progress_factory]) + that closes over the bridge and returns a `NapariVolumeProgress` instance when called + by `register_volume`. +3. `register_volume` instantiates the progress inside the worker thread and wires it to + SimpleITK's iteration and end events as usual. """ from __future__ import annotations @@ -54,12 +51,11 @@ class NapariProgressBridge(QObject): """Thread-boundary signal bridge for napari registration progress. - Construct this on the GUI thread before starting the registration worker. - Connect `iterated` to a slot that mutates a napari layer (e.g. writes - `layer.data = arr`); the slot will be invoked on the GUI thread thanks to - Qt's automatic cross-thread connection. The bridge itself never touches the - napari layer, keeping a clean separation between the worker's data path and - the GUI update path. + Construct this on the GUI thread before starting the registration worker. Connect + `iterated` to a slot that mutates a napari layer (e.g. writes `layer.data = arr`); + the slot will be invoked on the GUI thread thanks to Qt's automatic cross-thread + connection. The bridge itself never touches the napari layer, keeping a clean + separation between the worker's data path and the GUI update path. See Also -------- @@ -67,32 +63,31 @@ class NapariProgressBridge(QObject): """ iterated = Signal(object) - """:pyqtSignal: Emitted at every optimizer iteration with the resampled - moving image as a numpy array in numpy axis order (matching `fixed`).""" + """Emitted at every optimizer iteration with the resampled moving image as a numpy + array in numpy axis order (matching `fixed`).""" metric_updated = Signal(float) - """:pyqtSignal: Emitted at every optimizer iteration with the current - optimizer metric value (a float).""" + """Emitted at every optimizer iteration with the current optimizer metric value (a + float).""" finished = Signal() - """:pyqtSignal: Emitted once when the registration end event fires.""" + """Emitted once when the registration end event fires.""" class NapariVolumeProgress: """Napari-layer progress reporter for `register_volume`. - Implements the - [`RegistrationProgress`][confusius.registration.RegistrationProgress] - protocol. Stores the registration method and SimpleITK images it needs to - resample the moving image at each iteration. The resampled array is - forwarded to the bridge via a Qt signal, so this object is safe to call - from the SimpleITK command callback running on the worker thread. + Implements the [`RegistrationProgress`][confusius.registration.RegistrationProgress] + protocol. Stores the registration method and SimpleITK images it needs to resample + the moving image at each iteration. The resampled array is forwarded to the bridge + via a Qt signal, so this object is safe to call from the SimpleITK command callback + running on the worker thread. Parameters ---------- bridge : NapariProgressBridge - GUI-thread signal bridge. Stored by reference; never accessed for GUI - APIs from this object. + GUI-thread signal bridge. Stored by reference; never accessed for GUI APIs from + this object. registration_method : SimpleITK.ImageRegistrationMethod Active registration method whose `GetInitialTransform` is used to resample the moving image at each iteration. @@ -175,16 +170,14 @@ class NapariVolumewiseProgressBridge(QObject): """Thread-boundary signal bridge for volumewise registration progress.""" frame_progress = Signal(int, int) - """:pyqtSignal: Emitted with `(completed_frames, total_frames)`. - """ + """Emitted with `(completed_frames, total_frames)`.""" frame_completed = Signal(int, object) - """:pyqtSignal: Emitted with `(frame_index, registered_frame_array)` when one - frame finishes. - """ + """Emitted with `(frame_index, registered_frame_array)` when one frame + finishes.""" finished = Signal() - """:pyqtSignal: Emitted once when the volumewise run ends.""" + """Emitted once when the volumewise run ends.""" class NapariVolumewiseProgress: diff --git a/tests/unit/test_napari/test_registration_progress.py b/tests/unit/test_napari/test_registration_progress.py index f73e341b..6ff619f9 100644 --- a/tests/unit/test_napari/test_registration_progress.py +++ b/tests/unit/test_napari/test_registration_progress.py @@ -11,6 +11,8 @@ from confusius._napari._registration._progress import ( NapariProgressBridge, NapariVolumeProgress, + NapariVolumewiseProgress, + NapariVolumewiseProgressBridge, make_napari_progress_factory, ) @@ -173,6 +175,77 @@ def test_close_emits_finished_signal(self, qtbot, fixed_img_2d, moving_img_2d): reporter.close() +class TestNapariVolumewiseProgressBridge: + """Signal bridge behaviour for volumewise registration.""" + + def test_frame_progress_signal_is_emitted(self, qtbot): + bridge = NapariVolumewiseProgressBridge() + payloads: list[tuple[int, int]] = [] + bridge.frame_progress.connect(lambda completed, total: payloads.append((completed, total))) + + with qtbot.waitSignal(bridge.frame_progress, timeout=1000): + bridge.frame_progress.emit(1, 3) + + assert payloads == [(1, 3)] + + def test_frame_completed_signal_is_emitted(self, qtbot): + bridge = NapariVolumewiseProgressBridge() + payloads: list[tuple[int, np.ndarray]] = [] + bridge.frame_completed.connect( + lambda index, array: payloads.append((index, array)) + ) + expected = np.ones((2, 2), dtype=np.float32) + + with qtbot.waitSignal(bridge.frame_completed, timeout=1000): + bridge.frame_completed.emit(2, expected) + + assert len(payloads) == 1 + assert payloads[0][0] == 2 + np.testing.assert_array_equal(payloads[0][1], expected) + + def test_finished_signal_is_emitted(self, qtbot): + bridge = NapariVolumewiseProgressBridge() + with qtbot.waitSignal(bridge.finished, timeout=1000): + bridge.finished.emit() + + +class TestNapariVolumewiseProgress: + """Aggregate per-frame progress for volumewise registration.""" + + def test_frame_completed_emits_progress_and_array(self, qtbot): + import xarray as xr + + bridge = NapariVolumewiseProgressBridge() + reporter = NapariVolumewiseProgress(bridge, n_frames=3) + progress_payloads: list[tuple[int, int]] = [] + frame_payloads: list[tuple[int, np.ndarray]] = [] + bridge.frame_progress.connect( + lambda completed, total: progress_payloads.append((completed, total)) + ) + bridge.frame_completed.connect( + lambda index, array: frame_payloads.append((index, array)) + ) + frame = xr.DataArray(np.ones((2, 2), dtype=np.float32), dims=("y", "x")) + diagnostics = object() + + with qtbot.waitSignals( + [bridge.frame_progress, bridge.frame_completed], timeout=1000 + ): + reporter.frame_completed(1, frame, diagnostics) # type: ignore[arg-type] + + assert progress_payloads == [(1, 3)] + assert len(frame_payloads) == 1 + assert frame_payloads[0][0] == 1 + np.testing.assert_array_equal(frame_payloads[0][1], frame.values) + + def test_close_emits_finished_signal(self, qtbot): + bridge = NapariVolumewiseProgressBridge() + reporter = NapariVolumewiseProgress(bridge, n_frames=3) + + with qtbot.waitSignal(bridge.finished, timeout=1000): + reporter.close() + + class TestMakeNapariProgressFactory: """Factory closure behaviour.""" From a2ac59cb376cda5eea10b1db33f147a01ff3e36c Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 13:03:42 +0200 Subject: [PATCH 31/72] refactor(registration): simplify napari progress flags --- .../_napari/_registration/_progress.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py index 7159144e..8d42012d 100644 --- a/src/confusius/_napari/_registration/_progress.py +++ b/src/confusius/_napari/_registration/_progress.py @@ -89,18 +89,18 @@ class NapariVolumeProgress: GUI-thread signal bridge. Stored by reference; never accessed for GUI APIs from this object. registration_method : SimpleITK.ImageRegistrationMethod - Active registration method whose `GetInitialTransform` is used to - resample the moving image at each iteration. + Active registration method whose `GetInitialTransform` is used to resample the + moving image at each iteration. fixed_img : SimpleITK.Image Fixed image defining the resample grid. moving_img : SimpleITK.Image Moving image to resample. plot_metric : bool, default: True - Currently unused by the napari path; kept for signature compatibility - with the matplotlib plotter factory. + Whether to emit `metric_updated` on each iteration. Kept aligned with the + matplotlib plotter factory signature. plot_composite : bool, default: True - Currently unused by the napari path (the resampled layer *is* the - composite view); kept for signature compatibility. + Kept for signature compatibility with the matplotlib plotter factory. The + napari preview always shows the resampled moving image directly. resample_kwargs : dict, optional Extra keyword arguments forwarded to [`_resample_intermediate`][confusius.registration._progress._resample_intermediate]. @@ -123,10 +123,8 @@ def __init__( self._fixed_img = fixed_img self._moving_img = moving_img self._resample_kwargs = dict(resample_kwargs or {}) - # The resampled layer acts as the composite view; the matplotlib-style - # composite overlay is always implied, regardless of plot_composite. self._plot_metric = plot_metric - self._plot_composite = plot_composite + del plot_composite def update(self) -> None: """Resample the moving image with the current transform and emit it. @@ -279,9 +277,9 @@ def factory( moving_img : SimpleITK.Image Moving image to resample. plot_metric : bool, default: True - Unused by the napari path; kept for signature compatibility. + Whether to emit `metric_updated` on each iteration. plot_composite : bool, default: True - Unused by the napari path; kept for signature compatibility. + Kept for signature compatibility with the matplotlib plotter factory. resample_kwargs : dict, optional Extra keyword arguments forwarded to [`_resample_intermediate`][confusius.registration._progress._resample_intermediate]. From ea9144a2314b660381835ae11f49ab815a9a562c Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 13:03:43 +0200 Subject: [PATCH 32/72] refactor(registration): rename bspline transform metadata --- .../_napari/_registration/_transforms.py | 8 +++-- src/confusius/registration/bspline.py | 30 +++++++++++-------- .../test_napari/test_registration_panel.py | 2 +- tests/unit/test_registration/test_volume.py | 4 +-- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/confusius/_napari/_registration/_transforms.py b/src/confusius/_napari/_registration/_transforms.py index f9819a97..c6039f08 100644 --- a/src/confusius/_napari/_registration/_transforms.py +++ b/src/confusius/_napari/_registration/_transforms.py @@ -515,10 +515,12 @@ def load_affine_transform_payload(path: str | Path) -> AffineTransformPayload: def _validate_bspline_dataarray(da: xr.DataArray) -> None: """Raise ValueError if *da* does not look like a valid B-spline transform.""" - if da.attrs.get("type") != "bspline_transform": + transform_type = da.attrs.get("transform_type") + if transform_type != "bspline_transform": raise ValueError( - f"Expected a DataArray with attrs['type'] == 'bspline_transform'; " - f"got {da.attrs.get('type')!r}." + "Expected a DataArray with attrs['transform_type'] == " + "'bspline_transform'; " + f"got {transform_type!r}." ) for key in ("order", "direction"): if key not in da.attrs: diff --git a/src/confusius/registration/bspline.py b/src/confusius/registration/bspline.py index 651752bf..9a43040d 100644 --- a/src/confusius/registration/bspline.py +++ b/src/confusius/registration/bspline.py @@ -8,13 +8,13 @@ ```python { - "type": "bspline_transform", - "order": 3, # B-spline polynomial order - "direction": [[...], [...], [...]], # (ndim, ndim) direction cosine matrix - "affines": { - "bspline_initialization": [[...]] # optional (N+1, N+1) pre-affine; - # only present when register_volume - # was called with affine initialization. + "transform_type": "bspline_transform", + "order": 3, # B-spline polynomial order + "direction": [[...], [...], [...]], # (ndim, ndim) direction cosine matrix + "affines": { + "bspline_initialization": [[...]] # optional (N+1, N+1) pre-affine; + # only present when register_volume + # was called with affine initialization. } } ``` @@ -62,7 +62,8 @@ def sitk_bspline_to_dataarray( Returns ------- xarray.DataArray - B-spline control-point DataArray with `attrs["type"] == "bspline_transform"`. + B-spline control-point DataArray with + `attrs["transform_type"] == "bspline_transform"`. Raises ------ @@ -106,7 +107,7 @@ def sitk_bspline_to_dataarray( coords[dim] = origin[i] + np.arange(grid_shape[i]) * spacing[i] attrs: dict[str, object] = { - "type": "bspline_transform", + "transform_type": "bspline_transform", "order": order, "direction": direction.tolist(), } @@ -250,12 +251,15 @@ def _validate_bspline_dataarray(da: xr.DataArray) -> None: Raises ------ ValueError - If `da.attrs["type"] != "bspline_transform"` or required attrs are missing. + If `da.attrs["transform_type"] != "bspline_transform"` or required attrs are + missing. """ - if da.attrs.get("type") != "bspline_transform": + transform_type = da.attrs.get("transform_type") + if transform_type != "bspline_transform": raise ValueError( - f"Expected a DataArray with attrs['type'] == 'bspline_transform'; " - f"got {da.attrs.get('type')!r}." + "Expected a DataArray with attrs['transform_type'] == " + "'bspline_transform'; " + f"got {transform_type!r}." ) for key in ("order", "direction"): if key not in da.attrs: diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index e15176dc..83ced7ef 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -56,7 +56,7 @@ def _make_bspline_transform() -> xr.DataArray: "x": xr.DataArray(np.arange(4) * 0.1, dims=["x"]), }, attrs={ - "type": "bspline_transform", + "transform_type": "bspline_transform", "order": 3, "direction": [[1.0, 0.0], [0.0, 1.0]], "affines": {"bspline_initialization": np.eye(3).tolist()}, diff --git a/tests/unit/test_registration/test_volume.py b/tests/unit/test_registration/test_volume.py index 36792676..e9ac9123 100644 --- a/tests/unit/test_registration/test_volume.py +++ b/tests/unit/test_registration/test_volume.py @@ -253,7 +253,7 @@ def test_bspline_returns_dataarray_transform(self, sample_2d_dataarray_spatial): transform_type="bspline", ) assert isinstance(bspline_tx, xr.DataArray) - assert bspline_tx.attrs.get("type") == "bspline_transform" + assert bspline_tx.attrs.get("transform_type") == "bspline_transform" assert bspline_tx.dims[0] == "component" def test_resample_true_coords_match_fixed( @@ -1129,7 +1129,7 @@ def test_bspline_abort_returns_initial_bspline_transform( # The returned DataArray wraps the initial (unoptimised) bspline — its # coefficients differ from a real registration only in that no iterations ran. assert isinstance(transform, xr.DataArray) - assert transform.attrs.get("type") == "bspline_transform" + assert transform.attrs.get("transform_type") == "bspline_transform" def test_affine_initialization_abort_returns_initialization_affine( self, sample_2d_dataarray_spatial From 6da3fb8ef0c06bf7b20c70fd4a751b1c56b19c46 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 14:13:46 +0200 Subject: [PATCH 33/72] refactor(registration): simplify progress api --- src/confusius/registration/volume.py | 64 +++++++++------------ src/confusius/registration/volumewise.py | 20 +++---- src/confusius/xarray/registration.py | 12 ++-- tests/unit/test_registration/test_volume.py | 26 --------- 4 files changed, 43 insertions(+), 79 deletions(-) diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index 59d3d4ca..53c1f83b 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -20,9 +20,10 @@ from confusius.registration.diagnostics import RegistrationDiagnostics if TYPE_CHECKING: - import SimpleITK as sitk from threading import Event + import SimpleITK as sitk + from confusius.registration._progress import RegistrationProgress @@ -268,14 +269,13 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 smoothing_sigmas: Sequence[int] = ..., resample: bool = ..., resample_interpolation: Literal["linear", "bspline"] = ..., + fill_value: float | None = ..., sitk_threads: int = ..., show_progress: bool = ..., - progress_plotter: "Callable[..., RegistrationProgress] | None" = None, plot_metric: bool = ..., plot_composite: bool = ..., - fill_value: float | None = ..., + progress_plotter: "Callable[..., RegistrationProgress] | None" = None, abort_event: "Event | None" = ..., - iteration_callback: Callable[[int, float], None] | None = ..., ) -> "tuple[xr.DataArray, npt.NDArray[np.floating], RegistrationDiagnostics]": """Overload for linear transforms (translation/rigid/affine).""" ... @@ -305,14 +305,13 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 smoothing_sigmas: Sequence[int] = ..., resample: bool = ..., resample_interpolation: Literal["linear", "bspline"] = ..., + fill_value: float | None = ..., sitk_threads: int = ..., show_progress: bool = ..., - progress_plotter: "Callable[..., RegistrationProgress] | None" = None, plot_metric: bool = ..., plot_composite: bool = ..., - fill_value: float | None = ..., + progress_plotter: "Callable[..., RegistrationProgress] | None" = None, abort_event: "Event | None" = ..., - iteration_callback: Callable[[int, float], None] | None = ..., ) -> "tuple[xr.DataArray, xr.DataArray, RegistrationDiagnostics]": """Overload for bspline transform (returns DataArray transform).""" ... @@ -341,14 +340,13 @@ def register_volume( # numpydoc ignore=GL08,PR01,RT01 smoothing_sigmas: Sequence[int] = ..., resample: bool = ..., resample_interpolation: Literal["linear", "bspline"] = ..., + fill_value: float | None = ..., sitk_threads: int = ..., show_progress: bool = ..., - progress_plotter: "Callable[..., RegistrationProgress] | None" = None, plot_metric: bool = ..., plot_composite: bool = ..., - fill_value: float | None = ..., + progress_plotter: "Callable[..., RegistrationProgress] | None" = None, abort_event: "Event | None" = ..., - iteration_callback: Callable[[int, float], None] | None = ..., ) -> "tuple[xr.DataArray, npt.NDArray[np.floating], RegistrationDiagnostics]": """Overload for default transform (rigid, returns affine).""" ... @@ -377,14 +375,13 @@ def register_volume( smoothing_sigmas: Sequence[int] = (6, 2, 1), resample: bool = True, resample_interpolation: Literal["linear", "bspline"] = "linear", + fill_value: float | None = None, sitk_threads: int = -1, show_progress: bool = False, - progress_plotter: "Callable[..., RegistrationProgress] | None" = None, plot_metric: bool = True, plot_composite: bool = True, - fill_value: float | None = None, + progress_plotter: "Callable[..., RegistrationProgress] | None" = None, abort_event: "Event | None" = None, - iteration_callback: Callable[[int, float], None] | None = None, ) -> "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics]": # noqa: E501 """Register a single 2D or 3D volume to a fixed reference. @@ -483,6 +480,13 @@ def register_volume( `"linear"` is fast and appropriate for most cases. `"bspline"` (3rd-order B-spline) produces smoother results and reduces ringing, useful for atlas registration. Only used when `resample=True`. + fill_value : float, optional + Fill value for voxels that fall outside the moving image's field of view after + resampling. Applied to both the final registered output (when `resample=True`) + and the progress composite overlay (when `show_progress=True` and + `plot_composite=True`). If not provided, defaults to the minimum value of + `moving`, which renders out-of-FOV regions as background regardless of intensity + scale (important for dB data where 0 is maximum intensity). sitk_threads : int, default: -1 Number of threads SimpleITK may use internally. Negative values resolve to `max(1, os.cpu_count() + 1 + sitk_threads)`, so `-1` means all CPUs, `-2` @@ -493,6 +497,13 @@ def register_volume( Whether to display a live progress plot during registration. The plot is shown in a Jupyter notebook or in an interactive matplotlib window depending on the active backend. + plot_metric : bool, default: True + Whether to include the optimizer metric curve in the progress plot. Ignored when + `show_progress=False`. + plot_composite : bool, default: True + Whether to include a fixed/moving composite overlay in the progress plot. + Requires resampling the moving image at every iteration. Ignored when + `show_progress=False`. progress_plotter : callable, optional Factory that builds the progress reporter, called inside `register_volume` as `progress_plotter(registration_method, fixed_img, moving_img, *, @@ -501,32 +512,13 @@ def register_volume( [`RegistrationProgress`][confusius.registration.RegistrationProgress] protocol (`update()` / `close()`). If not provided, defaults to [`RegistrationProgressPlotter`][confusius.registration.RegistrationProgressPlotter] - (matplotlib). Ignored when `show_progress=False`. Custom factories are - expected to be safe to call from a non-GUI thread; GUI side effects must - be marshalled via thread-safe primitives such as Qt signals. - plot_metric : bool, default: True - Whether to include the optimizer metric curve in the progress plot. Ignored when - `show_progress=False`. - plot_composite : bool, default: True - Whether to include a fixed/moving composite overlay in the progress plot. - Requires resampling the moving image at every iteration. Ignored when - `show_progress=False`. - fill_value : float, optional - Fill value for voxels that fall outside the moving image's field of view after - resampling. Applied to both the final registered output (when `resample=True`) - and the progress composite overlay (when `show_progress=True` and - `plot_composite=True`). If not provided, defaults to the minimum value of - `moving`, which renders out-of-FOV regions as background regardless of intensity - scale (important for dB data where 0 is maximum intensity). + (matplotlib). Ignored when `show_progress=False`. Custom factories are expected + to be safe to call from a non-GUI thread; GUI side effects must be marshalled + via thread-safe primitives such as Qt signals. abort_event : threading.Event, optional Cooperative cancellation flag. If set before or during optimisation, the registration stops at the next SimpleITK iteration boundary and returns the current intermediate result with `diagnostics.status="aborted"`. - iteration_callback : callable, optional - Callback invoked at every optimizer iteration as - `iteration_callback(iteration, metric_value)`, where `iteration` is - 1-indexed. Useful for higher-level progress aggregation such as - `register_volumewise`. Returns ------- @@ -746,8 +738,6 @@ def register_volume( def _record_iteration() -> None: metric_value = float(registration.GetMetricValue()) metric_values.append(metric_value) - if iteration_callback is not None: - iteration_callback(len(metric_values), metric_value) needs_fill_value = resample or (show_progress and plot_composite) _fill_value = ( diff --git a/src/confusius/registration/volumewise.py b/src/confusius/registration/volumewise.py index 539f885e..8f50b808 100644 --- a/src/confusius/registration/volumewise.py +++ b/src/confusius/registration/volumewise.py @@ -40,9 +40,9 @@ def register_volumewise( smoothing_sigmas: Sequence[int] = (6, 2, 1), resample_interpolation: Literal["linear", "bspline"] = "linear", show_progress: bool = True, - keep_diagnostics: bool = False, abort_event: "Event | None" = None, progress_reporter: "VolumewiseProgressReporter | None" = None, + keep_diagnostics: bool = False, ) -> xr.DataArray: """Register all volumes in a fUSI recording to a reference volume. @@ -119,15 +119,6 @@ def register_volumewise( cost of speed. show_progress : bool, default: True Whether to display a progress bar while registering volumes. - keep_diagnostics : bool, default: False - Whether to keep the full per-frame - [`RegistrationDiagnostics`][confusius.registration.RegistrationDiagnostics] - list on the returned DataArray under - `attrs["registration_diagnostics"]`. Disabled by default because each - diagnostics object carries the full optimizer metric trace, which adds - up over long recordings. The cheap per-frame summaries - (`final_metric_value`, `n_iterations`) are always added to - `motion_params` regardless of this flag. abort_event : threading.Event, optional Cooperative cancellation flag shared across frames. If set before or during execution, in-flight frame registrations stop at the next optimiser iteration @@ -138,6 +129,15 @@ def register_volumewise( Thread-safe reporter notified whenever one frame completes. Useful for GUI progress bars or progressively filling an output layer while frames finish. + keep_diagnostics : bool, default: False + Whether to keep the full per-frame + [`RegistrationDiagnostics`][confusius.registration.RegistrationDiagnostics] + list on the returned DataArray under + `attrs["registration_diagnostics"]`. Disabled by default because each + diagnostics object carries the full optimizer metric trace, which adds + up over long recordings. The cheap per-frame summaries + (`final_metric_value`, `n_iterations`) are always added to + `motion_params` regardless of this flag. Returns ------- diff --git a/src/confusius/xarray/registration.py b/src/confusius/xarray/registration.py index 42ace582..7176dd9e 100644 --- a/src/confusius/xarray/registration.py +++ b/src/confusius/xarray/registration.py @@ -51,10 +51,10 @@ def to_volume( smoothing_sigmas: Sequence[int] = (6, 2, 1), resample: bool = False, resample_interpolation: Literal["linear", "bspline"] = "linear", + fill_value: float | None = None, show_progress: bool = False, plot_metric: bool = True, plot_composite: bool = True, - fill_value: float | None = None, ) -> "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray | None, RegistrationDiagnostics]": # noqa: E501 """Register this volume to a fixed reference volume. @@ -115,6 +115,10 @@ def to_volume( estimated and the moving volume is returned unchanged. resample_interpolation : {"linear", "bspline"}, default: "linear" Interpolation method used for the final resample step. + fill_value : float, optional + Fill value for voxels outside the moving image's field of view after + resampling. If not provided, defaults to the minimum of the moving + image. See [`register_volume`][confusius.registration.register_volume]. show_progress : bool, default: False Whether to display a live progress plot during registration. plot_metric : bool, default: True @@ -123,10 +127,6 @@ def to_volume( plot_composite : bool, default: True Whether to include a fixed/moving composite overlay in the progress plot. Ignored when `show_progress=False`. - fill_value : float, optional - Fill value for voxels outside the moving image's field of view after - resampling. If not provided, defaults to the minimum of the moving - image. See [`register_volume`][confusius.registration.register_volume]. Returns ------- @@ -166,10 +166,10 @@ def to_volume( smoothing_sigmas=smoothing_sigmas, resample=resample, resample_interpolation=resample_interpolation, + fill_value=fill_value, show_progress=show_progress, plot_metric=plot_metric, plot_composite=plot_composite, - fill_value=fill_value, ) def volumewise( diff --git a/tests/unit/test_registration/test_volume.py b/tests/unit/test_registration/test_volume.py index e9ac9123..69a054f4 100644 --- a/tests/unit/test_registration/test_volume.py +++ b/tests/unit/test_registration/test_volume.py @@ -1077,32 +1077,6 @@ def test_default_fill_value_is_moving_min(self): ) -class TestRegisterVolumeIterationCallback: - """The `iteration_callback` is invoked at every optimizer iteration.""" - - def test_callback_receives_one_indexed_iteration_and_metric( - self, sample_2d_dataarray_spatial - ): - """Each callback call is (iteration, metric) with iteration starting at 1.""" - calls: list[tuple[int, float]] = [] - - def callback(iteration: int, metric: float) -> None: - calls.append((iteration, metric)) - - register_volume( - sample_2d_dataarray_spatial, - sample_2d_dataarray_spatial, - transform_type="translation", - number_of_iterations=5, - iteration_callback=callback, - ) - - assert len(calls) == 5 - assert [c[0] for c in calls] == [1, 2, 3, 4, 5] - for _, metric in calls: - assert np.isfinite(metric) - - class TestRegisterVolumePreSetAbort: """Pre-set abort_event short-circuits before SimpleITK Execute is called.""" From 8b29a9ad88d52232d92f04a0eb697f78b8e2e3a6 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 14:17:00 +0200 Subject: [PATCH 34/72] refactor(registration): rename progress plotters --- src/confusius/_napari/_registration/_panel.py | 18 +++++----- .../_napari/_registration/_progress.py | 32 ++++++++--------- src/confusius/registration/__init__.py | 4 +-- src/confusius/registration/_progress.py | 2 +- src/confusius/registration/volume.py | 6 ++-- src/confusius/registration/volumewise.py | 5 ++- .../test_napari/test_registration_progress.py | 36 +++++++++---------- tests/unit/test_registration/test_progress.py | 32 ++++++++--------- 8 files changed, 67 insertions(+), 68 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 904e32f3..dff15309 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -44,8 +44,8 @@ ) from confusius._napari._registration._progress import ( NapariProgressBridge, - NapariVolumewiseProgress, - NapariVolumewiseProgressBridge, + NapariRegistrationProgressReporter, + NapariRegistrationProgressReporterBridge, make_napari_progress_factory, ) from confusius._napari._registration._transforms import ( @@ -693,7 +693,7 @@ def _run_register_volumewise( smoothing_sigmas: Sequence[int] = (6, 2, 1), keep_diagnostics: bool = False, abort_event: Event | None = None, - progress_reporter: NapariVolumewiseProgress | None = None, + progress_reporter: NapariRegistrationProgressReporter | None = None, ) -> xr.DataArray: """Run `register_volumewise` from the GUI. @@ -733,7 +733,7 @@ def _run_register_volumewise( Store detailed optimization diagnostics. abort_event : threading.Event, optional Cooperative cancellation flag forwarded to `register_volumewise`. - progress_reporter : NapariVolumewiseProgress, optional + progress_reporter : NapariRegistrationProgressReporter, optional GUI-thread bridge-backed reporter forwarded to `register_volumewise`. Returns @@ -785,7 +785,9 @@ def __init__(self, viewer: napari.Viewer) -> None: self._progress_fixed_layer: Image | None = None self._progress_moving_layer: Image | None = None self._manual_transform_event_layers: list[Layer] = [] - self._volumewise_progress_bridge: NapariVolumewiseProgressBridge | None = None + self._volumewise_progress_bridge: ( + NapariRegistrationProgressReporterBridge | None + ) = None self._volumewise_progress_layer: Image | None = None self._volumewise_moving_preview_layer: Image | None = None self._volumewise_progress_time_axis: int | None = None @@ -2181,7 +2183,7 @@ def _setup_volumewise_progress( moving: xr.DataArray, layer_name: str, scale_mode: str = "off", - ) -> NapariVolumewiseProgress: + ) -> NapariRegistrationProgressReporter: """Create a progress bridge and output layer for volumewise registration.""" self._teardown_volumewise_progress(remove_layer=True) @@ -2245,7 +2247,7 @@ def _setup_volumewise_progress( contrast_limits=contrast_limits, **display_kwargs, ) - bridge = NapariVolumewiseProgressBridge() + bridge = NapariRegistrationProgressReporterBridge() bridge.frame_progress.connect(self._update_volumewise_progress_bar) bridge.frame_completed.connect(self._update_volumewise_progress_frame) @@ -2256,7 +2258,7 @@ def _setup_volumewise_progress( self._volumewise_progress_total = moving.sizes[TIME_DIM] self._progress.setRange(0, self._volumewise_progress_total) self._progress.setValue(0) - return NapariVolumewiseProgress( + return NapariRegistrationProgressReporter( bridge, n_frames=moving.sizes[TIME_DIM], ) diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py index 8d42012d..e2c2e20b 100644 --- a/src/confusius/_napari/_registration/_progress.py +++ b/src/confusius/_napari/_registration/_progress.py @@ -1,7 +1,7 @@ """Napari-layer-backed progress reporting for `register_volume`. This module provides a progress reporter that mirrors the matplotlib-based -[`RegistrationProgressPlotter`][confusius.registration.RegistrationProgressPlotter] but +[`MatplotlibRegistrationProgressPlotter`][confusius.registration.MatplotlibRegistrationProgressPlotter] but streams the intermediate resampled volume into a napari Image layer instead of a matplotlib figure. @@ -13,7 +13,7 @@ is a lightweight `QObject` that lives on the GUI thread and exposes Qt signals. The worker thread calls `emit` on it; Qt marshals the slot invocations back to the GUI thread via an automatically-detected queued connection. -- [`NapariVolumeProgress`][confusius._napari._registration._progress.NapariVolumeProgress] +- [`NapariRegistrationProgressPlotter`][confusius._napari._registration._progress.NapariRegistrationProgressPlotter] implements the [`RegistrationProgress`][confusius.registration.RegistrationProgress] protocol. It is constructed inside `register_volume` (i.e. on the worker thread) and resamples the moving image at every iteration using the current tentative transform, @@ -25,7 +25,7 @@ `iterated` signal to a slot that writes the array into the resampled napari layer. 2. The panel builds a factory (via [`make_napari_progress_factory`][confusius._napari._registration._progress.make_napari_progress_factory]) - that closes over the bridge and returns a `NapariVolumeProgress` instance when called + that closes over the bridge and returns a `NapariRegistrationProgressPlotter` instance when called by `register_volume`. 3. `register_volume` instantiates the progress inside the worker thread and wires it to SimpleITK's iteration and end events as usual. @@ -59,7 +59,7 @@ class NapariProgressBridge(QObject): See Also -------- - NapariVolumeProgress : Worker-side reporter that emits via this bridge. + NapariRegistrationProgressPlotter : Worker-side reporter that emits via this bridge. """ iterated = Signal(object) @@ -74,7 +74,7 @@ class NapariProgressBridge(QObject): """Emitted once when the registration end event fires.""" -class NapariVolumeProgress: +class NapariRegistrationProgressPlotter: """Napari-layer progress reporter for `register_volume`. Implements the [`RegistrationProgress`][confusius.registration.RegistrationProgress] @@ -164,7 +164,7 @@ def close(self) -> None: self._bridge.finished.emit() -class NapariVolumewiseProgressBridge(QObject): +class NapariRegistrationProgressReporterBridge(QObject): """Thread-boundary signal bridge for volumewise registration progress.""" frame_progress = Signal(int, int) @@ -178,12 +178,12 @@ class NapariVolumewiseProgressBridge(QObject): """Emitted once when the volumewise run ends.""" -class NapariVolumewiseProgress: +class NapariRegistrationProgressReporter: """Aggregate per-frame progress for `register_volumewise` on the GUI thread. Parameters ---------- - bridge : NapariVolumewiseProgressBridge + bridge : NapariRegistrationProgressReporterBridge GUI-thread signal bridge used to forward progress updates. n_frames : int Number of frames that will be registered. @@ -191,7 +191,7 @@ class NapariVolumewiseProgress: def __init__( self, - bridge: NapariVolumewiseProgressBridge, + bridge: NapariRegistrationProgressReporterBridge, *, n_frames: int, ) -> None: @@ -238,10 +238,9 @@ def make_napari_progress_factory( """Return a progress-plotter factory bound to a bridge. The returned callable has the signature expected by `register_volume`'s - `progress_plotter` argument — it accepts - `(registration_method, fixed_img, moving_img, *, plot_metric, - plot_composite, resample_kwargs)` and returns a - [`NapariVolumeProgress`][confusius._napari._registration._progress.NapariVolumeProgress] + `progress_plotter` argument—it accepts `(registration_method, fixed_img, moving_img, + *, plot_metric, plot_composite, resample_kwargs)` and returns a + [`NapariRegistrationProgressPlotter`][confusius._napari._registration._progress.NapariRegistrationProgressPlotter] instance wrapping `bridge`. Parameters @@ -265,13 +264,12 @@ def factory( plot_composite: bool = True, resample_kwargs: dict[str, Any] | None = None, ) -> "RegistrationProgress": - """Build a NapariVolumeProgress wrapping the captured bridge. + """Build a NapariRegistrationProgressPlotter wrapping the captured bridge. Parameters ---------- registration_method : SimpleITK.ImageRegistrationMethod - Active registration method whose transform is sampled at every - iteration. + Active registration method whose transform is sampled at every iteration. fixed_img : SimpleITK.Image Fixed reference image defining the resample grid. moving_img : SimpleITK.Image @@ -290,7 +288,7 @@ def factory( Progress reporter ready to be wired to SimpleITK's iteration and end events by `register_volume`. """ - return NapariVolumeProgress( + return NapariRegistrationProgressPlotter( bridge, registration_method, fixed_img, diff --git a/src/confusius/registration/__init__.py b/src/confusius/registration/__init__.py index ea5d2626..e0c19e4b 100644 --- a/src/confusius/registration/__init__.py +++ b/src/confusius/registration/__init__.py @@ -2,7 +2,7 @@ from confusius.registration._progress import ( RegistrationProgress, - RegistrationProgressPlotter, + MatplotlibRegistrationProgressPlotter, ) from confusius.registration.affines import ( compose_affine, @@ -27,7 +27,7 @@ "RegistrationAbortedError", "RegistrationDiagnostics", "RegistrationProgress", - "RegistrationProgressPlotter", + "MatplotlibRegistrationProgressPlotter", "compose_affine", "decompose_affine", "register_volume", diff --git a/src/confusius/registration/_progress.py b/src/confusius/registration/_progress.py index 2054178b..6d30b7e9 100644 --- a/src/confusius/registration/_progress.py +++ b/src/confusius/registration/_progress.py @@ -122,7 +122,7 @@ def close(self) -> None: ... -class RegistrationProgressPlotter: +class MatplotlibRegistrationProgressPlotter: """Plot registration progress in real time. Displays an optimizer metric curve, a composite fixed/moving overlay, or diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index 53c1f83b..941e5bf3 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -511,7 +511,7 @@ def register_volume( implement the [`RegistrationProgress`][confusius.registration.RegistrationProgress] protocol (`update()` / `close()`). If not provided, defaults to - [`RegistrationProgressPlotter`][confusius.registration.RegistrationProgressPlotter] + [`MatplotlibRegistrationProgressPlotter`][confusius.registration.MatplotlibRegistrationProgressPlotter] (matplotlib). Ignored when `show_progress=False`. Custom factories are expected to be safe to call from a non-GUI thread; GUI side effects must be marshalled via thread-safe primitives such as Qt signals. @@ -760,7 +760,7 @@ def _record_iteration() -> None: if show_progress: from confusius.registration._progress import ( RegistrationProgress, - RegistrationProgressPlotter, + MatplotlibRegistrationProgressPlotter, ) resample_kwargs: dict[str, object] = { @@ -770,7 +770,7 @@ def _record_iteration() -> None: if _fill_value is not None: resample_kwargs["default_value"] = _fill_value - plotter_factory = progress_plotter or RegistrationProgressPlotter + plotter_factory = progress_plotter or MatplotlibRegistrationProgressPlotter plotter: RegistrationProgress = plotter_factory( registration, fixed_sitk, diff --git a/src/confusius/registration/volumewise.py b/src/confusius/registration/volumewise.py index 8f50b808..a46caf90 100644 --- a/src/confusius/registration/volumewise.py +++ b/src/confusius/registration/volumewise.py @@ -126,9 +126,8 @@ def register_volumewise( that were not started are left blank (filled with the data minimum), and per-frame `motion_params` rows are marked via the diagnostics status. progress_reporter : VolumewiseProgressReporter, optional - Thread-safe reporter notified whenever one frame completes. Useful for - GUI progress bars or progressively filling an output layer while frames - finish. + Thread-safe reporter notified whenever one frame completes. Useful for GUI + progress bars or progressively filling an output layer while frames finish. keep_diagnostics : bool, default: False Whether to keep the full per-frame [`RegistrationDiagnostics`][confusius.registration.RegistrationDiagnostics] diff --git a/tests/unit/test_napari/test_registration_progress.py b/tests/unit/test_napari/test_registration_progress.py index 6ff619f9..4d5b2b3b 100644 --- a/tests/unit/test_napari/test_registration_progress.py +++ b/tests/unit/test_napari/test_registration_progress.py @@ -10,9 +10,9 @@ from confusius._napari._registration._progress import ( NapariProgressBridge, - NapariVolumeProgress, - NapariVolumewiseProgress, - NapariVolumewiseProgressBridge, + NapariRegistrationProgressPlotter, + NapariRegistrationProgressReporter, + NapariRegistrationProgressReporterBridge, make_napari_progress_factory, ) @@ -88,7 +88,7 @@ def test_metric_updated_signal_is_emitted(self, qtbot): bridge.metric_updated.emit(0.42) -class TestNapariVolumeProgress: +class TestNapariRegistrationProgressPlotter: """Per-iteration reporter behaviour.""" def test_update_resamples_and_emits_array(self, qtbot, fixed_img_2d, moving_img_2d): @@ -97,7 +97,7 @@ def test_update_resamples_and_emits_array(self, qtbot, fixed_img_2d, moving_img_ spy = _SignalSpy() bridge.iterated.connect(spy) - reporter = NapariVolumeProgress( + reporter = NapariRegistrationProgressPlotter( bridge, reg, fixed_img_2d, @@ -122,7 +122,7 @@ def test_update_emits_metric_value(self, qtbot, fixed_img_2d, moving_img_2d): metric_spy = _SignalSpy() bridge.metric_updated.connect(metric_spy) - reporter = NapariVolumeProgress( + reporter = NapariRegistrationProgressPlotter( bridge, reg, fixed_img_2d, @@ -146,7 +146,7 @@ def test_update_skips_metric_when_plot_metric_false( metric_spy = _SignalSpy() bridge.metric_updated.connect(metric_spy) - reporter = NapariVolumeProgress( + reporter = NapariRegistrationProgressPlotter( bridge, reg, fixed_img_2d, @@ -164,7 +164,7 @@ def test_update_skips_metric_when_plot_metric_false( def test_close_emits_finished_signal(self, qtbot, fixed_img_2d, moving_img_2d): reg = _make_registration_method(ndim=2) bridge = NapariProgressBridge() - reporter = NapariVolumeProgress( + reporter = NapariRegistrationProgressPlotter( bridge, reg, fixed_img_2d, @@ -175,11 +175,11 @@ def test_close_emits_finished_signal(self, qtbot, fixed_img_2d, moving_img_2d): reporter.close() -class TestNapariVolumewiseProgressBridge: +class TestNapariRegistrationProgressReporterBridge: """Signal bridge behaviour for volumewise registration.""" def test_frame_progress_signal_is_emitted(self, qtbot): - bridge = NapariVolumewiseProgressBridge() + bridge = NapariRegistrationProgressReporterBridge() payloads: list[tuple[int, int]] = [] bridge.frame_progress.connect(lambda completed, total: payloads.append((completed, total))) @@ -189,7 +189,7 @@ def test_frame_progress_signal_is_emitted(self, qtbot): assert payloads == [(1, 3)] def test_frame_completed_signal_is_emitted(self, qtbot): - bridge = NapariVolumewiseProgressBridge() + bridge = NapariRegistrationProgressReporterBridge() payloads: list[tuple[int, np.ndarray]] = [] bridge.frame_completed.connect( lambda index, array: payloads.append((index, array)) @@ -204,19 +204,19 @@ def test_frame_completed_signal_is_emitted(self, qtbot): np.testing.assert_array_equal(payloads[0][1], expected) def test_finished_signal_is_emitted(self, qtbot): - bridge = NapariVolumewiseProgressBridge() + bridge = NapariRegistrationProgressReporterBridge() with qtbot.waitSignal(bridge.finished, timeout=1000): bridge.finished.emit() -class TestNapariVolumewiseProgress: +class TestNapariRegistrationProgressReporter: """Aggregate per-frame progress for volumewise registration.""" def test_frame_completed_emits_progress_and_array(self, qtbot): import xarray as xr - bridge = NapariVolumewiseProgressBridge() - reporter = NapariVolumewiseProgress(bridge, n_frames=3) + bridge = NapariRegistrationProgressReporterBridge() + reporter = NapariRegistrationProgressReporter(bridge, n_frames=3) progress_payloads: list[tuple[int, int]] = [] frame_payloads: list[tuple[int, np.ndarray]] = [] bridge.frame_progress.connect( @@ -239,8 +239,8 @@ def test_frame_completed_emits_progress_and_array(self, qtbot): np.testing.assert_array_equal(frame_payloads[0][1], frame.values) def test_close_emits_finished_signal(self, qtbot): - bridge = NapariVolumewiseProgressBridge() - reporter = NapariVolumewiseProgress(bridge, n_frames=3) + bridge = NapariRegistrationProgressReporterBridge() + reporter = NapariRegistrationProgressReporter(bridge, n_frames=3) with qtbot.waitSignal(bridge.finished, timeout=1000): reporter.close() @@ -265,7 +265,7 @@ def test_factory_returns_napari_volume_progress( resample_kwargs={"default_value": 0.0}, ) - assert isinstance(plotter, NapariVolumeProgress) + assert isinstance(plotter, NapariRegistrationProgressPlotter) assert plotter._bridge is bridge assert plotter._method is reg assert plotter._fixed_img is fixed_img_2d diff --git a/tests/unit/test_registration/test_progress.py b/tests/unit/test_registration/test_progress.py index d08ca2c3..e8ad97fe 100644 --- a/tests/unit/test_registration/test_progress.py +++ b/tests/unit/test_registration/test_progress.py @@ -1,4 +1,4 @@ -"""Unit tests for RegistrationProgressPlotter.""" +"""Unit tests for MatplotlibRegistrationProgressPlotter.""" import matplotlib import numpy as np @@ -8,7 +8,7 @@ matplotlib.use("Agg") from confusius.registration._progress import ( # noqa: E402 - RegistrationProgressPlotter, + MatplotlibRegistrationProgressPlotter, ) # --------------------------------------------------------------------------- @@ -75,17 +75,17 @@ def _make_registration_method(): # --------------------------------------------------------------------------- -# RegistrationProgressPlotter +# MatplotlibRegistrationProgressPlotter # --------------------------------------------------------------------------- -class TestRegistrationProgressPlotterInstantiation: +class TestMatplotlibRegistrationProgressPlotterInstantiation: """Smoke tests for plotter construction.""" def test_metric_only(self, fixed_img_2d, moving_img_2d): """Plotter with only metric panel is created without error.""" reg = _make_registration_method() - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, @@ -97,7 +97,7 @@ def test_metric_only(self, fixed_img_2d, moving_img_2d): def test_composite_only(self, fixed_img_2d, moving_img_2d): """Plotter with only composite panel is created without error.""" reg = _make_registration_method() - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, @@ -109,7 +109,7 @@ def test_composite_only(self, fixed_img_2d, moving_img_2d): def test_both_panels(self, fixed_img_2d, moving_img_2d): """Plotter with both panels is created without error.""" reg = _make_registration_method() - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, @@ -119,7 +119,7 @@ def test_both_panels(self, fixed_img_2d, moving_img_2d): plotter.figure.clf() -class TestRegistrationProgressPlotterUpdate: +class TestMatplotlibRegistrationProgressPlotterUpdate: """Tests for metric_values population and composite rendering.""" def test_metric_values_populated_after_registration( @@ -127,7 +127,7 @@ def test_metric_values_populated_after_registration( ): """metric_values contains one entry per iteration after registration.""" reg = _make_registration_method() - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, @@ -146,7 +146,7 @@ def test_metric_values_populated_after_registration( def test_metric_values_are_floats(self, fixed_img_2d, moving_img_2d): """All recorded metric values are finite floats.""" reg = _make_registration_method() - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, @@ -167,7 +167,7 @@ def test_composite_panel_rendered_after_registration( ): """Composite panel renders without error after at least one iteration.""" reg = _make_registration_method() - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, @@ -198,7 +198,7 @@ def test_3d_composite_panel_rendered(self, fixed_img_3d, moving_img_3d): reg.SmoothingSigmasAreSpecifiedInPhysicalUnitsOff() reg.SetInitialTransform(sitk.TranslationTransform(3), inPlace=True) - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_3d, moving_img_3d, @@ -214,13 +214,13 @@ def test_3d_composite_panel_rendered(self, fixed_img_3d, moving_img_3d): plotter.figure.clf() -class TestRegistrationProgressPlotterResampleKwargs: +class TestMatplotlibRegistrationProgressPlotterResampleKwargs: """Tests for resample_kwargs fill-value behaviour.""" def test_default_fill_value_is_moving_min(self, fixed_img_2d, moving_img_2d): """When resample_kwargs omits default_value, it is set to moving_img.min().""" reg = _make_registration_method() - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, plot_metric=False, plot_composite=True ) expected = float(sitk.GetArrayFromImage(moving_img_2d).min()) @@ -230,7 +230,7 @@ def test_default_fill_value_is_moving_min(self, fixed_img_2d, moving_img_2d): def test_explicit_fill_value_is_respected(self, fixed_img_2d, moving_img_2d): """Explicit default_value in resample_kwargs overrides the auto-default.""" reg = _make_registration_method() - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, @@ -244,7 +244,7 @@ def test_explicit_fill_value_is_respected(self, fixed_img_2d, moving_img_2d): def test_explicit_interpolation_is_stored(self, fixed_img_2d, moving_img_2d): """interpolation key in resample_kwargs is stored and later used.""" reg = _make_registration_method() - plotter = RegistrationProgressPlotter( + plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, From 5199056a121e030257589c0b081ef527976dbcfb Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 14:28:51 +0200 Subject: [PATCH 35/72] feat(xarray): expose registration wrapper args --- src/confusius/xarray/registration.py | 38 ++++++++++++++++++-- tests/unit/test_xarray/test_wrapper_calls.py | 22 +++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/confusius/xarray/registration.py b/src/confusius/xarray/registration.py index 7176dd9e..ac1a1edf 100644 --- a/src/confusius/xarray/registration.py +++ b/src/confusius/xarray/registration.py @@ -1,15 +1,18 @@ """Xarray accessor for registration.""" -from collections.abc import Sequence -from typing import Literal +from collections.abc import Callable, Sequence +from threading import Event +from typing import Literal, cast import numpy as np import numpy.typing as npt import xarray as xr +from confusius.registration._progress import RegistrationProgress from confusius.registration.diagnostics import RegistrationDiagnostics from confusius.registration.volume import register_volume from confusius.registration.volumewise import register_volumewise +from confusius.registration.volumewise_progress import VolumewiseProgressReporter class FUSIRegistrationAccessor: @@ -34,6 +37,8 @@ def to_volume( self, fixed: xr.DataArray, *, + fixed_mask: xr.DataArray | None = None, + moving_mask: xr.DataArray | None = None, transform: Literal["translation", "rigid", "affine", "bspline"] = "rigid", metric: Literal["correlation", "mattes_mi"] = "correlation", number_of_histogram_bins: int = 50, @@ -52,9 +57,12 @@ def to_volume( resample: bool = False, resample_interpolation: Literal["linear", "bspline"] = "linear", fill_value: float | None = None, + sitk_threads: int = -1, show_progress: bool = False, plot_metric: bool = True, plot_composite: bool = True, + progress_plotter: Callable[..., RegistrationProgress] | None = None, + abort_event: Event | None = None, ) -> "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray | None, RegistrationDiagnostics]": # noqa: E501 """Register this volume to a fixed reference volume. @@ -62,6 +70,10 @@ def to_volume( ---------- fixed : xarray.DataArray Reference volume to register to. + fixed_mask : xarray.DataArray, optional + Boolean mask for the fixed volume. + moving_mask : xarray.DataArray, optional + Boolean mask for this moving volume. transform : {"translation", "rigid", "affine", "bspline"}, default: "rigid" Type of transform to use for registration. metric : {"correlation", "mattes_mi"}, default: "correlation" @@ -119,6 +131,8 @@ def to_volume( Fill value for voxels outside the moving image's field of view after resampling. If not provided, defaults to the minimum of the moving image. See [`register_volume`][confusius.registration.register_volume]. + sitk_threads : int, default: -1 + Number of threads SimpleITK may use internally. show_progress : bool, default: False Whether to display a live progress plot during registration. plot_metric : bool, default: True @@ -127,6 +141,11 @@ def to_volume( plot_composite : bool, default: True Whether to include a fixed/moving composite overlay in the progress plot. Ignored when `show_progress=False`. + progress_plotter : callable, optional + Custom progress reporter factory. See + [`register_volume`][confusius.registration.register_volume]. + abort_event : threading.Event, optional + Cooperative cancellation flag. Returns ------- @@ -151,6 +170,8 @@ def to_volume( return register_volume( self._obj, fixed, + fixed_mask=fixed_mask, + moving_mask=moving_mask, transform_type=transform, metric=metric, number_of_histogram_bins=number_of_histogram_bins, @@ -167,9 +188,14 @@ def to_volume( resample=resample, resample_interpolation=resample_interpolation, fill_value=fill_value, + sitk_threads=sitk_threads, show_progress=show_progress, plot_metric=plot_metric, plot_composite=plot_composite, + progress_plotter=cast( + "Callable[..., RegistrationProgress] | None", progress_plotter + ), + abort_event=abort_event, ) def volumewise( @@ -192,6 +218,8 @@ def volumewise( smoothing_sigmas: Sequence[int] = (6, 2, 1), resample_interpolation: Literal["linear", "bspline"] = "linear", show_progress: bool = True, + progress_reporter: VolumewiseProgressReporter | None = None, + abort_event: Event | None = None, keep_diagnostics: bool = False, ) -> xr.DataArray: """Register all volumes to a reference time point. @@ -248,6 +276,10 @@ def volumewise( Interpolation method used for the final resample step. show_progress : bool, default: True Whether to display a progress bar while registering volumes. + progress_reporter : VolumewiseProgressReporter, optional + Thread-safe reporter notified whenever one frame completes. + abort_event : threading.Event, optional + Cooperative cancellation flag shared across frames. keep_diagnostics : bool, default: False Whether to keep per-frame registration diagnostics on the result. See @@ -282,5 +314,7 @@ def volumewise( smoothing_sigmas=smoothing_sigmas, resample_interpolation=resample_interpolation, show_progress=show_progress, + progress_reporter=progress_reporter, + abort_event=abort_event, keep_diagnostics=keep_diagnostics, ) diff --git a/tests/unit/test_xarray/test_wrapper_calls.py b/tests/unit/test_xarray/test_wrapper_calls.py index 87c1dcf0..cf04b099 100644 --- a/tests/unit/test_xarray/test_wrapper_calls.py +++ b/tests/unit/test_xarray/test_wrapper_calls.py @@ -226,9 +226,15 @@ def _volumewise(data, **kwargs): ) fixed = sample_3d_volume.copy() + fixed_mask = fixed > 0 + moving_mask = sample_3d_volume > 0 + progress_plotter = object() + abort_event = object() assert ( sample_3d_volume.fusi.register.to_volume( fixed, + fixed_mask=fixed_mask, + moving_mask=moving_mask, transform="affine", metric="mattes_mi", number_of_histogram_bins=40, @@ -248,6 +254,9 @@ def _volumewise(data, **kwargs): plot_metric=False, plot_composite=False, fill_value=-1.0, + sitk_threads=2, + progress_plotter=progress_plotter, + abort_event=abort_event, ) is reg_result ) @@ -255,6 +264,8 @@ def _volumewise(data, **kwargs): sample_3d_volume, fixed, { + "fixed_mask": fixed_mask, + "moving_mask": moving_mask, "transform_type": "affine", "metric": "mattes_mi", "number_of_histogram_bins": 40, @@ -270,13 +281,18 @@ def _volumewise(data, **kwargs): "smoothing_sigmas": (3, 1, 0), "resample": True, "resample_interpolation": "bspline", + "fill_value": -1.0, + "sitk_threads": 2, "show_progress": True, "plot_metric": False, "plot_composite": False, - "fill_value": -1.0, + "progress_plotter": progress_plotter, + "abort_event": abort_event, }, ) + progress_reporter = object() + abort_event = object() assert ( sample_3d_volume.fusi.register.volumewise( reference_time=2, @@ -295,6 +311,8 @@ def _volumewise(data, **kwargs): smoothing_sigmas=(2, 0), resample_interpolation="bspline", show_progress=False, + progress_reporter=progress_reporter, + abort_event=abort_event, keep_diagnostics=True, ) is volumewise_result @@ -318,6 +336,8 @@ def _volumewise(data, **kwargs): "smoothing_sigmas": (2, 0), "resample_interpolation": "bspline", "show_progress": False, + "progress_reporter": progress_reporter, + "abort_event": abort_event, "keep_diagnostics": True, }, ) From e03398f609a2e3c58d3d6bc2ba5e25866bc7f4d1 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 14:38:28 +0200 Subject: [PATCH 36/72] refactor(registration): simplify progress resampling --- .../_napari/_registration/_progress.py | 23 +++++++---- src/confusius/registration/_progress.py | 40 ++++++++++++------- src/confusius/registration/volume.py | 7 ++-- .../test_napari/test_registration_progress.py | 12 +++--- tests/unit/test_registration/test_progress.py | 14 +++---- 5 files changed, 56 insertions(+), 40 deletions(-) diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py index e2c2e20b..7949e04d 100644 --- a/src/confusius/_napari/_registration/_progress.py +++ b/src/confusius/_napari/_registration/_progress.py @@ -34,7 +34,7 @@ from __future__ import annotations from threading import Lock -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Literal, cast import numpy as np from qtpy.QtCore import QObject, Signal @@ -102,9 +102,8 @@ class NapariRegistrationProgressPlotter: Kept for signature compatibility with the matplotlib plotter factory. The napari preview always shows the resampled moving image directly. resample_kwargs : dict, optional - Extra keyword arguments forwarded to - [`_resample_intermediate`][confusius.registration._progress._resample_intermediate]. - Must include `"default_value"`; `interpolation` defaults to `"linear"`. + Extra keyword arguments for the intermediate resample. Supported keys are + `interpolation`, `fill_value`, and `sitk_threads`. """ def __init__( @@ -122,7 +121,13 @@ def __init__( self._method = registration_method self._fixed_img = fixed_img self._moving_img = moving_img - self._resample_kwargs = dict(resample_kwargs or {}) + _kw = dict(resample_kwargs or {}) + self._interpolation = cast( + 'Literal["linear", "nearest", "bspline"]', + _kw.get("interpolation", "linear"), + ) + self._fill_value = float(_kw.get("fill_value", 0.0)) + self._sitk_threads = int(_kw.get("sitk_threads", -1)) self._plot_metric = plot_metric del plot_composite @@ -145,7 +150,9 @@ def update(self) -> None: self._method, self._moving_img, self._fixed_img, - self._resample_kwargs, + interpolation=self._interpolation, + fill_value=self._fill_value, + sitk_threads=self._sitk_threads, ) # .T restores numpy axis order (inverse of the .T used when building # the SITK image), matching what `register_volume` produces. @@ -279,8 +286,8 @@ def factory( plot_composite : bool, default: True Kept for signature compatibility with the matplotlib plotter factory. resample_kwargs : dict, optional - Extra keyword arguments forwarded to - [`_resample_intermediate`][confusius.registration._progress._resample_intermediate]. + Extra keyword arguments for the intermediate resample. Supported keys are + `interpolation`, `fill_value`, and `sitk_threads`. Returns ------- diff --git a/src/confusius/registration/_progress.py b/src/confusius/registration/_progress.py index 6d30b7e9..b1d11c5f 100644 --- a/src/confusius/registration/_progress.py +++ b/src/confusius/registration/_progress.py @@ -3,7 +3,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast import numpy as np @@ -58,7 +58,10 @@ def _resample_intermediate( registration_method: "sitk.ImageRegistrationMethod", moving_img: "sitk.Image", fixed_img: "sitk.Image", - resample_kwargs: dict[str, Any], + *, + interpolation: Literal["linear", "nearest", "bspline"] = "linear", + fill_value: float = 0.0, + sitk_threads: int = -1, ) -> "sitk.Image": """Resample the moving image onto the fixed grid using the current transform. @@ -74,10 +77,12 @@ def _resample_intermediate( Moving image to resample. fixed_img : SimpleITK.Image Reference image defining the output grid. - resample_kwargs : dict[str, Any] - Keyword arguments forwarded to `sitk.Resample`. Must contain - `"interpolation"` and `"default_value"`. May contain - `"sitk_threads"`. + interpolation : {"linear", "nearest", "bspline"}, default: "linear" + Interpolator used for the intermediate resample. + fill_value : float, default: 0.0 + Fill value used outside the moving image field of view. + sitk_threads : int, default: -1 + Number of threads SimpleITK may use for the intermediate resample. Returns ------- @@ -88,10 +93,7 @@ def _resample_intermediate( from confusius.registration._utils import set_sitk_thread_count - interpolation = resample_kwargs.get("interpolation", "linear") sitk_interp = _resolve_sitk_interpolation(interpolation) - fill_value = resample_kwargs.get("default_value", 0.0) - sitk_threads = resample_kwargs.get("sitk_threads", -1) transform = registration_method.GetInitialTransform() with set_sitk_thread_count(sitk_threads): @@ -143,8 +145,8 @@ class MatplotlibRegistrationProgressPlotter: Whether to display a blended fixed/moving composite at each iteration. Requires an additional `sitk.Resample` call per iteration. resample_kwargs : dict, optional - Extra keyword arguments forwarded to the internal resample call at each - iteration. + Extra keyword arguments for the internal resample call at each iteration. + Supported keys are `interpolation`, `fill_value`, and `sitk_threads`. """ def __init__( @@ -168,11 +170,17 @@ def __init__( self._metric_values: list[float] = [] _kw: dict[str, Any] = dict(resample_kwargs or {}) - if "default_value" not in _kw: + self._interpolation = cast( + 'Literal["linear", "nearest", "bspline"]', + _kw.get("interpolation", "linear"), + ) + if "fill_value" in _kw: + self._fill_value = float(_kw["fill_value"]) + else: import SimpleITK as sitk - _kw["default_value"] = float(sitk.GetArrayFromImage(moving_img).min()) - self._resample_kwargs = _kw + self._fill_value = float(sitk.GetArrayFromImage(moving_img).min()) + self._sitk_threads = int(_kw.get("sitk_threads", -1)) # Detect Jupyter notebook environment. A plain IPython terminal shell # also has get_ipython() != None, so we check the kernel class name to @@ -260,7 +268,9 @@ def update(self) -> None: self._method, self._moving_img, self._fixed_img, - self._resample_kwargs, + interpolation=self._interpolation, + fill_value=self._fill_value, + sitk_threads=self._sitk_threads, ) import SimpleITK as sitk diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index 941e5bf3..8ec19aa1 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -507,7 +507,9 @@ def register_volume( progress_plotter : callable, optional Factory that builds the progress reporter, called inside `register_volume` as `progress_plotter(registration_method, fixed_img, moving_img, *, - plot_metric, plot_composite, resample_kwargs)`. The returned object must + plot_metric, plot_composite, resample_kwargs)`. Here `resample_kwargs` + carries `interpolation`, `fill_value`, and `sitk_threads`. The returned + object must implement the [`RegistrationProgress`][confusius.registration.RegistrationProgress] protocol (`update()` / `close()`). If not provided, defaults to @@ -765,10 +767,9 @@ def _record_iteration() -> None: resample_kwargs: dict[str, object] = { "interpolation": resample_interpolation, + "fill_value": _fill_value, "sitk_threads": sitk_threads, } - if _fill_value is not None: - resample_kwargs["default_value"] = _fill_value plotter_factory = progress_plotter or MatplotlibRegistrationProgressPlotter plotter: RegistrationProgress = plotter_factory( diff --git a/tests/unit/test_napari/test_registration_progress.py b/tests/unit/test_napari/test_registration_progress.py index 4d5b2b3b..eac4da62 100644 --- a/tests/unit/test_napari/test_registration_progress.py +++ b/tests/unit/test_napari/test_registration_progress.py @@ -102,8 +102,7 @@ def test_update_resamples_and_emits_array(self, qtbot, fixed_img_2d, moving_img_ reg, fixed_img_2d, moving_img_2d, - # default_value is required by `_resample_intermediate`. - resample_kwargs={"interpolation": "linear", "default_value": 0.0}, + resample_kwargs={"interpolation": "linear", "fill_value": 0.0}, ) with qtbot.waitSignal(bridge.iterated, timeout=2000): @@ -127,7 +126,7 @@ def test_update_emits_metric_value(self, qtbot, fixed_img_2d, moving_img_2d): reg, fixed_img_2d, moving_img_2d, - resample_kwargs={"default_value": 0.0}, + resample_kwargs={"fill_value": 0.0}, ) with qtbot.waitSignal(bridge.metric_updated, timeout=2000): @@ -135,7 +134,6 @@ def test_update_emits_metric_value(self, qtbot, fixed_img_2d, moving_img_2d): assert len(metric_spy.payloads) == 1 assert isinstance(metric_spy.payloads[0], float) - assert np.isfinite(metric_spy.payloads[0]) def test_update_skips_metric_when_plot_metric_false( self, qtbot, fixed_img_2d, moving_img_2d @@ -152,7 +150,7 @@ def test_update_skips_metric_when_plot_metric_false( fixed_img_2d, moving_img_2d, plot_metric=False, - resample_kwargs={"default_value": 0.0}, + resample_kwargs={"fill_value": 0.0}, ) # Iterate and confirm the metric signal never fires. We trigger the # iterated signal first to give the metric a chance to emit, then @@ -169,7 +167,7 @@ def test_close_emits_finished_signal(self, qtbot, fixed_img_2d, moving_img_2d): reg, fixed_img_2d, moving_img_2d, - resample_kwargs={"default_value": 0.0}, + resample_kwargs={"fill_value": 0.0}, ) with qtbot.waitSignal(bridge.finished, timeout=1000): reporter.close() @@ -262,7 +260,7 @@ def test_factory_returns_napari_volume_progress( moving_img_2d, plot_metric=True, plot_composite=True, - resample_kwargs={"default_value": 0.0}, + resample_kwargs={"fill_value": 0.0}, ) assert isinstance(plotter, NapariRegistrationProgressPlotter) diff --git a/tests/unit/test_registration/test_progress.py b/tests/unit/test_registration/test_progress.py index e8ad97fe..2aba016d 100644 --- a/tests/unit/test_registration/test_progress.py +++ b/tests/unit/test_registration/test_progress.py @@ -215,20 +215,20 @@ def test_3d_composite_panel_rendered(self, fixed_img_3d, moving_img_3d): class TestMatplotlibRegistrationProgressPlotterResampleKwargs: - """Tests for resample_kwargs fill-value behaviour.""" + """Tests for intermediate-resample settings.""" def test_default_fill_value_is_moving_min(self, fixed_img_2d, moving_img_2d): - """When resample_kwargs omits default_value, it is set to moving_img.min().""" + """When resample_kwargs omits fill_value, it defaults to moving_img.min().""" reg = _make_registration_method() plotter = MatplotlibRegistrationProgressPlotter( reg, fixed_img_2d, moving_img_2d, plot_metric=False, plot_composite=True ) expected = float(sitk.GetArrayFromImage(moving_img_2d).min()) - assert plotter._resample_kwargs["default_value"] == pytest.approx(expected) + assert plotter._fill_value == pytest.approx(expected) plotter.figure.clf() def test_explicit_fill_value_is_respected(self, fixed_img_2d, moving_img_2d): - """Explicit default_value in resample_kwargs overrides the auto-default.""" + """Explicit fill_value in resample_kwargs overrides the auto-default.""" reg = _make_registration_method() plotter = MatplotlibRegistrationProgressPlotter( reg, @@ -236,9 +236,9 @@ def test_explicit_fill_value_is_respected(self, fixed_img_2d, moving_img_2d): moving_img_2d, plot_metric=False, plot_composite=True, - resample_kwargs={"default_value": -60.0}, + resample_kwargs={"fill_value": -60.0}, ) - assert plotter._resample_kwargs["default_value"] == pytest.approx(-60.0) + assert plotter._fill_value == pytest.approx(-60.0) plotter.figure.clf() def test_explicit_interpolation_is_stored(self, fixed_img_2d, moving_img_2d): @@ -252,7 +252,7 @@ def test_explicit_interpolation_is_stored(self, fixed_img_2d, moving_img_2d): plot_composite=True, resample_kwargs={"interpolation": "nearest"}, ) - assert plotter._resample_kwargs["interpolation"] == "nearest" + assert plotter._interpolation == "nearest" plotter.figure.clf() From c2311955b8431bf1a17ee5ad0ce672702d534de2 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 14:45:34 +0200 Subject: [PATCH 37/72] docs: fix docstring args ordering --- src/confusius/registration/volumewise.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/confusius/registration/volumewise.py b/src/confusius/registration/volumewise.py index a46caf90..d3e0b2cf 100644 --- a/src/confusius/registration/volumewise.py +++ b/src/confusius/registration/volumewise.py @@ -40,8 +40,8 @@ def register_volumewise( smoothing_sigmas: Sequence[int] = (6, 2, 1), resample_interpolation: Literal["linear", "bspline"] = "linear", show_progress: bool = True, - abort_event: "Event | None" = None, progress_reporter: "VolumewiseProgressReporter | None" = None, + abort_event: "Event | None" = None, keep_diagnostics: bool = False, ) -> xr.DataArray: """Register all volumes in a fUSI recording to a reference volume. @@ -119,15 +119,15 @@ def register_volumewise( cost of speed. show_progress : bool, default: True Whether to display a progress bar while registering volumes. + progress_reporter : VolumewiseProgressReporter, optional + Thread-safe reporter notified whenever one frame completes. Useful for GUI + progress bars or progressively filling an output layer while frames finish. abort_event : threading.Event, optional Cooperative cancellation flag shared across frames. If set before or during execution, in-flight frame registrations stop at the next optimiser iteration boundary and this function returns the partial dataset collected so far. Frames that were not started are left blank (filled with the data minimum), and per-frame `motion_params` rows are marked via the diagnostics status. - progress_reporter : VolumewiseProgressReporter, optional - Thread-safe reporter notified whenever one frame completes. Useful for GUI - progress bars or progressively filling an output layer while frames finish. keep_diagnostics : bool, default: False Whether to keep the full per-frame [`RegistrationDiagnostics`][confusius.registration.RegistrationDiagnostics] From 7b5f9ce36ef0452d00cb4152eda4514d724c3ca1 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 14:49:07 +0200 Subject: [PATCH 38/72] refactor(registration): make progress module public --- src/confusius/_napari/_registration/_progress.py | 2 +- src/confusius/registration/__init__.py | 2 +- src/confusius/registration/{_progress.py => progress.py} | 0 src/confusius/registration/volume.py | 4 ++-- src/confusius/xarray/registration.py | 2 +- tests/unit/test_registration/test_progress.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename src/confusius/registration/{_progress.py => progress.py} (100%) diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py index 7949e04d..9ff48066 100644 --- a/src/confusius/_napari/_registration/_progress.py +++ b/src/confusius/_napari/_registration/_progress.py @@ -39,7 +39,7 @@ import numpy as np from qtpy.QtCore import QObject, Signal -from confusius.registration._progress import _resample_intermediate +from confusius.registration.progress import _resample_intermediate if TYPE_CHECKING: import SimpleITK as sitk diff --git a/src/confusius/registration/__init__.py b/src/confusius/registration/__init__.py index e0c19e4b..52372159 100644 --- a/src/confusius/registration/__init__.py +++ b/src/confusius/registration/__init__.py @@ -1,6 +1,6 @@ """Registration module for fUSI data.""" -from confusius.registration._progress import ( +from confusius.registration.progress import ( RegistrationProgress, MatplotlibRegistrationProgressPlotter, ) diff --git a/src/confusius/registration/_progress.py b/src/confusius/registration/progress.py similarity index 100% rename from src/confusius/registration/_progress.py rename to src/confusius/registration/progress.py diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index 8ec19aa1..655884c2 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -24,7 +24,7 @@ import SimpleITK as sitk - from confusius.registration._progress import RegistrationProgress + from confusius.registration.progress import RegistrationProgress def _validate_register_volume_inputs( @@ -760,7 +760,7 @@ def _record_iteration() -> None: ) if show_progress: - from confusius.registration._progress import ( + from confusius.registration.progress import ( RegistrationProgress, MatplotlibRegistrationProgressPlotter, ) diff --git a/src/confusius/xarray/registration.py b/src/confusius/xarray/registration.py index ac1a1edf..42b1f211 100644 --- a/src/confusius/xarray/registration.py +++ b/src/confusius/xarray/registration.py @@ -8,7 +8,7 @@ import numpy.typing as npt import xarray as xr -from confusius.registration._progress import RegistrationProgress +from confusius.registration.progress import RegistrationProgress from confusius.registration.diagnostics import RegistrationDiagnostics from confusius.registration.volume import register_volume from confusius.registration.volumewise import register_volumewise diff --git a/tests/unit/test_registration/test_progress.py b/tests/unit/test_registration/test_progress.py index 2aba016d..e543f8ae 100644 --- a/tests/unit/test_registration/test_progress.py +++ b/tests/unit/test_registration/test_progress.py @@ -7,7 +7,7 @@ matplotlib.use("Agg") -from confusius.registration._progress import ( # noqa: E402 +from confusius.registration.progress import ( # noqa: E402 MatplotlibRegistrationProgressPlotter, ) From 0df28f07310d41d0bf3cec61b68e1996d447e5c6 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Mon, 29 Jun 2026 14:57:39 +0200 Subject: [PATCH 39/72] refactor(registration): simplify transform payload helpers --- .../_napari/_registration/_transforms.py | 69 ++----------------- src/confusius/registration/bspline.py | 4 +- .../test_napari/test_registration_panel.py | 6 +- 3 files changed, 9 insertions(+), 70 deletions(-) diff --git a/src/confusius/_napari/_registration/_transforms.py b/src/confusius/_napari/_registration/_transforms.py index c6039f08..91f7afc1 100644 --- a/src/confusius/_napari/_registration/_transforms.py +++ b/src/confusius/_napari/_registration/_transforms.py @@ -10,6 +10,8 @@ import numpy.typing as npt import xarray as xr +from confusius.registration.bspline import validate_bspline_dataarray + if TYPE_CHECKING: from collections.abc import Mapping @@ -179,7 +181,7 @@ def make_affine_transform_payload( def _serialize_bspline_dataarray(transform: "xr.DataArray") -> BSplineDataArrayPayload: """Return a JSON-serializable B-spline DataArray payload.""" - _validate_bspline_dataarray(transform) + validate_bspline_dataarray(transform) return { "dims": [str(dim) for dim in transform.dims], "data": np.asarray(transform, dtype=float).tolist(), @@ -205,7 +207,7 @@ def _deserialize_bspline_dataarray(payload: BSplineDataArrayPayload) -> xr.DataA coords=coords, attrs=dict(payload["attrs"]), ) - _validate_bspline_dataarray(transform) + validate_bspline_dataarray(transform) return transform @@ -410,7 +412,7 @@ def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload finally: ds.close() - _validate_bspline_dataarray(transform) + validate_bspline_dataarray(transform) payload: BSplineTransformPayload = { "kind": "bspline", "bspline": _serialize_bspline_dataarray(transform), @@ -478,64 +480,3 @@ def load_transform_payload(path: str | Path) -> TransformPayload: affine_transform_from_payload(payload) output_grid_from_payload(payload) return cast("TransformPayload", payload) - - -def save_affine_transform_payload( - path: str | Path, payload: AffineTransformPayload -) -> None: - """Save an affine transform payload as JSON. - - Parameters - ---------- - path : str or pathlib.Path - Output JSON path. - payload : AffineTransformPayload - Transform payload to save. - """ - save_transform_payload(path, payload) - - -def load_affine_transform_payload(path: str | Path) -> AffineTransformPayload: - """Load an affine transform payload from JSON. - - Parameters - ---------- - path : str or pathlib.Path - Input JSON path. - - Returns - ------- - AffineTransformPayload - Loaded affine transform payload. - """ - payload = load_transform_payload(path) - affine_transform_from_payload(payload) - return cast("AffineTransformPayload", payload) - - -def _validate_bspline_dataarray(da: xr.DataArray) -> None: - """Raise ValueError if *da* does not look like a valid B-spline transform.""" - transform_type = da.attrs.get("transform_type") - if transform_type != "bspline_transform": - raise ValueError( - "Expected a DataArray with attrs['transform_type'] == " - "'bspline_transform'; " - f"got {transform_type!r}." - ) - for key in ("order", "direction"): - if key not in da.attrs: - raise ValueError( - f"B-spline transform DataArray is missing required attribute {key!r}." - ) - if not da.dims or da.dims[0] != "component": - raise ValueError( - "B-spline transform DataArray must have 'component' as its first " - f"dimension; got {da.dims[0] if da.dims else None!r}." - ) - ndim = da.ndim - 1 - if da.sizes["component"] != ndim: - raise ValueError( - "B-spline transform DataArray component axis must match the number of " - f"spatial dimensions; got {da.sizes['component']} components for {ndim} " - "spatial dims." - ) diff --git a/src/confusius/registration/bspline.py b/src/confusius/registration/bspline.py index 9a43040d..faf25abb 100644 --- a/src/confusius/registration/bspline.py +++ b/src/confusius/registration/bspline.py @@ -147,7 +147,7 @@ def _dataarray_to_sitk_bspline(da: xr.DataArray) -> "sitk.Transform": """ import SimpleITK as sitk - _validate_bspline_dataarray(da) + validate_bspline_dataarray(da) ndim = da.ndim - 1 # subtract the component axis order = int(da.attrs["order"]) @@ -240,7 +240,7 @@ def _extract_bspline(transform: "sitk.Transform") -> "sitk.BSplineTransform": ) -def _validate_bspline_dataarray(da: xr.DataArray) -> None: +def validate_bspline_dataarray(da: xr.DataArray) -> None: """Raise ValueError if *da* does not look like a valid B-spline transform DataArray. Parameters diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 83ced7ef..2df81835 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -13,12 +13,10 @@ from confusius._napari._registration._transforms import ( affine_transform_from_payload, bspline_transform_from_payload, - load_affine_transform_payload, load_transform_payload, make_affine_transform_payload, make_bspline_transform_payload, output_grid_from_payload, - save_affine_transform_payload, save_transform_payload, ) from confusius.registration import resample_like @@ -684,8 +682,8 @@ def test_affine_payload_roundtrip(self, tmp_path): ) path = tmp_path / "transform.json" - save_affine_transform_payload(path, payload) - loaded = load_affine_transform_payload(path) + save_transform_payload(path, payload) + loaded = load_transform_payload(path) assert loaded["source_layer_name"] == "moving" assert loaded["name"] == "moving → fixed (rigid)" From c3d3ce4ec486be30e5acdd4d15a510bd50864475 Mon Sep 17 00:00:00 2001 From: Felipe Cybis Pereira Date: Mon, 29 Jun 2026 22:25:24 +0200 Subject: [PATCH 40/72] preserve user view when running registration --- src/confusius/_napari/_registration/_panel.py | 221 +++++++++++------- .../test_napari/test_registration_panel.py | 37 +++ 2 files changed, 173 insertions(+), 85 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index dff15309..32ba5cbe 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2,7 +2,8 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterator +from contextlib import contextmanager from pathlib import Path from threading import Event from typing import TYPE_CHECKING, Any, Literal, Sequence, cast @@ -76,6 +77,47 @@ from confusius.registration import RegistrationDiagnostics, RegistrationProgress +@contextmanager +def _preserve_view(viewer: "napari.Viewer") -> Iterator[None]: + """Keep the viewer camera and dims state across a block that adds layers. + + Adding image layers makes napari recompute `camera.center` and re-apply + `napari.imshow`'s default `ndisplay`/`order` to the dims, which yanks the + canvas back to a default framing. Wrapping the layer creation in this + context manager snapshots the current pan, zoom, rotation, and slider + position and restores them once the block exits, so the user keeps the view + they were on when starting a registration run. + + Parameters + ---------- + viewer : napari.Viewer + Viewer whose camera and dims state are snapshotted and restored. + + Yields + ------ + None + Control returns to the wrapped block; the saved state is restored when + it exits, including on early return or exception. + """ + camera = viewer.camera + dims = viewer.dims + center = tuple(camera.center) + zoom = camera.zoom + angles = tuple(camera.angles) + ndisplay = dims.ndisplay + order = tuple(dims.order) + current_step = tuple(dims.current_step) + try: + yield + finally: + dims.ndisplay = ndisplay + dims.order = order + dims.current_step = current_step + camera.center = center + camera.zoom = zoom + camera.angles = angles + + def _default_dims_for_ndim(ndim: int) -> tuple[str, ...]: """Return fallback dimension names for a raw napari layer. @@ -2208,45 +2250,49 @@ def _setup_volumewise_progress( attrs=moving.attrs.copy(), ) - try: - moving_preview_layer = cast( - "Image", - self.viewer.layers[self._volumewise_moving_preview_layer_name()], - ) - except KeyError: - _, moving_preview_layer = plot_napari( - moving, + # Adding the preview/progress layers makes napari recompute the camera + # and reset the dims; snapshot and restore so the run starts from the + # user's current view. + with _preserve_view(self.viewer): + try: + moving_preview_layer = cast( + "Image", + self.viewer.layers[self._volumewise_moving_preview_layer_name()], + ) + except KeyError: + _, moving_preview_layer = plot_napari( + moving, + viewer=self.viewer, + name=self._volumewise_moving_preview_layer_name(), + show_colorbar=False, + contrast_limits=contrast_limits, + **moving_display_kwargs, + ) + else: + moving_preview_layer.data = np.asarray(moving.data) # type: ignore[invalid-assignment] + moving_preview_layer.colormap = moving_display_kwargs["colormap"] + moving_preview_layer.gamma = cast( + "float", moving_display_kwargs.get("gamma", 1.0) + ) + moving_preview_layer.contrast_limits = contrast_limits + + try: + fixed_preview_layer = cast( + "Image", self.viewer.layers[self._volume_fixed_preview_layer_name()] + ) + except KeyError: + fixed_preview_layer = None + else: + fixed_preview_layer.visible = False + + _, layer = plot_napari( + preview, viewer=self.viewer, - name=self._volumewise_moving_preview_layer_name(), + name=layer_name, show_colorbar=False, contrast_limits=contrast_limits, - **moving_display_kwargs, - ) - else: - moving_preview_layer.data = np.asarray(moving.data) # type: ignore[invalid-assignment] - moving_preview_layer.colormap = moving_display_kwargs["colormap"] - moving_preview_layer.gamma = cast( - "float", moving_display_kwargs.get("gamma", 1.0) - ) - moving_preview_layer.contrast_limits = contrast_limits - - try: - fixed_preview_layer = cast( - "Image", self.viewer.layers[self._volume_fixed_preview_layer_name()] + **display_kwargs, ) - except KeyError: - fixed_preview_layer = None - else: - fixed_preview_layer.visible = False - - _, layer = plot_napari( - preview, - viewer=self.viewer, - name=layer_name, - show_colorbar=False, - contrast_limits=contrast_limits, - **display_kwargs, - ) bridge = NapariRegistrationProgressReporterBridge() bridge.frame_progress.connect(self._update_volumewise_progress_bar) bridge.frame_completed.connect(self._update_volumewise_progress_frame) @@ -2410,62 +2456,67 @@ def _setup_volume_progress( ) preview_contrast_limits = tuple(calc_data_range(preview.data)) - try: + # Adding the preview/progress layers makes napari recompute the camera + # and reset the dims; snapshot and restore so the run starts from the + # user's current view. + with _preserve_view(self.viewer): try: - fixed_preview_layer = cast( - "Image", self.viewer.layers[self._volume_fixed_preview_layer_name()] - ) - except KeyError: - _, fixed_preview_layer = plot_napari( - fixed, - viewer=self.viewer, - name=self._volume_fixed_preview_layer_name(), - show_colorbar=False, - **fixed_display_kwargs, - ) - else: - fixed_preview_layer.data = np.asarray(fixed.data) # type: ignore[invalid-assignment] - fixed_preview_layer.colormap = fixed_display_kwargs["colormap"] - fixed_preview_layer.gamma = cast( - "float", fixed_display_kwargs.get("gamma", 1.0) - ) - fixed_preview_layer.visible = True + try: + fixed_preview_layer = cast( + "Image", + self.viewer.layers[self._volume_fixed_preview_layer_name()], + ) + except KeyError: + _, fixed_preview_layer = plot_napari( + fixed, + viewer=self.viewer, + name=self._volume_fixed_preview_layer_name(), + show_colorbar=False, + **fixed_display_kwargs, + ) + else: + fixed_preview_layer.data = np.asarray(fixed.data) # type: ignore[invalid-assignment] + fixed_preview_layer.colormap = fixed_display_kwargs["colormap"] + fixed_preview_layer.gamma = cast( + "float", fixed_display_kwargs.get("gamma", 1.0) + ) + fixed_preview_layer.visible = True - try: - moving_preview_layer = cast( - "Image", - self.viewer.layers[self._volume_moving_preview_layer_name()], - ) - except KeyError: - _, moving_preview_layer = plot_napari( + try: + moving_preview_layer = cast( + "Image", + self.viewer.layers[self._volume_moving_preview_layer_name()], + ) + except KeyError: + _, moving_preview_layer = plot_napari( + preview, + viewer=self.viewer, + name=self._volume_moving_preview_layer_name(), + show_colorbar=False, + contrast_limits=preview_contrast_limits, + **moving_display_kwargs, + ) + else: + moving_preview_layer.data = np.asarray(preview.data) # type: ignore[invalid-assignment] + moving_preview_layer.colormap = moving_display_kwargs["colormap"] + moving_preview_layer.blending = moving_display_kwargs["blending"] + moving_preview_layer.gamma = cast( + "float", moving_display_kwargs.get("gamma", 1.0) + ) + moving_preview_layer.contrast_limits = preview_contrast_limits + moving_preview_layer.visible = False + + _, layer = plot_napari( preview, viewer=self.viewer, - name=self._volume_moving_preview_layer_name(), + name=layer_name, show_colorbar=False, contrast_limits=preview_contrast_limits, - **moving_display_kwargs, - ) - else: - moving_preview_layer.data = np.asarray(preview.data) # type: ignore[invalid-assignment] - moving_preview_layer.colormap = moving_display_kwargs["colormap"] - moving_preview_layer.blending = moving_display_kwargs["blending"] - moving_preview_layer.gamma = cast( - "float", moving_display_kwargs.get("gamma", 1.0) + **display_kwargs, ) - moving_preview_layer.contrast_limits = preview_contrast_limits - moving_preview_layer.visible = False - - _, layer = plot_napari( - preview, - viewer=self.viewer, - name=layer_name, - show_colorbar=False, - contrast_limits=preview_contrast_limits, - **display_kwargs, - ) - except Exception as exc: # noqa: BLE001 - self._set_error(f"Could not create progress layer: {exc}") - return None + except Exception as exc: # noqa: BLE001 + self._set_error(f"Could not create progress layer: {exc}") + return None bridge = NapariProgressBridge() bridge.iterated.connect(self._update_progress_layer) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 2df81835..40d716cb 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -227,6 +227,43 @@ def test_scale_preprocessing_resets_gamma_for_previews(self, viewer, registratio assert viewer.layers["Moving"].gamma == pytest.approx(0.4) assert viewer.layers["Registered (rigid)"].gamma == pytest.approx(0.4) + def test_setup_volume_progress_preserves_camera_view( + self, viewer, registration_panel + ): + moving_data = xr.DataArray( + np.ones((5, 4, 6), dtype=np.float32), + dims=["z", "y", "x"], + coords={ + "z": xr.DataArray(np.arange(5) * 0.3, dims=["z"]), + "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), + }, + ) + fixed = xr.DataArray( + 2 * np.ones((5, 4, 6), dtype=np.float32), + dims=["z", "y", "x"], + coords=moving_data.coords, + ) + moving = viewer.add_image(moving_data.values, name="moving") + fixed_layer = viewer.add_image(fixed.values, name="fixed") + + # User navigates to a custom 3D view before launching the run. + viewer.dims.ndisplay = 3 + viewer.camera.center = (1.0, 2.0, 3.0) + viewer.camera.zoom = 7.0 + before = (tuple(viewer.camera.center), viewer.camera.zoom, viewer.dims.ndisplay) + + registration_panel._setup_volume_progress( + moving_layer=moving, + fixed_layer=fixed_layer, + moving=moving_data, + fixed=fixed, + layer_name="Registered (rigid)", + ) + + after = (tuple(viewer.camera.center), viewer.camera.zoom, viewer.dims.ndisplay) + assert after == before + def test_metric_specific_rows_follow_metric(self, registration_panel): registration_panel._advanced_toggle.setChecked(True) assert registration_panel._metric_combo.currentText() == "correlation" From f4a98bcf070690989240e16d9677f6589da436b3 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Tue, 30 Jun 2026 15:36:42 +0200 Subject: [PATCH 41/72] fix(registration): clarify optimizer scale failures --- src/confusius/registration/volume.py | 65 ++++++++++++++++++++- tests/unit/test_registration/test_volume.py | 23 ++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index 655884c2..ec1c45b0 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -245,6 +245,56 @@ def _expand_thin_dims(img: "sitk.Image", min_size: int = 4) -> "sitk.Image": return sitk.Expand(img, factors.tolist()) +def _translate_registration_runtime_error( + exc: RuntimeError, + *, + transform_type: Literal["translation", "rigid", "affine", "bspline"], + learning_rate: float | Literal["auto"], +) -> RuntimeError: + """Return a clearer registration error for known SimpleITK failures. + + Parameters + ---------- + exc : RuntimeError + Exception raised by SimpleITK during optimizer execution. + transform_type : {"translation", "rigid", "affine", "bspline"} + Registration model used for the failed run. + learning_rate : float or "auto" + User-requested learning rate mode. + + Returns + ------- + RuntimeError + Translated exception when the failure mode is recognized, otherwise `exc`. + """ + message = str(exc) + if "m_Scales values must be > epsilon" not in message: + return exc + + parts = [ + "SimpleITK could not compute valid optimizer scales for this registration.", + "Some transform parameters have near-zero physical effect, so the gradient-descent optimizer cannot choose a stable step size.", + ] + if transform_type == "bspline": + parts.append( + "This is most common for `transform_type='bspline'`, especially when the control-point grid is too fine for the image extent or overlap." + ) + if learning_rate == "auto": + parts.append( + 'Retry with a fixed `learning_rate` such as `0.1` or `0.01` instead of `"auto"`.' + ) + else: + parts.append( + "Changing `learning_rate` alone may not help because this failure happens before optimisation starts." + ) + if transform_type == "bspline": + parts.append( + "If that still fails, use a coarser `mesh_size` or run affine/rigid registration first and pass the result as `initialization`." + ) + + return RuntimeError(" ".join(parts)) + + @overload def register_volume( # numpydoc ignore=GL08,PR01,RT01 moving: xr.DataArray, @@ -624,6 +674,8 @@ def register_volume( f"image dimensionality {ndim}D (expected {expected_shape})." ) + requested_learning_rate = learning_rate + registration = sitk.ImageRegistrationMethod() # --- Metric --- @@ -796,8 +848,17 @@ def _record_iteration() -> None: aborted = True stop_condition = "Registration aborted before optimisation started." else: - with set_sitk_thread_count(sitk_threads): - sitk_optimized_transform = registration.Execute(fixed_reg, moving_reg) + try: + with set_sitk_thread_count(sitk_threads): + sitk_optimized_transform = registration.Execute( + fixed_reg, moving_reg + ) + except RuntimeError as exc: + raise _translate_registration_runtime_error( + exc, + transform_type=transform_type, + learning_rate=requested_learning_rate, + ) from exc executed = True aborted = effective_abort_event.is_set() stop_condition = registration.GetOptimizerStopConditionDescription() diff --git a/tests/unit/test_registration/test_volume.py b/tests/unit/test_registration/test_volume.py index 69a054f4..9f337775 100644 --- a/tests/unit/test_registration/test_volume.py +++ b/tests/unit/test_registration/test_volume.py @@ -224,6 +224,29 @@ def test_abort_event_returns_partial_result(self, sample_2d_dataarray_spatial): assert diagnostics.status == "aborted" assert diagnostics.n_iterations == 0 + def test_bspline_scale_error_raises_clearer_message( + self, sample_2d_dataarray_spatial, monkeypatch + ): + """Known SimpleITK scale failures are rewritten to actionable errors.""" + import SimpleITK as sitk + + def fake_execute(self, fixed, moving): + del self, fixed, moving + raise RuntimeError( + "Exception thrown in SimpleITK ImageRegistrationMethod_Execute: " + "ITK ERROR: GradientDescentOptimizerv4Template: " + "m_Scales values must be > epsilon.[1e-20, 1e-12]" + ) + + monkeypatch.setattr(sitk.ImageRegistrationMethod, "Execute", fake_execute) + + with pytest.raises(RuntimeError, match="could not compute valid optimizer scales"): + register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="bspline", + learning_rate=1.0, + ) class TestRegisterVolumeOutput: From 03c1085ce801d5ce4c500c72f0d05508a56468fd Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Tue, 30 Jun 2026 16:47:59 +0200 Subject: [PATCH 42/72] refactor(registration): clean progress defaults --- src/confusius/registration/volume.py | 22 +++++----- src/confusius/xarray/registration.py | 16 ++++---- .../unit/test_registration/test_volumewise.py | 41 +++++++++++++++++++ 3 files changed, 59 insertions(+), 20 deletions(-) diff --git a/src/confusius/registration/volume.py b/src/confusius/registration/volume.py index ec1c45b0..e31d4c49 100644 --- a/src/confusius/registration/volume.py +++ b/src/confusius/registration/volume.py @@ -555,18 +555,17 @@ def register_volume( Requires resampling the moving image at every iteration. Ignored when `show_progress=False`. progress_plotter : callable, optional - Factory that builds the progress reporter, called inside `register_volume` - as `progress_plotter(registration_method, fixed_img, moving_img, *, - plot_metric, plot_composite, resample_kwargs)`. Here `resample_kwargs` - carries `interpolation`, `fill_value`, and `sitk_threads`. The returned - object must + Factory that builds the progress reporter, called inside `register_volume` as + `progress_plotter(registration_method, fixed_img, moving_img, *, plot_metric, + plot_composite, resample_kwargs)`. Here `resample_kwargs` carries + `interpolation`, `fill_value`, and `sitk_threads`. The returned object must implement the - [`RegistrationProgress`][confusius.registration.RegistrationProgress] - protocol (`update()` / `close()`). If not provided, defaults to + [`RegistrationProgress`][confusius.registration.RegistrationProgress] protocol + (`update()` / `close()`). If not provided, the default [`MatplotlibRegistrationProgressPlotter`][confusius.registration.MatplotlibRegistrationProgressPlotter] - (matplotlib). Ignored when `show_progress=False`. Custom factories are expected - to be safe to call from a non-GUI thread; GUI side effects must be marshalled - via thread-safe primitives such as Qt signals. + is used. Ignored when `show_progress=False`. Custom factories are expected to + be safe to call from a non-GUI thread; GUI side effects must be marshalled via + thread-safe primitives such as Qt signals. abort_event : threading.Event, optional Cooperative cancellation flag. If set before or during optimisation, the registration stops at the next SimpleITK iteration boundary and returns @@ -813,7 +812,6 @@ def _record_iteration() -> None: if show_progress: from confusius.registration.progress import ( - RegistrationProgress, MatplotlibRegistrationProgressPlotter, ) @@ -824,7 +822,7 @@ def _record_iteration() -> None: } plotter_factory = progress_plotter or MatplotlibRegistrationProgressPlotter - plotter: RegistrationProgress = plotter_factory( + plotter = plotter_factory( registration, fixed_sitk, moving_sitk, diff --git a/src/confusius/xarray/registration.py b/src/confusius/xarray/registration.py index 42b1f211..d0cfe865 100644 --- a/src/confusius/xarray/registration.py +++ b/src/confusius/xarray/registration.py @@ -2,14 +2,14 @@ from collections.abc import Callable, Sequence from threading import Event -from typing import Literal, cast +from typing import Literal import numpy as np import numpy.typing as npt import xarray as xr -from confusius.registration.progress import RegistrationProgress from confusius.registration.diagnostics import RegistrationDiagnostics +from confusius.registration.progress import RegistrationProgress from confusius.registration.volume import register_volume from confusius.registration.volumewise import register_volumewise from confusius.registration.volumewise_progress import VolumewiseProgressReporter @@ -142,8 +142,9 @@ def to_volume( Whether to include a fixed/moving composite overlay in the progress plot. Ignored when `show_progress=False`. progress_plotter : callable, optional - Custom progress reporter factory. See - [`register_volume`][confusius.registration.register_volume]. + Custom progress reporter factory. If not provided, the default + [`MatplotlibRegistrationProgressPlotter`][confusius.registration.MatplotlibRegistrationProgressPlotter] + is used. See [`register_volume`][confusius.registration.register_volume]. abort_event : threading.Event, optional Cooperative cancellation flag. @@ -192,9 +193,7 @@ def to_volume( show_progress=show_progress, plot_metric=plot_metric, plot_composite=plot_composite, - progress_plotter=cast( - "Callable[..., RegistrationProgress] | None", progress_plotter - ), + progress_plotter=progress_plotter, abort_event=abort_event, ) @@ -277,7 +276,8 @@ def volumewise( show_progress : bool, default: True Whether to display a progress bar while registering volumes. progress_reporter : VolumewiseProgressReporter, optional - Thread-safe reporter notified whenever one frame completes. + Thread-safe reporter notified whenever one frame completes. If not + provided, no per-frame callback is used. abort_event : threading.Event, optional Cooperative cancellation flag shared across frames. keep_diagnostics : bool, default: False diff --git a/tests/unit/test_registration/test_volumewise.py b/tests/unit/test_registration/test_volumewise.py index 657323d4..e6b4a711 100644 --- a/tests/unit/test_registration/test_volumewise.py +++ b/tests/unit/test_registration/test_volumewise.py @@ -142,6 +142,47 @@ def _fake_register_volume(_volume, _ref_da, **kwargs): ) assert reporter.closed + def test_abort_during_run_skips_not_yet_started_frames( + self, sample_2d_dataarray, monkeypatch + ): + """Frames starting after abort reuse the cheap aborted-frame path.""" + abort_event = Event() + calls = {"count": 0} + + def _fake_register_volume(volume, _ref_da, **kwargs): + calls["count"] += 1 + if calls["count"] == 1: + abort_event.set() + diagnostics = RegistrationDiagnostics( + metric="correlation", + metric_values=np.asarray([-1.0]), + final_metric_value=-1.0, + n_iterations=1, + stop_condition="done", + status="completed", + ) + return volume.copy(), np.eye(3), diagnostics + + monkeypatch.setattr( + "confusius.registration.volumewise.register_volume", + _fake_register_volume, + ) + + result = register_volumewise( + sample_2d_dataarray, + n_jobs=1, + transform="translation", + show_progress=False, + abort_event=abort_event, + ) + + statuses = list(result.attrs["motion_params"]["status"]) + assert statuses[0] == "completed" + assert all(status == "aborted" for status in statuses[1:]) + + background = sample_2d_dataarray.values.min() + assert np.all(result.values[1:] == background) + def test_wrong_dimensionality_raises(self): """Data that is neither 2D+t nor 3D+t raises ValueError.""" # 1D+time = 2D total. From 7a9197cbbc64860261745b180b06c34fe7e20484 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Tue, 30 Jun 2026 17:26:34 +0200 Subject: [PATCH 43/72] test(registration): cover patch branches --- tests/unit/test_registration/test_progress.py | 107 ++++++++++++++++++ tests/unit/test_registration/test_volume.py | 50 ++++++++ .../unit/test_registration/test_volumewise.py | 28 ++++- 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_registration/test_progress.py b/tests/unit/test_registration/test_progress.py index e543f8ae..333240ed 100644 --- a/tests/unit/test_registration/test_progress.py +++ b/tests/unit/test_registration/test_progress.py @@ -1,5 +1,9 @@ """Unit tests for MatplotlibRegistrationProgressPlotter.""" +import builtins +import sys +import types + import matplotlib import numpy as np import pytest @@ -82,6 +86,31 @@ def _make_registration_method(): class TestMatplotlibRegistrationProgressPlotterInstantiation: """Smoke tests for plotter construction.""" + def test_importerror_from_ipython_detection_falls_back_to_script_mode( + self, fixed_img_2d, moving_img_2d, monkeypatch + ): + """Missing IPython support falls back cleanly to non-notebook mode.""" + reg = _make_registration_method() + original_import = builtins.__import__ + + def _guarded_import(name, *args, **kwargs): + if name == "IPython.core.getipython": + raise ImportError("no ipython") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _guarded_import) + + plotter = MatplotlibRegistrationProgressPlotter( + reg, + fixed_img_2d, + moving_img_2d, + plot_metric=True, + plot_composite=False, + ) + + assert plotter._notebook is False + plotter.figure.clf() + def test_metric_only(self, fixed_img_2d, moving_img_2d): """Plotter with only metric panel is created without error.""" reg = _make_registration_method() @@ -122,6 +151,46 @@ def test_both_panels(self, fixed_img_2d, moving_img_2d): class TestMatplotlibRegistrationProgressPlotterUpdate: """Tests for metric_values population and composite rendering.""" + def test_notebook_mode_uses_display_and_closes_figure( + self, fixed_img_2d, moving_img_2d, monkeypatch + ): + """Notebook mode renders via IPython display and closes on finish.""" + import matplotlib.pyplot as plt + + reg = _make_registration_method() + display_calls: list[tuple[object, bool]] = [] + closed_figures: list[object] = [] + + fake_getipython = types.ModuleType("IPython.core.getipython") + + class ZMQInteractiveShell: + pass + + fake_getipython.get_ipython = lambda: ZMQInteractiveShell() + fake_display = types.ModuleType("IPython.display") + fake_display.display = ( + lambda fig, clear=False: display_calls.append((fig, clear)) + ) + monkeypatch.setitem(sys.modules, "IPython.core.getipython", fake_getipython) + monkeypatch.setitem(sys.modules, "IPython.display", fake_display) + monkeypatch.setattr(plt, "close", lambda fig: closed_figures.append(fig)) + + plotter = MatplotlibRegistrationProgressPlotter( + reg, + fixed_img_2d, + moving_img_2d, + plot_metric=False, + plot_composite=True, + ) + + plotter.update() + plotter.close() + + assert display_calls + assert display_calls[-1][0] is plotter.figure + assert display_calls[-1][1] is True + assert closed_figures == [plotter.figure] + def test_metric_values_populated_after_registration( self, fixed_img_2d, moving_img_2d ): @@ -217,6 +286,44 @@ def test_3d_composite_panel_rendered(self, fixed_img_3d, moving_img_3d): class TestMatplotlibRegistrationProgressPlotterResampleKwargs: """Tests for intermediate-resample settings.""" + def test_none_interpolation_falls_back_to_linear( + self, fixed_img_2d, moving_img_2d + ): + """A `None` interpolation override falls back to linear at render time.""" + reg = _make_registration_method() + plotter = MatplotlibRegistrationProgressPlotter( + reg, + fixed_img_2d, + moving_img_2d, + plot_metric=False, + plot_composite=True, + resample_kwargs={"interpolation": None}, + ) + + plotter.update() + + assert plotter._composite_im is not None + plotter.figure.clf() + + def test_invalid_interpolation_raises_on_update( + self, fixed_img_2d, moving_img_2d + ): + """Unknown interpolation names raise a clear ValueError during rendering.""" + reg = _make_registration_method() + plotter = MatplotlibRegistrationProgressPlotter( + reg, + fixed_img_2d, + moving_img_2d, + plot_metric=False, + plot_composite=True, + resample_kwargs={"interpolation": "bogus"}, + ) + + with pytest.raises(ValueError, match="Invalid `interpolation`"): + plotter.update() + + plotter.figure.clf() + def test_default_fill_value_is_moving_min(self, fixed_img_2d, moving_img_2d): """When resample_kwargs omits fill_value, it defaults to moving_img.min().""" reg = _make_registration_method() diff --git a/tests/unit/test_registration/test_volume.py b/tests/unit/test_registration/test_volume.py index 9f337775..730cae9a 100644 --- a/tests/unit/test_registration/test_volume.py +++ b/tests/unit/test_registration/test_volume.py @@ -224,6 +224,29 @@ def test_abort_event_returns_partial_result(self, sample_2d_dataarray_spatial): assert diagnostics.status == "aborted" assert diagnostics.n_iterations == 0 + def test_unknown_runtime_error_is_passed_through( + self, sample_2d_dataarray_spatial, monkeypatch + ): + """Unknown SimpleITK runtime errors are re-raised unchanged.""" + import SimpleITK as sitk + + error = RuntimeError("boom") + + def fake_execute(self, fixed, moving): + del self, fixed, moving + raise error + + monkeypatch.setattr(sitk.ImageRegistrationMethod, "Execute", fake_execute) + + with pytest.raises(RuntimeError) as excinfo: + register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="translation", + ) + + assert excinfo.value is error + def test_bspline_scale_error_raises_clearer_message( self, sample_2d_dataarray_spatial, monkeypatch ): @@ -248,6 +271,33 @@ def fake_execute(self, fixed, moving): learning_rate=1.0, ) + def test_bspline_scale_error_with_auto_learning_rate_suggests_fixed_rate( + self, sample_2d_dataarray_spatial, monkeypatch + ): + """Auto-learning-rate scale failures suggest retrying with a fixed rate.""" + import SimpleITK as sitk + + def fake_execute(self, fixed, moving): + del self, fixed, moving + raise RuntimeError( + "Exception thrown in SimpleITK ImageRegistrationMethod_Execute: " + "ITK ERROR: GradientDescentOptimizerv4Template: " + "m_Scales values must be > epsilon.[1e-20, 1e-12]" + ) + + monkeypatch.setattr(sitk.ImageRegistrationMethod, "Execute", fake_execute) + + with pytest.raises( + RuntimeError, + match='Retry with a fixed `learning_rate` such as `0.1` or `0.01`', + ): + register_volume( + sample_2d_dataarray_spatial, + sample_2d_dataarray_spatial, + transform_type="bspline", + learning_rate="auto", + ) + class TestRegisterVolumeOutput: """Output properties for register_volume.""" diff --git a/tests/unit/test_registration/test_volumewise.py b/tests/unit/test_registration/test_volumewise.py index e6b4a711..665eb816 100644 --- a/tests/unit/test_registration/test_volumewise.py +++ b/tests/unit/test_registration/test_volumewise.py @@ -145,7 +145,9 @@ def _fake_register_volume(_volume, _ref_da, **kwargs): def test_abort_during_run_skips_not_yet_started_frames( self, sample_2d_dataarray, monkeypatch ): - """Frames starting after abort reuse the cheap aborted-frame path.""" + """Already-scheduled frames hit the cheap aborted-frame fast path.""" + import joblib + abort_event = Event() calls = {"count": 0} @@ -163,14 +165,35 @@ def _fake_register_volume(volume, _ref_da, **kwargs): ) return volume.copy(), np.eye(3), diagnostics + class _FakeParallel: + def __init__(self, *args, **kwargs): + del args, kwargs + + def __call__(self, tasks): + scheduled = list(tasks) + + def _run(): + for task in scheduled: + yield task() + + return _run() + + def _fake_delayed(func): + def _wrap(*args, **kwargs): + return lambda: func(*args, **kwargs) + + return _wrap + monkeypatch.setattr( "confusius.registration.volumewise.register_volume", _fake_register_volume, ) + monkeypatch.setattr(joblib, "Parallel", _FakeParallel) + monkeypatch.setattr(joblib, "delayed", _fake_delayed) result = register_volumewise( sample_2d_dataarray, - n_jobs=1, + n_jobs=2, transform="translation", show_progress=False, abort_event=abort_event, @@ -179,6 +202,7 @@ def _fake_register_volume(volume, _ref_da, **kwargs): statuses = list(result.attrs["motion_params"]["status"]) assert statuses[0] == "completed" assert all(status == "aborted" for status in statuses[1:]) + assert calls["count"] == 1 background = sample_2d_dataarray.values.min() assert np.all(result.values[1:] == background) From fc5f7f448a2eda66bd815fd127bf8934a36816ca Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 11:09:35 +0100 Subject: [PATCH 44/72] docs(gui): add registration panel guide --- docs/gui/overview.md | 7 +- docs/gui/plugin.md | 19 +++- docs/images/gui/generate.py | 185 ++++++++++++++++++++++++++++++++- tools/prefetch_doc_datasets.py | 2 +- 4 files changed, 206 insertions(+), 7 deletions(-) diff --git a/docs/gui/overview.md b/docs/gui/overview.md index 64e66ba8..c8ca7f93 100644 --- a/docs/gui/overview.md +++ b/docs/gui/overview.md @@ -46,9 +46,10 @@ There are two ways to start the plugin: uvx -p 3.13 confusius ``` -The widget contains three collapsible panels—[Data I/O](plugin.md#data-io-panel), -[Signals](plugin.md#signals-panel), and [QC](plugin.md#qc-panel)—that can each be -expanded or collapsed independently. If you want a quick walkthrough inside napari +The widget contains five collapsible panels—[Data I/O](plugin.md#data-io-panel), +[Video](plugin.md#video-panel), [Signals](plugin.md#signals-panel), +[QC](plugin.md#qc-panel), and [Registration](plugin.md#registration-panel)—that can +be expanded or collapsed independently. If you want a quick walkthrough inside napari itself, click **Take a Tour** in the upper-right corner of the sidebar header. !!! tip "Running napari programmatically" diff --git a/docs/gui/plugin.md b/docs/gui/plugin.md index 88ab4dd2..765bfa14 100644 --- a/docs/gui/plugin.md +++ b/docs/gui/plugin.md @@ -4,7 +4,7 @@ icon: lucide/app-window # Using the Plugin -The ConfUSIus sidebar contains four collapsible panels. Each panel operates +The ConfUSIus sidebar contains five collapsible panels. Each panel operates independently and can be expanded or collapsed by clicking its header. For an in-app introduction, click **Take a Tour** in the sidebar header. @@ -12,6 +12,7 @@ introduction, click **Take a Tour** in the sidebar header. - [**Video**](#video-panel) — load videos side-by-side, temporally synced with the fUSI acquisition. - [**Signals**](#signals-panel) — plot voxel, point, or label-region signals in a bottom dock. - [**QC**](#qc-panel) — compute DVARS, carpet, CV, tSNR for a selected layer. +- [**Registration**](#registration-panel) — run between-scan or within-scan registration, inspect progress, and save/apply transforms. ## Data I/O Panel @@ -234,3 +235,19 @@ Select a layer from the **Layer** dropdown, check the metrics you want, and clic shadow zones behind the skull can appear bright. CV correctly highlights regions with high temporal variability. See the [Quality Control guide](../user-guide/quality-control.md#temporal-snr) for a full explanation. + +## Registration Panel + +The Registration Panel runs ConfUSIus registration workflows directly from napari. +Use **Between scans** for moving/fixed registration and **Within-scan** for +frame-to-reference motion correction of a time series. The panel supports live preview +layers, a registration metric plot, cooperative aborts, and transform save/load/apply +workflows. + +![ConfUSIus Registration panel — rigid between-session angiography run](../images/gui/plugin-registration.gif) + +For between-scan registration, pick the moving and fixed layers, choose a transform +model such as `rigid`, then click **Run registration**. The guide animation below uses +between-session angiography volumes from the same animal. ConfUSIus keeps the original +layers untouched, adds dedicated preview layers for inspection, and stores the final +registered result as a new layer when the run completes. diff --git a/docs/images/gui/generate.py b/docs/images/gui/generate.py index 0e9ce144..ba651409 100644 --- a/docs/images/gui/generate.py +++ b/docs/images/gui/generate.py @@ -25,6 +25,7 @@ - `plugin-signals-labels.png` — Signals panel in labels mode. - `plugin-qc.png` — QC panel with DVARS, carpet, and CV computed. - `plugin-video.gif` — Video panel with video synced to the fUSI acquisition. +- `plugin-registration.gif` — Registration panel during a rigid between-session angiography run. Notes ----- @@ -55,6 +56,10 @@ _TASK = "spontaneous" _ACQ_SLICE = "slice04" +_REGISTRATION_SUBJECT = "CR022" +_REGISTRATION_FIXED_SESSION = "20201007" +_REGISTRATION_MOVING_SESSION = "20201011" + _SLICE_INDEX = int(_ACQ_SLICE.replace("slice", "")) _ROI_STRUCTURE_ID = 1089 @@ -232,8 +237,8 @@ def _best_matching_z_coordinate(reference_2d, volume_3d) -> float: _section("Load Data") console.print("Fetching Nunez-Elizalde 2022 dataset") bids_root = fetch_nunez_elizalde_2022( - subjects=[_SUBJECT], - sessions=[_SESSION], + subjects=[_SUBJECT, _REGISTRATION_SUBJECT], + sessions=[_SESSION, _REGISTRATION_FIXED_SESSION, _REGISTRATION_MOVING_SESSION], tasks=[_TASK], acqs=[_ACQ_SLICE], ) @@ -785,6 +790,182 @@ def _open_accordion_panel(widget, title: str): except Exception as exc: _warn(f"plugin-video.gif failed: {exc}") +# --------------------------------------------------------------------------- +# 7. Registration panel — rigid between-session GIF via the plugin +# --------------------------------------------------------------------------- + +try: + from PIL import Image as _PILImage + + from confusius._napari._registration._panel import RegistrationPanel + from confusius.plotting.napari import plot_napari + + fixed_path = ( + bids_root + / f"sub-{_REGISTRATION_SUBJECT}/ses-{_REGISTRATION_FIXED_SESSION}/angio" + / f"sub-{_REGISTRATION_SUBJECT}_ses-{_REGISTRATION_FIXED_SESSION}_pwd.nii.gz" + ) + moving_path = ( + bids_root + / f"sub-{_REGISTRATION_SUBJECT}/ses-{_REGISTRATION_MOVING_SESSION}/angio" + / f"sub-{_REGISTRATION_SUBJECT}_ses-{_REGISTRATION_MOVING_SESSION}_pwd.nii.gz" + ) + + fixed_da = cf.load(fixed_path) + moving_da = cf.load(moving_path) + registration_contrast = ( + min(float(fixed_da.min()), float(moving_da.min())), + max(float(fixed_da.quantile(0.9995)), float(moving_da.quantile(0.9995))), + ) + + viewer7 = napari.Viewer(show=False) + _viewer7, fixed_layer7 = plot_napari( + fixed_da, + viewer=viewer7, + name=f"Fixed angio ({_REGISTRATION_FIXED_SESSION})", + gamma=0.4, + colormap="red", + contrast_limits=registration_contrast, + ) + _viewer7, moving_layer7 = plot_napari( + moving_da, + viewer=viewer7, + name=f"Moving angio ({_REGISTRATION_MOVING_SESSION})", + gamma=0.4, + colormap="cyan", + blending="additive", + contrast_limits=registration_contrast, + ) + + widget7 = ConfUSIusWidget(viewer7) + viewer7.window.add_dock_widget(widget7, name="ConfUSIus", area="right") + _qt_sleep(250) + + registration_panel = widget7.findChild(RegistrationPanel) + if registration_panel is None: + raise RuntimeError("RegistrationPanel not found in ConfUSIusWidget") + + _open_accordion_panel(widget7, "Registration") + + moving_idx = registration_panel._moving_combo.findText(moving_layer7.name) + if moving_idx >= 0: + registration_panel._moving_combo.setCurrentIndex(moving_idx) + fixed_idx = registration_panel._fixed_combo.findText(fixed_layer7.name) + if fixed_idx >= 0: + registration_panel._fixed_combo.setCurrentIndex(fixed_idx) + registration_panel._single_volume_radio.setChecked(True) + registration_panel._transform_combo.setCurrentText("rigid") + registration_panel._metric_combo.setCurrentText("correlation") + scale_idx = registration_panel._scale_combo.findData("off") + if scale_idx >= 0: + registration_panel._scale_combo.setCurrentIndex(scale_idx) + registration_panel._learning_rate_auto_check.setChecked(False) + registration_panel._learning_rate_edit.setValue(0.01) + registration_panel._iterations_spin.setValue(500) + registration_panel._validate_registration_selection() + _qt_sleep(100) + + win7 = viewer7.window._qt_window + win7.setAttribute(Qt.WA_DontShowOnScreen) + win7.show() + win7.resize(1500, 950) + get_qapp().processEvents() + viewer7.reset_view() + if fixed_da.sizes.get("z", 1) > 1: + viewer7.dims.set_point(0, fixed_da.sizes["z"] // 2) + _qt_sleep(100) + + frames_pil: list = [] + + def _capture_registration_frame() -> None: + raw = viewer7.screenshot(canvas_only=False) + if raw.size == 0: + raise RuntimeError("napari returned an empty registration GIF frame") + raw = raw[..., :3] + h, w = raw.shape[:2] + gif_width = 1200 + scale = gif_width / w + frames_pil.append( + _PILImage.fromarray(raw).resize( + (gif_width, int(h * scale)), _PILImage.Resampling.LANCZOS + ) + ) + + def _capture_z_sweep(n_frames: int = 20) -> None: + if fixed_da.sizes.get("z", 1) <= 1: + return + z_mid = fixed_da.sizes["z"] // 2 + z_max = fixed_da.sizes["z"] - 1 + segments = [ + np.linspace(z_mid, z_max, n_frames), + np.linspace(z_max, z_mid, n_frames)[1:], + np.linspace(z_mid, 0, n_frames)[1:], + np.linspace(0, z_mid, n_frames)[1:], + ] + for z in np.concatenate(segments): + viewer7.dims.set_current_step(0, int(round(float(z)))) + get_qapp().processEvents() + _qt_sleep(180) + _capture_registration_frame() + + _capture_z_sweep() + if fixed_da.sizes.get("z", 1) > 1: + viewer7.dims.set_current_step(0, fixed_da.sizes["z"] // 2) + for _ in range(6): + get_qapp().processEvents() + _qt_sleep(120) + _capture_registration_frame() + + registration_panel._run_registration() + if registration_panel._worker is None: + raise RuntimeError("Registration worker did not start") + + while registration_panel._worker is not None and len(frames_pil) < 96: + if fixed_da.sizes.get("z", 1) > 1: + viewer7.dims.set_current_step(0, fixed_da.sizes["z"] // 2) + get_qapp().processEvents() + _qt_sleep(90) + _capture_registration_frame() + + while registration_panel._worker is not None: + get_qapp().processEvents() + _qt_sleep(120) + + fixed_layer7.visible = False + moving_layer7.visible = False + get_qapp().processEvents() + + if fixed_da.sizes.get("z", 1) > 1: + viewer7.dims.set_current_step(0, fixed_da.sizes["z"] // 2) + for _ in range(8): + get_qapp().processEvents() + _qt_sleep(120) + _capture_registration_frame() + + _capture_z_sweep() + if fixed_da.sizes.get("z", 1) > 1: + viewer7.dims.set_current_step(0, fixed_da.sizes["z"] // 2) + for _ in range(6): + get_qapp().processEvents() + _qt_sleep(120) + _capture_registration_frame() + + palette_src = frames_pil[0].quantize(colors=256, dither=0) + quantized = [frame.quantize(palette=palette_src, dither=0) for frame in frames_pil] + + gif_path = str(HERE / "plugin-registration.gif") + quantized[0].save( + gif_path, + save_all=True, + append_images=quantized[1:], + duration=1000 // 16, + loop=0, + ) + viewer7.close() + _ok("Saved plugin-registration.gif") +except Exception as exc: + _warn(f"plugin-registration.gif failed: {exc}") + # --------------------------------------------------------------------------- _ok("Done! Rebuild docs with `just docs` to preview changes") diff --git a/tools/prefetch_doc_datasets.py b/tools/prefetch_doc_datasets.py index e613e10c..8e2227a3 100644 --- a/tools/prefetch_doc_datasets.py +++ b/tools/prefetch_doc_datasets.py @@ -42,7 +42,7 @@ def _prefetch_nunez_elizalde() -> None: # docs/images/gui/generate.py fetch_nunez_elizalde_2022( subjects="CR022", - sessions="20201011", + sessions=["20201007", "20201011"], tasks="spontaneous", acqs="slice04", ) From 40091c4fb689a3140df5e0bc2a28003a07c502d6 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 11:31:18 +0100 Subject: [PATCH 45/72] docs(gui): add volumewise registration gif --- docs/gui/plugin.md | 7 ++ docs/images/gui/generate.py | 176 ++++++++++++++++++++++++++++++++- tools/prefetch_doc_datasets.py | 8 ++ 3 files changed, 187 insertions(+), 4 deletions(-) diff --git a/docs/gui/plugin.md b/docs/gui/plugin.md index 765bfa14..b32b3580 100644 --- a/docs/gui/plugin.md +++ b/docs/gui/plugin.md @@ -251,3 +251,10 @@ model such as `rigid`, then click **Run registration**. The guide animation belo between-session angiography volumes from the same animal. ConfUSIus keeps the original layers untouched, adds dedicated preview layers for inspection, and stores the final registered result as a new layer when the run completes. + +For within-scan motion correction, switch to **Within-scan**, choose the moving layer, +set the reference frame, and run the correction. The animation below uses a short +open-field recording chunk and shows the `Motion corrected` result filling in as frames +finish. + +![ConfUSIus Registration panel — within-scan motion correction](../images/gui/plugin-registration-volumewise.gif) diff --git a/docs/images/gui/generate.py b/docs/images/gui/generate.py index ba651409..12af43e8 100644 --- a/docs/images/gui/generate.py +++ b/docs/images/gui/generate.py @@ -26,6 +26,7 @@ - `plugin-qc.png` — QC panel with DVARS, carpet, and CV computed. - `plugin-video.gif` — Video panel with video synced to the fUSI acquisition. - `plugin-registration.gif` — Registration panel during a rigid between-session angiography run. +- `plugin-registration-volumewise.gif` — Registration panel during within-scan motion correction. Notes ----- @@ -60,6 +61,10 @@ _REGISTRATION_FIXED_SESSION = "20201007" _REGISTRATION_MOVING_SESSION = "20201011" +_VOLUMEWISE_SUBJECT = "rat75" +_VOLUMEWISE_SESSION = "20220523" +_VOLUMEWISE_ACQ_SLICE = "slice32" + _SLICE_INDEX = int(_ACQ_SLICE.replace("slice", "")) _ROI_STRUCTURE_ID = 1089 @@ -327,12 +332,12 @@ def _best_matching_z_coordinate(reference_2d, volume_3d) -> float: _VIDEO_SESSION = "20220525" _VIDEO_ACQ_SLICE = "slice37" -console.print("Fetching Cybis-Pereira 2026 dataset (for video GIF)") +console.print("Fetching Cybis-Pereira 2026 dataset (for video and registration GIFs)") video_bids_root = fetch_cybis_pereira_2026( datasets=_VIDEO_DATASETS, - subjects=[_VIDEO_SUBJECT], - sessions=[_VIDEO_SESSION], - acqs=[_VIDEO_ACQ_SLICE], + subjects=[_VIDEO_SUBJECT, _VOLUMEWISE_SUBJECT], + sessions=[_VIDEO_SESSION, _VOLUMEWISE_SESSION], + acqs=[_VIDEO_ACQ_SLICE, _VOLUMEWISE_ACQ_SLICE], ) _VIDEO_FUSI_PATH = ( @@ -966,6 +971,169 @@ def _capture_z_sweep(n_frames: int = 20) -> None: except Exception as exc: _warn(f"plugin-registration.gif failed: {exc}") +# --------------------------------------------------------------------------- +# 8. Registration panel — within-scan motion-correction GIF via the plugin +# --------------------------------------------------------------------------- + +try: + from PIL import Image as _PILImage + + from confusius._napari._registration._panel import RegistrationPanel + from confusius.plotting.napari import plot_napari + + volumewise_path = ( + video_bids_root + / f"sub-{_VOLUMEWISE_SUBJECT}/ses-{_VOLUMEWISE_SESSION}/fusi" + / f"sub-{_VOLUMEWISE_SUBJECT}_ses-{_VOLUMEWISE_SESSION}_task-openfield_acq-{_VOLUMEWISE_ACQ_SLICE}_pwd.nii.gz" + ) + + volumewise_da = cf.load(volumewise_path).isel(time=slice(220, 340)).compute() + volumewise_contrast = ( + float(volumewise_da.min()), + float(volumewise_da.quantile(0.9995)), + ) + n_frames = volumewise_da.sizes["time"] + + viewer8 = napari.Viewer(show=False) + _viewer8, moving_layer8 = plot_napari( + volumewise_da, + viewer=viewer8, + name=f"Open field ({_VOLUMEWISE_SESSION})", + gamma=0.4, + contrast_limits=volumewise_contrast, + ) + + widget8 = ConfUSIusWidget(viewer8) + viewer8.window.add_dock_widget(widget8, name="ConfUSIus", area="right") + _qt_sleep(250) + + registration_panel8 = widget8.findChild(RegistrationPanel) + if registration_panel8 is None: + raise RuntimeError("RegistrationPanel not found in ConfUSIusWidget") + + _open_accordion_panel(widget8, "Registration") + + moving_idx = registration_panel8._moving_combo.findText(moving_layer8.name) + if moving_idx >= 0: + registration_panel8._moving_combo.setCurrentIndex(moving_idx) + registration_panel8._time_series_radio.setChecked(True) + registration_panel8._transform_combo.setCurrentText("rigid") + registration_panel8._metric_combo.setCurrentText("correlation") + scale_idx = registration_panel8._scale_combo.findData("off") + if scale_idx >= 0: + registration_panel8._scale_combo.setCurrentIndex(scale_idx) + registration_panel8._reference_time_spin.setValue(n_frames // 2) + registration_panel8._learning_rate_auto_check.setChecked(False) + registration_panel8._learning_rate_edit.setValue(1.0) + registration_panel8._n_jobs_spin.setValue(-1) + registration_panel8._keep_diagnostics_check.setChecked(False) + registration_panel8._validate_registration_selection() + _qt_sleep(100) + + win8 = viewer8.window._qt_window + win8.setAttribute(Qt.WA_DontShowOnScreen) + win8.show() + win8.resize(1500, 950) + get_qapp().processEvents() + viewer8.reset_view() + viewer8.dims.set_current_step(0, 0) + _qt_sleep(100) + + frames_pil: list = [] + + def _capture_volumewise_frame() -> None: + raw = viewer8.screenshot(canvas_only=False) + if raw.size == 0: + raise RuntimeError( + "napari returned an empty volumewise registration GIF frame" + ) + raw = raw[..., :3] + h, w = raw.shape[:2] + gif_width = 1200 + scale = gif_width / w + frames_pil.append( + _PILImage.fromarray(raw).resize( + (gif_width, int(h * scale)), _PILImage.Resampling.LANCZOS + ) + ) + + def _capture_time_sweep(n_frames_sweep: int = 18) -> None: + forward = np.linspace(0, n_frames - 1, n_frames_sweep) + backward = np.linspace(n_frames - 1, 0, n_frames_sweep)[1:] + for t in np.concatenate([forward, backward]): + viewer8.dims.set_current_step(0, int(round(float(t)))) + get_qapp().processEvents() + _qt_sleep(70) + _capture_volumewise_frame() + + _capture_time_sweep() + viewer8.dims.set_current_step(0, 0) + for _ in range(4): + get_qapp().processEvents() + _qt_sleep(90) + _capture_volumewise_frame() + + registration_panel8._run_registration() + if registration_panel8._worker is None: + raise RuntimeError("Volumewise registration worker did not start") + + registration_start_frames = len(frames_pil) + while ( + registration_panel8._worker is not None + and len(frames_pil) - registration_start_frames < 140 + ): + viewer8.dims.set_current_step(0, 0) + get_qapp().processEvents() + _qt_sleep(100) + _capture_volumewise_frame() + + while registration_panel8._worker is not None: + get_qapp().processEvents() + _qt_sleep(100) + + moving_layer8.visible = False + try: + viewer8.layers["Moving"].visible = False + except KeyError: + pass + try: + motion_corrected_layer = viewer8.layers["Motion corrected"] + except KeyError: + pass + else: + motion_corrected_layer.colormap = "gray" + motion_corrected_layer.blending = "translucent_no_depth" + get_qapp().processEvents() + + viewer8.dims.set_current_step(0, n_frames // 2) + for _ in range(4): + get_qapp().processEvents() + _qt_sleep(90) + _capture_volumewise_frame() + + _capture_time_sweep() + viewer8.dims.set_current_step(0, n_frames // 2) + for _ in range(4): + get_qapp().processEvents() + _qt_sleep(90) + _capture_volumewise_frame() + + palette_src = frames_pil[0].quantize(colors=256, dither=0) + quantized = [frame.quantize(palette=palette_src, dither=0) for frame in frames_pil] + + gif_path = str(HERE / "plugin-registration-volumewise.gif") + quantized[0].save( + gif_path, + save_all=True, + append_images=quantized[1:], + duration=1000 // 16, + loop=0, + ) + viewer8.close() + _ok("Saved plugin-registration-volumewise.gif") +except Exception as exc: + _warn(f"plugin-registration-volumewise.gif failed: {exc}") + # --------------------------------------------------------------------------- _ok("Done! Rebuild docs with `just docs` to preview changes") diff --git a/tools/prefetch_doc_datasets.py b/tools/prefetch_doc_datasets.py index 8e2227a3..814bfc21 100644 --- a/tools/prefetch_doc_datasets.py +++ b/tools/prefetch_doc_datasets.py @@ -73,6 +73,14 @@ def _prefetch_cybis_pereira() -> None: acqs="slice37", ) + # docs/images/gui/generate.py (within-scan registration GIF) + fetch_cybis_pereira_2026( + datasets="rawdata", + subjects="rat75", + sessions="20220523", + acqs="slice32", + ) + # docs/examples/registration/register_volume_same_subject.py fetch_cybis_pereira_2026( datasets="rawdata", From e3098f61bddc669b844bf1fff5692c96e257d498 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 12:14:57 +0100 Subject: [PATCH 46/72] refactor(registration): hide volumewise auto rate --- docs/gui/plugin.md | 112 +++++++++++++++--- src/confusius/_napari/_registration/_panel.py | 3 +- .../test_napari/test_registration_panel.py | 6 +- 3 files changed, 103 insertions(+), 18 deletions(-) diff --git a/docs/gui/plugin.md b/docs/gui/plugin.md index b32b3580..91743f12 100644 --- a/docs/gui/plugin.md +++ b/docs/gui/plugin.md @@ -238,23 +238,105 @@ Select a layer from the **Layer** dropdown, check the metrics you want, and clic ## Registration Panel -The Registration Panel runs ConfUSIus registration workflows directly from napari. -Use **Between scans** for moving/fixed registration and **Within-scan** for -frame-to-reference motion correction of a time series. The panel supports live preview -layers, a registration metric plot, cooperative aborts, and transform save/load/apply -workflows. +The Registration Panel runs the ConfUSIus registration workflows directly from napari. +Use **Between scans** for registering different recordings, or **Within-scan** for +volume-wise motion correction within a single recording. The panel supports modifying +registration parameters, live preview, and saving/loading/applying computed transforms. + +### Between scans + +Use **Between scans** when you want to register one layer onto another, for example two +recordings from different animals or a functional recording onto an angiography. + +1. Select the **Moving layer** and **Fixed layer**. +2. Choose a transform model. +3. Optionally choose a **Scale** for to compress the intensity dynamics. +3. Optionally choose an **Initialization** transform if layers are very misaligned. +4. Click **Run registration**. + +Available transform models are: + +- `translation` for x/y/z-only shifts, +- `rigid` for translations and rotations, +- `affine` for translations, rotations, scaling, and shear, +- `bspline` for non-linear local deformations. + +For `bspline`, a staged workflow usually works best: first run `rigid` or `affine`, then +run `bspline` and select the previous transform in **Initialization**. This lets the +B-spline model refine a good global alignment instead of trying to solve both +large-scale and local deformation at once. + +#### Main parameters + +| Parameter | What it does | When it is useful | +|---|---|---| +| **Transform** | Chooses the motion model being optimized. | Start with `translation` or `rigid` for simple alignment; use `affine` for global scale/shear differences; use `bspline` only after a good global initialization. | +| **Mesh size** | Sets the B-spline control-grid density. | Increase it only when `bspline` needs to capture finer local mismatches; too fine a grid can lead to unrealistic warping. | +| **Metric** | Chooses the similarity criterion (`correlation` or `mattes_mi`). | `correlation` is a good default for power Doppler data; `mattes_mi` is more robust when intensity distributions differ. | +| **Scale** | Applies optional intensity scaling before registration. | Useful for power Doppler data where large vessels are typically overbright compared to finer structures. | +| **Initialization** | Sets the starting transform before optimization. | Use `center_geometry` or `center_moments` for coarse setup; reuse a saved/manual affine transform when you already have a good approximate alignment. | +| **Learning rate** | Sets the optimizer step size. | Lower values are safer but slower; higher values can converge faster but may create instabilities. | +| **Iterations** | Maximum number of optimizer steps. | Increase it when alignment is still improving near the end of a run. | + +#### Advanced parameters + +| Parameter | What it does | When it is useful | +|---|---|---| +| **Histogram bins** | Number of bins used by `mattes_mi`. | Tune only when using mutual information; more bins can capture finer intensity structure but may be noisier. | +| **Convergence minimum value** | Minimum optimizer improvement required to keep iterating. | Lower it when you want stricter convergence. | +| **Convergence window size** | Number of recent iterations used to test convergence. | Increase it to make convergence detection less sensitive to noise. | +| **Multi-resolution** | Runs registration from coarse to fine scales. | Usually helpful for difficult B-spline alignments or large initial offsets. | +| **Shrink factors** | Downsampling factors for each resolution level. | Use larger coarse levels when the initial mismatch is large. | +| **Smoothing sigmas** | Gaussian smoothing at each resolution level. | Helps emphasize global structure before fine alignment. | +| **Resample interp.** | Interpolation used for the registered output and previews. | `linear` is the usual default; `bspline` can give smoother resampled images. | +| **Fill value** | Value written outside the moving field of view after resampling. | Useful for controlling the appearance of padded background. | + +The animation below uses between-session angiography volumes from the same animal across +different days. ConfUSIus keeps the original layers untouched, adds dedicated preview +layers for inspection, and stores the final registered result as a new layer when the +run completes. ![ConfUSIus Registration panel — rigid between-session angiography run](../images/gui/plugin-registration.gif) -For between-scan registration, pick the moving and fixed layers, choose a transform -model such as `rigid`, then click **Run registration**. The guide animation below uses -between-session angiography volumes from the same animal. ConfUSIus keeps the original -layers untouched, adds dedicated preview layers for inspection, and stores the final -registered result as a new layer when the run completes. - -For within-scan motion correction, switch to **Within-scan**, choose the moving layer, -set the reference frame, and run the correction. The animation below uses a short -open-field recording chunk and shows the `Motion corrected` result filling in as frames -finish. +### Within-scan + +Use **Within-scan** for motion correction inside a single time series. + +1. Switch **Mode** to **Within-scan**. +2. Select the time-series **Moving layer**. +3. Choose the **Ref. time** time point used as the registration target. +4. Pick a transform model (`translation`, `rigid`, or `affine`). +5. Click **Run registration**. + +#### Main parameters + +| Parameter | What it does | When it is useful | +|---|---|---| +| **Ref. time** | Chooses the time point used as the motion-correction target. | Pick a representative, sharp frame with little motion. | +| **Transform** | Chooses the volume-wise motion model. | `rigid` is the safest starting point; `affine` is available when motion is more complex. | +| **Metric** | Chooses the volume-to-reference similarity criterion. | `correlation` is usually a good default for within-recording motion correction. | +| **Scale** | Applies optional preprocessing before registration. | Useful when an intensity transform makes anatomy more stable across time for the optimizer. | +| **Initialization** | Sets the initial volume-wise centering transform. | Most runs can use no initialization. | +| **Learning rate** | Sets the optimizer step size for each frame. | Reduce it if updates look unstable; increase it if frames are already close and convergence is too slow. | +| **Iterations** | Maximum optimizer steps per frame. | Increase it for harder motion or more flexible transforms. | + +#### Advanced parameters + +| Parameter | What it does | When it is useful | +|---|---|---| +| **Histogram bins** | Number of bins used by `mattes_mi`. | Only relevant when using mutual information. | +| **Convergence minimum value** | Minimum optimizer improvement required to keep iterating. | Lower it when you want stricter volume-wise convergence. | +| **Convergence window size** | Number of recent iterations used to test convergence. | Increase it when convergence decisions look too jittery. | +| **Multi-resolution** | Runs each frame registration from coarse to fine scales. | Helpful when motion is large or frames are noisy. | +| **Shrink factors** | Downsampling factors for each resolution level. | Useful for coarse-to-fine motion correction. | +| **Smoothing sigmas** | Gaussian smoothing at each resolution level. | Helps stabilize coarse registration before fine refinement. | +| **Resample interp.** | Interpolation used for the motion-corrected output. | Controls output smoothness. | +| **Fill value** | Value used outside the field of view after resampling. | Mostly useful for controlling output background appearance. | +| **Parallel jobs** | Number of workers used for volume-wise registration. | Increase it to speed up long runs; reduce it if your machine is already busy. `-1` uses all available CPUs | +| **Keep full traces** | Stores full volume-wise optimizer diagnostics. | Enable it only when you want detailed debugging or later inspection. | + +This workflow returns a `Motion corrected` layer and updates the progress bar as frames +finish. The animation below uses a short open-field recording chunk and shows the result +filling in progressively. ![ConfUSIus Registration panel — within-scan motion correction](../images/gui/plugin-registration-volumewise.gif) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 32ba5cbe..be6a3a48 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2125,7 +2125,7 @@ def _apply_mode_parameters( self._initialization_combo.setCurrentIndex(i) break self._learning_rate_auto_check.setChecked( - cast("bool", params["learning_rate_auto"]) + False if is_volumewise else cast("bool", params["learning_rate_auto"]) ) self._learning_rate_edit.setValue(cast("float", params["learning_rate_value"])) self._iterations_spin.setValue(cast("int", params["number_of_iterations"])) @@ -2182,6 +2182,7 @@ def _on_mode_changed(self) -> None: self._reference_time_spin.setVisible(is_volumewise) self._n_jobs_row.setVisible(is_volumewise) + self._learning_rate_auto_check.setVisible(not is_volumewise) self._fill_value_row.setVisible(not is_volumewise) self._keep_diagnostics_row.setVisible(is_volumewise) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 40d716cb..602b2012 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -133,23 +133,25 @@ def test_learning_rate_auto_disables_edit(self, registration_panel): def test_volumewise_learning_rate_defaults_to_fixed_0_01(self, registration_panel): registration_panel._time_series_radio.setChecked(True) + assert registration_panel._learning_rate_auto_check.isHidden() assert not registration_panel._learning_rate_auto_check.isChecked() assert registration_panel._learning_rate_edit.isEnabled() assert registration_panel._learning_rate_edit.value() == pytest.approx(0.01) def test_mode_switch_preserves_session_parameters(self, registration_panel): registration_panel._time_series_radio.setChecked(True) - registration_panel._learning_rate_auto_check.setChecked(True) registration_panel._learning_rate_edit.setValue(0.23) registration_panel._n_jobs_spin.setValue(3) registration_panel._scale_combo.setCurrentText("square root") registration_panel._single_volume_radio.setChecked(True) + registration_panel._learning_rate_auto_check.setChecked(True) registration_panel._learning_rate_edit.setValue(0.42) registration_panel._scale_combo.setCurrentText("none") registration_panel._time_series_radio.setChecked(True) - assert registration_panel._learning_rate_auto_check.isChecked() + assert registration_panel._learning_rate_auto_check.isHidden() + assert not registration_panel._learning_rate_auto_check.isChecked() assert registration_panel._learning_rate_edit.value() == pytest.approx(0.23) assert registration_panel._n_jobs_spin.value() == 3 assert registration_panel._scale_combo.currentText() == "square root" From 5b9a1d08ed908b5f8ea31068bdb3d5b6ff7daae1 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 12:29:29 +0100 Subject: [PATCH 47/72] refactor(napari): polish registration and signal panel layout Rename the volumewise 'Ref. time' label to 'Reference volume' to match its spinbox behaviour (clamped to the moving layer's time dimension) and update the related tooltip and docs copy. Constrain the width of the reference-volume, learning-rate, and iterations spinboxes so they don't stretch and align with the rest of the form fields. In the signals panel, regroup the y min/max spinboxes on a single row and tighten their width. --- docs/gui/plugin.md | 4 +- src/confusius/_napari/_registration/_panel.py | 10 +++-- src/confusius/_napari/_signals/_panel.py | 41 ++++++++++--------- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/docs/gui/plugin.md b/docs/gui/plugin.md index 91743f12..c29cea65 100644 --- a/docs/gui/plugin.md +++ b/docs/gui/plugin.md @@ -304,7 +304,7 @@ Use **Within-scan** for motion correction inside a single time series. 1. Switch **Mode** to **Within-scan**. 2. Select the time-series **Moving layer**. -3. Choose the **Ref. time** time point used as the registration target. +3. Choose the **Reference volume** index used as the registration target. 4. Pick a transform model (`translation`, `rigid`, or `affine`). 5. Click **Run registration**. @@ -312,7 +312,7 @@ Use **Within-scan** for motion correction inside a single time series. | Parameter | What it does | When it is useful | |---|---|---| -| **Ref. time** | Chooses the time point used as the motion-correction target. | Pick a representative, sharp frame with little motion. | +| **Reference volume** | Chooses the volume index used as the motion-correction target. | Pick a representative, sharp frame with little motion. | | **Transform** | Chooses the volume-wise motion model. | `rigid` is the safest starting point; `affine` is available when motion is more complex. | | **Metric** | Chooses the volume-to-reference similarity criterion. | `correlation` is usually a good default for within-recording motion correction. | | **Scale** | Applies optional preprocessing before registration. | Useful when an intensity transform makes anatomy more stable across time for the optimizer. | diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index be6a3a48..69e02f62 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -970,11 +970,12 @@ def _setup_ui(self) -> None: ) operation_layout.addRow(self._fixed_label, self._fixed_combo) - self._reference_time_label = QLabel("Ref. time") + self._reference_time_label = QLabel("Reference volume") self._reference_time_spin = QSpinBox() self._reference_time_spin.setMinimum(0) + self._reference_time_spin.setMaximumWidth(64) self._reference_time_label.setToolTip( - "Time index used as the registration target for within-scan motion correction." + "Volume index used as the registration target for within-scan motion correction." ) operation_layout.addRow(self._reference_time_label, self._reference_time_spin) @@ -1121,12 +1122,14 @@ def _setup_ui(self) -> None: self._learning_rate_edit.setToolTip( "Optimizer step size. Accepts decimal (0.1) or scientific notation (1e-5)." ) + self._learning_rate_edit.setMaximumWidth(96) self._learning_rate_edit.setEnabled(False) self._learning_rate_auto_check.toggled.connect( lambda checked: self._learning_rate_edit.setEnabled(not checked) ) learning_rate_row.addWidget(self._learning_rate_auto_check) - learning_rate_row.addWidget(self._learning_rate_edit, stretch=1) + learning_rate_row.addWidget(self._learning_rate_edit) + learning_rate_row.addStretch(1) params_layout.addRow( self._make_form_label( "Learning rate", @@ -1139,6 +1142,7 @@ def _setup_ui(self) -> None: self._iterations_spin.setRange(1, 100_000) self._iterations_spin.setSingleStep(100) self._iterations_spin.setValue(100) + self._iterations_spin.setMaximumWidth(96) params_layout.addRow( self._make_form_label( "Iterations", diff --git a/src/confusius/_napari/_signals/_panel.py b/src/confusius/_napari/_signals/_panel.py index 42808ea1..05246382 100644 --- a/src/confusius/_napari/_signals/_panel.py +++ b/src/confusius/_napari/_signals/_panel.py @@ -146,7 +146,7 @@ def _setup_ui(self) -> None: # X-axis dimension selection. xaxis_row = QHBoxLayout() - xaxis_label = QLabel("x-axis:") + xaxis_label = QLabel("x-axis") xaxis_label.setTextFormat(Qt.TextFormat.RichText) xaxis_row.addWidget(xaxis_label) self._xaxis_combo = QComboBox() @@ -161,25 +161,6 @@ def _setup_ui(self) -> None: xaxis_row.addWidget(self._xaxis_combo, stretch=1) axis_layout.addLayout(xaxis_row) - spinbox: list[QDoubleSpinBox] = [] - for lim in ("min", "max"): - ylim_layout = QHBoxLayout() - ylim_label = QLabel(f"y {lim}:") - ylim_label.setTextFormat(Qt.TextFormat.RichText) - ylim_layout.addWidget(ylim_label) - spin = QDoubleSpinBox() - spin.setObjectName(f"y{lim}_spin") - spin.setAlignment(Qt.AlignmentFlag.AlignCenter) - spin.setRange(-1e9, 1e9) - spin.setValue(-1.0 if lim == "min" else 1.0) - spin.valueChanged.connect(self._apply_settings) - spinbox.append(spin) - ylim_layout.addWidget(spin) - - axis_layout.addLayout(ylim_layout) - - self._ymin_spin, self._ymax_spin = spinbox - # Autoscale checkbox. QCheckBox does not support rich text, so we pair a # text-less checkbox with a clickable QLabel to get the italic "y". autoscale_row = QHBoxLayout() @@ -194,6 +175,26 @@ def _setup_ui(self) -> None: autoscale_row.addStretch() axis_layout.addLayout(autoscale_row) + yminmax_row = QHBoxLayout() + spinbox: list[QDoubleSpinBox] = [] + for lim in ("min", "max"): + ylim_label = QLabel(f"y {lim}") + ylim_label.setTextFormat(Qt.TextFormat.RichText) + yminmax_row.addWidget(ylim_label) + spin = QDoubleSpinBox() + spin.setObjectName(f"y{lim}_spin") + spin.setAlignment(Qt.AlignmentFlag.AlignCenter) + spin.setRange(-1e9, 1e9) + spin.setValue(-1.0 if lim == "min" else 1.0) + spin.setMaximumWidth(96) + spin.valueChanged.connect(self._apply_settings) + spinbox.append(spin) + yminmax_row.addWidget(spin) + yminmax_row.addStretch(1) + axis_layout.addLayout(yminmax_row) + + self._ymin_spin, self._ymax_spin = spinbox + # Apply initial autoscale state so spinboxes start disabled. self._on_autoscale_changed(True) From 4548b1251f08e89dfe2962d3632d9de2171b4527 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 15:07:19 +0100 Subject: [PATCH 48/72] chore: remove some ty ignores --- src/confusius/_napari/_registration/_panel.py | 28 +++++++++---------- src/confusius/_napari/_signals/_panel.py | 8 +++++- src/confusius/_napari/_signals/_plotter.py | 6 ++-- src/confusius/registration/bspline.py | 17 ++++++----- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 69e02f62..cb811d08 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -402,14 +402,14 @@ def __init__(self, parent: QWidget | None = None) -> None: self.setAccelerated(True) self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) - def validate( # ty: ignore[invalid-method-override] - self, text: str | None, pos: int + def validate( + self, input: str | None, pos: int ) -> tuple[QValidator.State, str, int]: """Validate decimals and scientific notation while the user types. Parameters ---------- - text : str, optional + input : str, optional Current text being edited. pos : int Cursor position. @@ -423,7 +423,7 @@ def validate( # ty: ignore[invalid-method-override] pos : int Cursor position. """ - normalized = text or "" + normalized = input or "" if normalized in {"", "+", "-", ".", "+.", "-."}: return (QValidator.State.Intermediate, normalized, pos) if self._ACCEPTABLE_RE.match(normalized).hasMatch(): @@ -432,7 +432,7 @@ def validate( # ty: ignore[invalid-method-override] return (QValidator.State.Intermediate, normalized, pos) return (QValidator.State.Invalid, normalized, pos) - def valueFromText(self, text): + def valueFromText(self, text: str | None) -> float: """Parse the current text into a float value. Parameters @@ -447,12 +447,12 @@ def valueFromText(self, text): """ return float(text or 0.0) - def textFromValue(self, value: float) -> str: # ty: ignore[invalid-method-override] + def textFromValue(self, v: float) -> str: """Format values compactly, using scientific notation when helpful. Parameters ---------- - value : float + v : float Value to format. Returns @@ -460,7 +460,7 @@ def textFromValue(self, value: float) -> str: # ty: ignore[invalid-method-overr str Formatted text. """ - return f"{value:.12g}" + return f"{v:.12g}" def stepBy(self, steps: int) -> None: """Apply additive stepping using the configured single-step size. @@ -2274,7 +2274,7 @@ def _setup_volumewise_progress( **moving_display_kwargs, ) else: - moving_preview_layer.data = np.asarray(moving.data) # type: ignore[invalid-assignment] + cast("Any", moving_preview_layer).data = np.asarray(moving.data) moving_preview_layer.colormap = moving_display_kwargs["colormap"] moving_preview_layer.gamma = cast( "float", moving_display_kwargs.get("gamma", 1.0) @@ -2480,7 +2480,7 @@ def _setup_volume_progress( **fixed_display_kwargs, ) else: - fixed_preview_layer.data = np.asarray(fixed.data) # type: ignore[invalid-assignment] + cast("Any", fixed_preview_layer).data = np.asarray(fixed.data) fixed_preview_layer.colormap = fixed_display_kwargs["colormap"] fixed_preview_layer.gamma = cast( "float", fixed_display_kwargs.get("gamma", 1.0) @@ -2502,7 +2502,7 @@ def _setup_volume_progress( **moving_display_kwargs, ) else: - moving_preview_layer.data = np.asarray(preview.data) # type: ignore[invalid-assignment] + cast("Any", moving_preview_layer).data = np.asarray(preview.data) moving_preview_layer.colormap = moving_display_kwargs["colormap"] moving_preview_layer.blending = moving_display_kwargs["blending"] moving_preview_layer.gamma = cast( @@ -2564,7 +2564,7 @@ def _update_progress_layer(self, arr: object) -> None: return if arr.shape != layer.data.shape: return - layer.data = arr # type: ignore[invalid-assignment] + cast("Any", layer).data = arr def _teardown_volume_progress(self) -> None: """Remove the progress preview layer and bridge references, if any. @@ -3087,7 +3087,7 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non if operation == "register_volume" and self._progress_layer is not None: layer = self._progress_layer - layer.data = np.asarray(registered.data) # type: ignore[invalid-assignment] + cast("Any", layer).data = np.asarray(registered.data) if hasattr(layer, "contrast_limits"): layer.contrast_limits = contrast_limits self._progress_bridge = None @@ -3097,7 +3097,7 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non and self._volumewise_progress_layer is not None ): layer = self._volumewise_progress_layer - layer.data = np.asarray(registered.data) # type: ignore[invalid-assignment] + cast("Any", layer).data = np.asarray(registered.data) if hasattr(layer, "contrast_limits"): layer.contrast_limits = contrast_limits self._teardown_volumewise_progress(remove_layer=False) diff --git a/src/confusius/_napari/_signals/_panel.py b/src/confusius/_napari/_signals/_panel.py index 05246382..60ee8629 100644 --- a/src/confusius/_napari/_signals/_panel.py +++ b/src/confusius/_napari/_signals/_panel.py @@ -3,6 +3,8 @@ from __future__ import annotations import napari +from typing import Any, cast + from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( QButtonGroup, @@ -169,7 +171,11 @@ def _setup_ui(self) -> None: self._autoscale_check.toggled.connect(self._on_autoscale_changed) autoscale_label = QLabel("Autoscale y-axis") autoscale_label.setTextFormat(Qt.TextFormat.RichText) - autoscale_label.mousePressEvent = lambda _e: self._autoscale_check.toggle() # type: ignore[method-assign] + setattr( + cast("Any", autoscale_label), + "mousePressEvent", + lambda _e: self._autoscale_check.toggle(), + ) autoscale_row.addWidget(self._autoscale_check) autoscale_row.addWidget(autoscale_label) autoscale_row.addStretch() diff --git a/src/confusius/_napari/_signals/_plotter.py b/src/confusius/_napari/_signals/_plotter.py index 3a03eb5b..5e13a9e5 100644 --- a/src/confusius/_napari/_signals/_plotter.py +++ b/src/confusius/_napari/_signals/_plotter.py @@ -774,14 +774,16 @@ def _extract_signals(self, layer, cursor_pos: np.ndarray) -> np.ndarray | None: Always uses the nearest voxel to the cursor position. """ data = layer.data - ind = list(int(round(x)) for x in layer.world_to_data(cursor_pos)) + ind: list[int | slice] = [ + int(round(x)) for x in layer.world_to_data(cursor_pos) + ] xaxis_index = self._xaxis_dim_index(layer) # Replace the x-axis index before bounds-checking: the injected x-axis world # coordinate (typically 0) may fall outside the data range (e.g. when the # coordinate starts at a non-zero offset), which would cause the check to # reject valid spatial positions. - ind[xaxis_index] = slice(None) # type: ignore[call-overload] + ind[xaxis_index] = slice(None) if not all( 0 <= i < max_i for i, max_i in zip(ind, data.shape) if isinstance(i, int) diff --git a/src/confusius/registration/bspline.py b/src/confusius/registration/bspline.py index faf25abb..dded3e89 100644 --- a/src/confusius/registration/bspline.py +++ b/src/confusius/registration/bspline.py @@ -30,7 +30,7 @@ the smooth deformation. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import numpy as np import numpy.typing as npt @@ -227,13 +227,16 @@ def _extract_bspline(transform: "sitk.Transform") -> "sitk.BSplineTransform": name = transform.GetName() if "BSpline" in name: - return transform # type: ignore[return-value] + return cast("sitk.BSplineTransform", transform) if name == "CompositeTransform": - n = transform.GetNumberOfTransforms() # type: ignore[attr-defined] - # The B-spline is the last sub-transform (it was added last and is optimised). - last = transform.GetNthTransform(n - 1) # type: ignore[attr-defined] - if "BSpline" in last.GetName(): - return last + get_count = getattr(transform, "GetNumberOfTransforms", None) + get_nth = getattr(transform, "GetNthTransform", None) + if callable(get_count) and callable(get_nth): + n = int(get_count()) + # The B-spline is the last sub-transform (it was added last and is optimised). + last = get_nth(n - 1) + if "BSpline" in last.GetName(): + return cast("sitk.BSplineTransform", last) raise TypeError( f"Expected a BSplineTransform or a CompositeTransform ending with a " f"BSplineTransform; got {transform.GetName()!r}." From f4c082324686a3300279500c30f3caf0c011d8c0 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 15:30:56 +0100 Subject: [PATCH 49/72] refactor(registration): split panel helpers --- src/confusius/_napari/_registration/_panel.py | 758 +----------------- .../_registration/_panel_transform_helpers.py | 161 ++++ .../_napari/_registration/_panel_utils.py | 414 ++++++++++ .../_napari/_registration/_panel_workers.py | 214 +++++ 4 files changed, 812 insertions(+), 735 deletions(-) create mode 100644 src/confusius/_napari/_registration/_panel_transform_helpers.py create mode 100644 src/confusius/_napari/_registration/_panel_utils.py create mode 100644 src/confusius/_napari/_registration/_panel_workers.py diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index cb811d08..18acc7cb 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2,19 +2,17 @@ from __future__ import annotations -from collections.abc import Callable, Iterator -from contextlib import contextmanager +from collections.abc import Callable from pathlib import Path from threading import Event -from typing import TYPE_CHECKING, Any, Literal, Sequence, cast +from typing import TYPE_CHECKING, Any, Literal, cast import numpy as np import xarray as xr from napari.layers.utils.layer_utils import calc_data_range from napari.qt.threading import thread_worker from napari.utils.notifications import show_error, show_info -from qtpy.QtCore import QRegularExpression, Qt, QTimer -from qtpy.QtGui import QValidator +from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( QApplication, QButtonGroup, @@ -62,12 +60,9 @@ ) from confusius.plotting.napari import plot_napari from confusius.registration import ( - register_volume, - register_volumewise, resample_like, resample_volume, ) -from confusius.xarray.scale import db_scale, power_scale if TYPE_CHECKING: import napari @@ -77,733 +72,26 @@ from confusius.registration import RegistrationDiagnostics, RegistrationProgress -@contextmanager -def _preserve_view(viewer: "napari.Viewer") -> Iterator[None]: - """Keep the viewer camera and dims state across a block that adds layers. - - Adding image layers makes napari recompute `camera.center` and re-apply - `napari.imshow`'s default `ndisplay`/`order` to the dims, which yanks the - canvas back to a default framing. Wrapping the layer creation in this - context manager snapshots the current pan, zoom, rotation, and slider - position and restores them once the block exits, so the user keeps the view - they were on when starting a registration run. - - Parameters - ---------- - viewer : napari.Viewer - Viewer whose camera and dims state are snapshotted and restored. - - Yields - ------ - None - Control returns to the wrapped block; the saved state is restored when - it exits, including on early return or exception. - """ - camera = viewer.camera - dims = viewer.dims - center = tuple(camera.center) - zoom = camera.zoom - angles = tuple(camera.angles) - ndisplay = dims.ndisplay - order = tuple(dims.order) - current_step = tuple(dims.current_step) - try: - yield - finally: - dims.ndisplay = ndisplay - dims.order = order - dims.current_step = current_step - camera.center = center - camera.zoom = zoom - camera.angles = angles - - -def _default_dims_for_ndim(ndim: int) -> tuple[str, ...]: - """Return fallback dimension names for a raw napari layer. - - Parameters - ---------- - ndim : int - Number of array dimensions. - - Returns - ------- - tuple of str - Default dimension names compatible with ConfUSIus conventions when - possible. - """ - defaults: dict[int, tuple[str, ...]] = { - 1: SPATIAL_DIMS[-1:], - 2: SPATIAL_DIMS[-2:], - 3: SPATIAL_DIMS, - 4: (TIME_DIM, *SPATIAL_DIMS), - } - return defaults.get(ndim, tuple(f"dim{i}" for i in range(ndim))) - - -def _normalize_layer_sequence(values: Any, ndim: int, fill: Any) -> list[Any]: - """Return a layer property as a list with length `ndim`. - - Parameters - ---------- - values : Any - Layer property such as `scale`, `translate`, `units`, or - `axis_labels`. - ndim : int - Number of dimensions expected on the layer data. - fill : Any - Value used to pad missing entries. - - Returns - ------- - list of Any - Normalized sequence with exactly `ndim` elements. - """ - if values is None: - return [fill] * ndim - seq = list(values) - if len(seq) < ndim: - return ([fill] * (ndim - len(seq))) + seq - if len(seq) > ndim: - return seq[-ndim:] - return seq - - -def _reconstruct_layer_dataarray(layer: "Layer") -> xr.DataArray: - """Reconstruct a DataArray from the current napari layer state. - - Parameters - ---------- - layer : napari.layers.Layer - Napari layer to convert. - - Returns - ------- - xarray.DataArray - DataArray reconstructed from the layer's current axis labels, - scale, translate, and units. - """ - data = np.asarray(layer.data) - ndim = data.ndim - - raw_labels = _normalize_layer_sequence( - getattr(layer, "axis_labels", None), ndim, None - ) - axis_labels = tuple( - str(label) if label not in (None, "") else default - for label, default in zip( - raw_labels, _default_dims_for_ndim(ndim), strict=False - ) - ) - - scale = [ - float(v) - for v in _normalize_layer_sequence(getattr(layer, "scale", None), ndim, 1.0) - ] - translate = [ - float(v) - for v in _normalize_layer_sequence(getattr(layer, "translate", None), ndim, 0.0) - ] - raw_units = _normalize_layer_sequence(getattr(layer, "units", None), ndim, None) - units = [None if u is None or str(u) == "pixel" else str(u) for u in raw_units] - - coords: dict[str, xr.DataArray] = {} - for dim, n, spacing, origin, unit in zip( - axis_labels, data.shape, scale, translate, units, strict=False - ): - attrs: dict[str, Any] = {"voxdim": abs(spacing)} - if unit is not None: - attrs["units"] = unit - coords[dim] = xr.DataArray( - origin + np.arange(n) * spacing, dims=[dim], attrs=attrs - ) - - return xr.DataArray(data, dims=axis_labels, coords=coords) - - -def _layer_supports_registration_source(layer: "Layer") -> bool: - """Return whether `layer` can be converted to a registration source. - - ConfUSIus-managed layers carry the original `xarray.DataArray` in metadata. - For plain napari image layers we can reconstruct one from eager NumPy data. - Lazy non-NumPy layers (for example the video panel's frame-on-demand array) - are intentionally excluded: forcing `np.asarray` on them can trigger expensive - decoding or backend errors while the registration panel is merely refreshing. - """ - if layer.metadata.get("xarray") is not None: - return True - if layer.metadata.get("confusius_cached_registration_xarray") is not None: - return True - return isinstance(layer.data, np.ndarray) - - -def _get_source_dataarray(layer: "Layer") -> xr.DataArray: - """Return the stable source DataArray for a napari layer. - - Parameters - ---------- - layer : napari.layers.Layer - Napari layer to convert. - - Returns - ------- - xarray.DataArray - Original ConfUSIus DataArray when present in `layer.metadata`, - otherwise a cached reconstruction captured before later manual napari - transforms mutate the layer pose. - - Raises - ------ - TypeError - If the layer is backed by a lazy non-NumPy array that the registration - panel should ignore. - """ - existing = layer.metadata.get("xarray") - if existing is not None: - return cast("xr.DataArray", existing) - - cached = layer.metadata.get("confusius_cached_registration_xarray") - if cached is not None: - return cast("xr.DataArray", cached) - - if not isinstance(layer.data, np.ndarray): - raise TypeError( - f"Layer {layer.name!r} is not backed by eager NumPy data and cannot be used " - "for registration." - ) - - reconstructed = _reconstruct_layer_dataarray(layer) - layer.metadata["confusius_cached_registration_xarray"] = reconstructed - return reconstructed - - -def _prepare_between_scan_data(data: xr.DataArray) -> xr.DataArray: - """Return a spatial-only DataArray for between-scan registration. - - Parameters - ---------- - data : xarray.DataArray - Input layer data. - - Returns - ------- - xarray.DataArray - Spatial-only data. If the input has a time dimension, it is averaged - over time with attributes preserved. - """ - if TIME_DIM not in data.dims: - return data - averaged = data.mean(dim=TIME_DIM, keep_attrs=True) - averaged.attrs = data.attrs.copy() - return averaged - - -def _apply_registration_scale( - data: xr.DataArray, scale_mode: Literal["off", "dB", "sqrt"] -) -> xr.DataArray: - """Apply optional intensity preprocessing for registration. - - Parameters - ---------- - data : xarray.DataArray - Input data. - scale_mode : {"off", "dB", "sqrt"} - Intensity scaling mode used before registration. - - Returns - ------- - xarray.DataArray - Preprocessed data. - - Raises - ------ - ValueError - If `scale_mode` is not recognized. - """ - if scale_mode == "off": - return data - if scale_mode == "dB": - return db_scale(data) - if scale_mode == "sqrt": - return power_scale(data, exponent=0.5) - raise ValueError(f"Unknown registration scale mode: {scale_mode}.") - - -def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: - """Return image-display kwargs copied from an existing napari layer. - - Parameters - ---------- - layer : napari.layers.Layer - Source layer whose visual settings should be reused when possible. - - Returns - ------- - dict[str, Any] - Keyword arguments suitable for [`plot_napari`][confusius.plotting.plot_napari]. - """ - kwargs: dict[str, Any] = {} - for attr in ("colormap", "gamma", "opacity"): - if hasattr(layer, attr): - kwargs[attr] = getattr(layer, attr) - return kwargs - - -def _should_reset_gamma(scale_mode: str) -> bool: - """Return whether registration preview/result gamma should be reset. - - Parameters - ---------- - scale_mode : str - Registration intensity scaling mode. - - Returns - ------- - bool - Whether preview/result layers should force `gamma=1.0`. - """ - return scale_mode != "off" - - -def _parse_sequence(text: str, expected_len: int = 3) -> tuple[int, ...]: - """Parse comma-separated integers from a text field.""" - parts = [p.strip() for p in text.split(",") if p.strip()] - if not parts: - return tuple() - try: - values = tuple(int(float(p)) for p in parts) - except ValueError: - return tuple() - if len(values) != expected_len: - return tuple() - return values - - -class ScientificDoubleSpinBox(QDoubleSpinBox): - """`QDoubleSpinBox` variant that accepts scientific notation. - - Parameters - ---------- - parent : QWidget, optional - Parent widget. - """ - - _ACCEPTABLE_RE = QRegularExpression( - r"^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?$" - ) - _INTERMEDIATE_RE = QRegularExpression( - r"^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))?(?:[eE][+-]?\d*)?$" - ) - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setDecimals(10) - self.setKeyboardTracking(False) - self.setAccelerated(True) - self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) - - def validate( - self, input: str | None, pos: int - ) -> tuple[QValidator.State, str, int]: - """Validate decimals and scientific notation while the user types. - - Parameters - ---------- - input : str, optional - Current text being edited. - pos : int - Cursor position. - - Returns - ------- - state : QValidator.State - Validation state. - text : str - Normalized text. - pos : int - Cursor position. - """ - normalized = input or "" - if normalized in {"", "+", "-", ".", "+.", "-."}: - return (QValidator.State.Intermediate, normalized, pos) - if self._ACCEPTABLE_RE.match(normalized).hasMatch(): - return (QValidator.State.Acceptable, normalized, pos) - if self._INTERMEDIATE_RE.match(normalized).hasMatch(): - return (QValidator.State.Intermediate, normalized, pos) - return (QValidator.State.Invalid, normalized, pos) - - def valueFromText(self, text: str | None) -> float: - """Parse the current text into a float value. - - Parameters - ---------- - text : str, optional - Text to parse. - - Returns - ------- - float - Parsed numeric value. - """ - return float(text or 0.0) - - def textFromValue(self, v: float) -> str: - """Format values compactly, using scientific notation when helpful. - - Parameters - ---------- - v : float - Value to format. - - Returns - ------- - str - Formatted text. - """ - return f"{v:.12g}" - - def stepBy(self, steps: int) -> None: - """Apply additive stepping using the configured single-step size. - - Parameters - ---------- - steps : int - Number of steps to apply. - """ - self.setValue(self.value() + (steps * self.singleStep())) - - -def _run_register_volume( - moving: xr.DataArray, - fixed: xr.DataArray, - *, - transform_type: Literal["translation", "rigid", "affine", "bspline"], - metric: Literal["correlation", "mattes_mi"], - learning_rate: float | Literal["auto"], - number_of_iterations: int, - use_multi_resolution: bool, - resample_interpolation: Literal["linear", "bspline"], - mesh_size: tuple[int, int, int] = (10, 10, 10), - number_of_histogram_bins: int = 50, - convergence_minimum_value: float = 1e-6, - convergence_window_size: int = 10, - center_initialization: Literal["center_geometry", "center_moments"] - | None = "center_geometry", - initial_transform: npt.NDArray[np.floating] | None = None, - shrink_factors: Sequence[int] = (6, 2, 1), - smoothing_sigmas: Sequence[int] = (6, 2, 1), - fill_value: float | None = None, - progress_plotter: Callable[..., RegistrationProgress] | None = None, - abort_event: Event | None = None, -) -> tuple[ - xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics -]: - """Run `register_volume` from the GUI. - - Parameters - ---------- - moving : xarray.DataArray - Moving volume. - fixed : xarray.DataArray - Fixed reference volume. - transform_type : {"translation", "rigid", "affine", "bspline"} - Registration model. - metric : {"correlation", "mattes_mi"} - Similarity metric. - learning_rate : float or {"auto"} - Optimizer learning rate. - number_of_iterations : int - Maximum number of optimizer iterations. - use_multi_resolution : bool - Whether to enable the registration pyramid. - resample_interpolation : {"linear", "bspline"} - Interpolator for the resampled output. - mesh_size : tuple of int, default: (10, 10, 10) - B-spline mesh size. - number_of_histogram_bins : int - Histogram bins for Mattes MI metric. - convergence_minimum_value : float - Convergence threshold. - convergence_window_size : int - Window size for convergence estimation. - center_initialization : {"center_geometry", "center_moments"} or None - Center-based transform initializer. - initial_transform : numpy.ndarray, optional - Pre-computed affine transform used as a warm start before optimization. - shrink_factors : sequence of int - Shrink factors per resolution level. - smoothing_sigmas : sequence of int - Smoothing sigmas per resolution level. - fill_value : float or None - Fill value for resampled output outside input domain. - progress_plotter : callable, optional - Optional progress-plotter factory forwarded to `register_volume`. - abort_event : threading.Event, optional - Cooperative cancellation flag forwarded to `register_volume`. - - Returns - ------- - registered : xarray.DataArray - Resampled registered volume. - transform : numpy.ndarray or xarray.DataArray - Estimated transform. - diagnostics : confusius.registration.RegistrationDiagnostics - Optimizer diagnostics. - """ - return register_volume( - moving, - fixed, - transform_type=transform_type, - metric=metric, - learning_rate=learning_rate, - number_of_iterations=number_of_iterations, - use_multi_resolution=use_multi_resolution, - resample=True, - resample_interpolation=resample_interpolation, - mesh_size=mesh_size, - number_of_histogram_bins=number_of_histogram_bins, - convergence_minimum_value=convergence_minimum_value, - convergence_window_size=convergence_window_size, - initialization=( - center_initialization if initial_transform is None else initial_transform - ), - shrink_factors=shrink_factors, - smoothing_sigmas=smoothing_sigmas, - fill_value=fill_value, - show_progress=progress_plotter is not None, - progress_plotter=progress_plotter, - abort_event=abort_event, - ) - - -def _affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: - """Return the stored affine transform payload for a napari layer. - - Parameters - ---------- - layer : napari.layers.Layer - Layer whose metadata should be inspected. - - Returns - ------- - AffineTransformPayload or None - Stored payload when present and affine, otherwise `None`. - """ - payload = layer.metadata.get("confusius_transform") - if not isinstance(payload, dict) or payload.get("kind") != "affine": - return None - affine_transform_from_payload(payload) - return cast("AffineTransformPayload", payload) - - -def _spatial_manual_affine_from_layer( - layer: "Layer", *, spatial_dims: Sequence[str] -) -> npt.NDArray[np.float64]: - """Return the spatial sub-affine from a napari layer's manual transform. - - Parameters - ---------- - layer : napari.layers.Layer - Layer whose manual napari affine should be extracted. - spatial_dims : sequence of str - Spatial dimension names, in the exact order expected by registration. - - Returns - ------- - (N+1, N+1) numpy.ndarray - Spatial homogeneous affine in world coordinates. - - Raises - ------ - ValueError - If the layer does not contain the requested spatial dimensions. - ValueError - If the layer affine has an unexpected shape. - ValueError - If the manual affine mixes selected spatial axes with ignored axes. - """ - data = _get_source_dataarray(layer) - layer_dims = [str(dim) for dim in data.dims] - missing_dims = [dim for dim in spatial_dims if dim not in layer_dims] - if missing_dims: - raise ValueError( - "Selected manual napari transform does not contain spatial dims " - f"{missing_dims}." - ) - - affine = np.asarray(layer.affine.affine_matrix, dtype=float) - expected_shape = (len(layer_dims) + 1, len(layer_dims) + 1) - if affine.shape != expected_shape: - raise ValueError( - f"Selected manual napari transform has shape {affine.shape}, " - f"but layer '{layer.name}' expects {expected_shape}." - ) - - spatial_indices = [layer_dims.index(dim) for dim in spatial_dims] - ignored_indices = [i for i in range(len(layer_dims)) if i not in spatial_indices] - linear = affine[:-1, :-1] - - if ignored_indices: - spatial_to_ignored = linear[np.ix_(spatial_indices, ignored_indices)] - ignored_to_spatial = linear[np.ix_(ignored_indices, spatial_indices)] - if not np.allclose(spatial_to_ignored, 0.0) or not np.allclose( - ignored_to_spatial, 0.0 - ): - raise ValueError( - "Selected manual napari transform mixes spatial axes with ignored " - "non-spatial axes, so it cannot be used as a registration " - "initialization." - ) - - spatial_affine = np.eye(len(spatial_dims) + 1, dtype=float) - spatial_affine[:-1, :-1] = linear[np.ix_(spatial_indices, spatial_indices)] - spatial_affine[:-1, -1] = affine[np.ix_(spatial_indices, [-1])].ravel() - return spatial_affine - - -def _make_manual_transform_payload(layer: "Layer") -> AffineTransformPayload: - """Build an affine payload from a layer's manual napari transform. - - Parameters - ---------- - layer : napari.layers.Layer - Layer whose current manual napari transform should be serialized. - - Returns - ------- - AffineTransformPayload - JSON-serializable affine payload representing the visible manual layer - transform on the layer's own spatial output grid. - """ - data = _get_source_dataarray(layer) - spatial_data = _prepare_between_scan_data(data) - spatial_dims = [str(dim) for dim in spatial_data.dims if dim in SPATIAL_DIMS] - manual_affine = _spatial_manual_affine_from_layer(layer, spatial_dims=spatial_dims) - pull_affine = np.linalg.inv(manual_affine) - return { - "kind": "affine", - "name": f"{layer.name} (manual)", - "affine": pull_affine.tolist(), - "source_layer_name": layer.name, - "target_layer_name": layer.name, - "operation": "manual_napari_transform", - "transform_model": "affine", - "metric": "manual", - "output_grid": { - "dims": [str(dim) for dim in spatial_data.dims], - "shape": [int(spatial_data.sizes[dim]) for dim in spatial_data.dims], - "spacing": [ - float(spatial_data.fusi.spacing[dim]) for dim in spatial_data.dims - ], - "origin": [ - float(spatial_data.fusi.origin[dim]) for dim in spatial_data.dims - ], - "units": [ - cast("str | None", spatial_data.coords[dim].attrs.get("units")) - if dim in spatial_data.coords - else None - for dim in spatial_data.dims - ], - }, - "diagnostics": { - "metric": "manual", - "final_metric_value": 0.0, - "n_iterations": 0, - "stop_condition": "Saved from manual napari layer transform.", - "status": "completed", - }, - } - - -def _run_register_volumewise( - data: xr.DataArray, - *, - reference_time: int, - n_jobs: int, - transform: Literal["translation", "rigid", "affine"], - metric: Literal["correlation", "mattes_mi"], - learning_rate: float | Literal["auto"] = 0.01, - number_of_iterations: int = 100, - use_multi_resolution: bool, - resample_interpolation: Literal["linear", "bspline"], - number_of_histogram_bins: int = 50, - convergence_minimum_value: float = 1e-6, - convergence_window_size: int = 10, - initialization: Literal["center_geometry", "center_moments"] - | None = "center_geometry", - shrink_factors: Sequence[int] = (6, 2, 1), - smoothing_sigmas: Sequence[int] = (6, 2, 1), - keep_diagnostics: bool = False, - abort_event: Event | None = None, - progress_reporter: NapariRegistrationProgressReporter | None = None, -) -> xr.DataArray: - """Run `register_volumewise` from the GUI. - - Parameters - ---------- - data : xarray.DataArray - Time-series data to motion-correct. - reference_time : int - Reference frame index. - n_jobs : int - Number of joblib workers to use. - transform : {"translation", "rigid", "affine"} - Registration model. - metric : {"correlation", "mattes_mi"} - Similarity metric. - learning_rate : float or {"auto"}, default: 0.01 - Optimizer learning rate. - number_of_iterations : int - Maximum number of optimizer iterations per frame. - use_multi_resolution : bool - Whether to enable the registration pyramid. - resample_interpolation : {"linear", "bspline"} - Interpolator for the resampled output. - number_of_histogram_bins : int - Histogram bins for Mattes MI metric. - convergence_minimum_value : float - Convergence threshold. - convergence_window_size : int - Window size for convergence estimation. - initialization : {"center_geometry", "center_moments"} or None - Transform initializer. - shrink_factors : tuple of int or None - Shrink factors per resolution level. - smoothing_sigmas : tuple of int or None - Smoothing sigmas per resolution level. - keep_diagnostics : bool - Store detailed optimization diagnostics. - abort_event : threading.Event, optional - Cooperative cancellation flag forwarded to `register_volumewise`. - progress_reporter : NapariRegistrationProgressReporter, optional - GUI-thread bridge-backed reporter forwarded to `register_volumewise`. - - Returns - ------- - xarray.DataArray - Registered time series. - """ - return register_volumewise( - data, - reference_time=reference_time, - n_jobs=n_jobs, - transform=transform, - metric=metric, - learning_rate=learning_rate, - number_of_iterations=number_of_iterations, - use_multi_resolution=use_multi_resolution, - resample_interpolation=resample_interpolation, - number_of_histogram_bins=number_of_histogram_bins, - convergence_minimum_value=convergence_minimum_value, - convergence_window_size=convergence_window_size, - initialization=initialization, - shrink_factors=shrink_factors, - smoothing_sigmas=smoothing_sigmas, - keep_diagnostics=keep_diagnostics, - show_progress=False, - abort_event=abort_event, - progress_reporter=progress_reporter, - ) +from confusius._napari._registration._panel_transform_helpers import ( + _affine_payload_from_layer, + _make_manual_transform_payload, + _spatial_manual_affine_from_layer, +) +from confusius._napari._registration._panel_utils import ( + ScientificDoubleSpinBox, + _apply_registration_scale, + _get_source_dataarray, + _image_display_kwargs_from_layer, + _layer_supports_registration_source, + _parse_sequence, + _prepare_between_scan_data, + _preserve_view, + _should_reset_gamma, +) +from confusius._napari._registration._panel_workers import ( + _run_register_volume, + _run_register_volumewise, +) class RegistrationPanel(QWidget): diff --git a/src/confusius/_napari/_registration/_panel_transform_helpers.py b/src/confusius/_napari/_registration/_panel_transform_helpers.py new file mode 100644 index 00000000..74621942 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel_transform_helpers.py @@ -0,0 +1,161 @@ +"""Transform-related helpers for the napari registration panel.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, cast + +import numpy as np + +from confusius._dims import SPATIAL_DIMS +from confusius._napari._registration._transforms import ( + AffineTransformPayload, + affine_transform_from_payload, +) +from confusius._napari._registration._panel_utils import ( + _get_source_dataarray, + _prepare_between_scan_data, +) + +if TYPE_CHECKING: + import numpy.typing as npt + from napari.layers import Layer + + +def _affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: + """Return the stored affine transform payload for a napari layer. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose metadata should be inspected. + + Returns + ------- + AffineTransformPayload or None + Stored payload when present and affine, otherwise `None`. + """ + payload = layer.metadata.get("confusius_transform") + if not isinstance(payload, dict) or payload.get("kind") != "affine": + return None + affine_transform_from_payload(payload) + return cast("AffineTransformPayload", payload) + + +def _spatial_manual_affine_from_layer( + layer: "Layer", *, spatial_dims: Sequence[str] +) -> "npt.NDArray[np.float64]": + """Return the spatial sub-affine from a napari layer's manual transform. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose manual napari affine should be extracted. + spatial_dims : sequence of str + Spatial dimension names, in the exact order expected by registration. + + Returns + ------- + (N+1, N+1) numpy.ndarray + Spatial homogeneous affine in world coordinates. + + Raises + ------ + ValueError + If the layer does not contain the requested spatial dimensions. + ValueError + If the layer affine has an unexpected shape. + ValueError + If the manual affine mixes selected spatial axes with ignored axes. + """ + data = _get_source_dataarray(layer) + layer_dims = [str(dim) for dim in data.dims] + missing_dims = [dim for dim in spatial_dims if dim not in layer_dims] + if missing_dims: + raise ValueError( + "Selected manual napari transform does not contain spatial dims " + f"{missing_dims}." + ) + + affine = np.asarray(layer.affine.affine_matrix, dtype=float) + expected_shape = (len(layer_dims) + 1, len(layer_dims) + 1) + if affine.shape != expected_shape: + raise ValueError( + f"Selected manual napari transform has shape {affine.shape}, " + f"but layer '{layer.name}' expects {expected_shape}." + ) + + spatial_indices = [layer_dims.index(dim) for dim in spatial_dims] + ignored_indices = [i for i in range(len(layer_dims)) if i not in spatial_indices] + linear = affine[:-1, :-1] + + if ignored_indices: + spatial_to_ignored = linear[np.ix_(spatial_indices, ignored_indices)] + ignored_to_spatial = linear[np.ix_(ignored_indices, spatial_indices)] + if not np.allclose(spatial_to_ignored, 0.0) or not np.allclose( + ignored_to_spatial, 0.0 + ): + raise ValueError( + "Selected manual napari transform mixes spatial axes with ignored " + "non-spatial axes, so it cannot be used as a registration " + "initialization." + ) + + spatial_affine = np.eye(len(spatial_dims) + 1, dtype=float) + spatial_affine[:-1, :-1] = linear[np.ix_(spatial_indices, spatial_indices)] + spatial_affine[:-1, -1] = affine[np.ix_(spatial_indices, [-1])].ravel() + return spatial_affine + + +def _make_manual_transform_payload(layer: "Layer") -> AffineTransformPayload: + """Build an affine payload from a layer's manual napari transform. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose current manual napari transform should be serialized. + + Returns + ------- + AffineTransformPayload + JSON-serializable affine payload representing the visible manual layer + transform on the layer's own spatial output grid. + """ + data = _get_source_dataarray(layer) + spatial_data = _prepare_between_scan_data(data) + spatial_dims = [str(dim) for dim in spatial_data.dims if dim in SPATIAL_DIMS] + manual_affine = _spatial_manual_affine_from_layer(layer, spatial_dims=spatial_dims) + pull_affine = np.linalg.inv(manual_affine) + return { + "kind": "affine", + "name": f"{layer.name} (manual)", + "affine": pull_affine.tolist(), + "source_layer_name": layer.name, + "target_layer_name": layer.name, + "operation": "manual_napari_transform", + "transform_model": "affine", + "metric": "manual", + "output_grid": { + "dims": [str(dim) for dim in spatial_data.dims], + "shape": [int(spatial_data.sizes[dim]) for dim in spatial_data.dims], + "spacing": [ + float(spatial_data.fusi.spacing[dim]) for dim in spatial_data.dims + ], + "origin": [ + float(spatial_data.fusi.origin[dim]) for dim in spatial_data.dims + ], + "units": [ + cast("str | None", spatial_data.coords[dim].attrs.get("units")) + if dim in spatial_data.coords + else None + for dim in spatial_data.dims + ], + }, + "diagnostics": { + "metric": "manual", + "final_metric_value": 0.0, + "n_iterations": 0, + "stop_condition": "Saved from manual napari layer transform.", + "status": "completed", + }, + } diff --git a/src/confusius/_napari/_registration/_panel_utils.py b/src/confusius/_napari/_registration/_panel_utils.py new file mode 100644 index 00000000..ab6e6fc4 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel_utils.py @@ -0,0 +1,414 @@ +"""Shared utility helpers for the napari registration panel.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Literal, cast + +import numpy as np +import xarray as xr +from qtpy.QtCore import QRegularExpression +from qtpy.QtGui import QValidator +from qtpy.QtWidgets import QDoubleSpinBox, QSizePolicy, QWidget + +from confusius._dims import SPATIAL_DIMS, TIME_DIM +from confusius.xarray.scale import db_scale, power_scale + +if TYPE_CHECKING: + import napari + from napari.layers import Layer + + +@contextmanager +def _preserve_view(viewer: "napari.Viewer") -> Iterator[None]: + """Keep the viewer camera and dims state across a block that adds layers. + + Adding image layers makes napari recompute `camera.center` and re-apply + `napari.imshow`'s default `ndisplay`/`order` to the dims, which yanks the canvas + back to a default framing. Wrapping the layer creation in this context manager + snapshots the current pan, zoom, rotation, and slider position and restores them + once the block exits, so the user keeps the view they were on when starting a + registration run. + + Parameters + ---------- + viewer : napari.Viewer + Viewer whose camera and dims state are snapshotted and restored. + + Yields + ------ + None + Control returns to the wrapped block; the saved state is restored when + it exits, including on early return or exception. + """ + camera = viewer.camera + dims = viewer.dims + center = tuple(camera.center) + zoom = camera.zoom + angles = tuple(camera.angles) + ndisplay = dims.ndisplay + order = tuple(dims.order) + current_step = tuple(dims.current_step) + try: + yield + finally: + dims.ndisplay = ndisplay + dims.order = order + dims.current_step = current_step + camera.center = center + camera.zoom = zoom + camera.angles = angles + + +def _default_dims_for_ndim(ndim: int) -> tuple[str, ...]: + """Return fallback dimension names for a raw napari layer. + + Parameters + ---------- + ndim : int + Number of array dimensions. + + Returns + ------- + tuple of str + Default dimension names compatible with ConfUSIus conventions when possible. + """ + defaults: dict[int, tuple[str, ...]] = { + 1: SPATIAL_DIMS[-1:], + 2: SPATIAL_DIMS[-2:], + 3: SPATIAL_DIMS, + 4: (TIME_DIM, *SPATIAL_DIMS), + } + return defaults.get(ndim, tuple(f"dim{i}" for i in range(ndim))) + + +def _normalize_layer_sequence(values: Any, ndim: int, fill: Any) -> list[Any]: + """Return a layer property as a list with length `ndim`. + + Parameters + ---------- + values : Any + Layer property such as `scale`, `translate`, `units`, or `axis_labels`. + ndim : int + Number of dimensions expected on the layer data. + fill : Any + Value used to pad missing entries. + + Returns + ------- + list of Any + Normalized sequence with exactly `ndim` elements. + """ + if values is None: + return [fill] * ndim + seq = list(values) + if len(seq) < ndim: + return ([fill] * (ndim - len(seq))) + seq + if len(seq) > ndim: + return seq[-ndim:] + return seq + + +def _reconstruct_layer_dataarray(layer: "Layer") -> xr.DataArray: + """Reconstruct a DataArray from the current napari layer state. + + Parameters + ---------- + layer : napari.layers.Layer + Napari layer to convert. + + Returns + ------- + xarray.DataArray + DataArray reconstructed from the layer's current axis labels, scale, translate, + and units. + """ + data = np.asarray(layer.data) + ndim = data.ndim + + raw_labels = _normalize_layer_sequence( + getattr(layer, "axis_labels", None), ndim, None + ) + axis_labels = tuple( + str(label) if label not in (None, "") else default + for label, default in zip( + raw_labels, _default_dims_for_ndim(ndim), strict=False + ) + ) + + scale = [ + float(v) + for v in _normalize_layer_sequence(getattr(layer, "scale", None), ndim, 1.0) + ] + translate = [ + float(v) + for v in _normalize_layer_sequence(getattr(layer, "translate", None), ndim, 0.0) + ] + raw_units = _normalize_layer_sequence(getattr(layer, "units", None), ndim, None) + units = [None if u is None or str(u) == "pixel" else str(u) for u in raw_units] + + coords: dict[str, xr.DataArray] = {} + for dim, n, spacing, origin, unit in zip( + axis_labels, data.shape, scale, translate, units, strict=False + ): + attrs: dict[str, Any] = {"voxdim": abs(spacing)} + if unit is not None: + attrs["units"] = unit + coords[dim] = xr.DataArray( + origin + np.arange(n) * spacing, dims=[dim], attrs=attrs + ) + + return xr.DataArray(data, dims=axis_labels, coords=coords) + + +def _layer_supports_registration_source(layer: "Layer") -> bool: + """Return whether `layer` can be converted to a registration source. + + ConfUSIus-managed layers carry the original `xarray.DataArray` in metadata. For + plain napari image layers we can reconstruct one from eager NumPy data. Lazy + non-NumPy layers (for example the video panel's frame-on-demand array) are + intentionally excluded: forcing `np.asarray` on them can trigger expensive decoding + or backend errors while the registration panel is merely refreshing. + """ + if layer.metadata.get("xarray") is not None: + return True + if layer.metadata.get("confusius_cached_registration_xarray") is not None: + return True + return isinstance(layer.data, np.ndarray) + + +def _get_source_dataarray(layer: "Layer") -> xr.DataArray: + """Return the stable source DataArray for a napari layer. + + Parameters + ---------- + layer : napari.layers.Layer + Napari layer to convert. + + Returns + ------- + xarray.DataArray + Original ConfUSIus DataArray when present in `layer.metadata`, otherwise a + cached reconstruction captured before later manual napari transforms mutate the + layer pose. + + Raises + ------ + TypeError + If the layer is backed by a lazy non-NumPy array that the registration + panel should ignore. + """ + existing = layer.metadata.get("xarray") + if existing is not None: + return cast("xr.DataArray", existing) + + cached = layer.metadata.get("confusius_cached_registration_xarray") + if cached is not None: + return cast("xr.DataArray", cached) + + if not isinstance(layer.data, np.ndarray): + raise TypeError( + f"Layer {layer.name!r} is not backed by eager NumPy data and cannot be used " + "for registration." + ) + + reconstructed = _reconstruct_layer_dataarray(layer) + layer.metadata["confusius_cached_registration_xarray"] = reconstructed + return reconstructed + + +def _prepare_between_scan_data(data: xr.DataArray) -> xr.DataArray: + """Return a spatial-only DataArray for between-scan registration. + + Parameters + ---------- + data : xarray.DataArray + Input layer data. + + Returns + ------- + xarray.DataArray + Spatial-only data. If the input has a time dimension, it is averaged over time + with attributes preserved. + """ + if TIME_DIM not in data.dims: + return data + averaged = data.mean(dim=TIME_DIM, keep_attrs=True) + averaged.attrs = data.attrs.copy() + return averaged + + +def _apply_registration_scale( + data: xr.DataArray, scale_mode: Literal["off", "dB", "sqrt"] +) -> xr.DataArray: + """Apply optional intensity preprocessing for registration. + + Parameters + ---------- + data : xarray.DataArray + Input data. + scale_mode : {"off", "dB", "sqrt"} + Intensity scaling mode used before registration. + + Returns + ------- + xarray.DataArray + Preprocessed data. + + Raises + ------ + ValueError + If `scale_mode` is not recognized. + """ + if scale_mode == "off": + return data + if scale_mode == "dB": + return db_scale(data) + if scale_mode == "sqrt": + return power_scale(data, exponent=0.5) + raise ValueError(f"Unknown registration scale mode: {scale_mode}.") + + +def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: + """Return image-display kwargs copied from an existing napari layer. + + Parameters + ---------- + layer : napari.layers.Layer + Source layer whose visual settings should be reused when possible. + + Returns + ------- + dict[str, Any] + Keyword arguments suitable for [`plot_napari`][confusius.plotting.plot_napari]. + """ + kwargs: dict[str, Any] = {} + for attr in ("colormap", "gamma", "opacity"): + if hasattr(layer, attr): + kwargs[attr] = getattr(layer, attr) + return kwargs + + +def _should_reset_gamma(scale_mode: str) -> bool: + """Return whether registration preview/result gamma should be reset. + + Parameters + ---------- + scale_mode : str + Registration intensity scaling mode. + + Returns + ------- + bool + Whether preview/result layers should force `gamma=1.0`. + """ + return scale_mode != "off" + + +def _parse_sequence(text: str, expected_len: int = 3) -> tuple[int, ...]: + """Parse comma-separated integers from a text field.""" + parts = [p.strip() for p in text.split(",") if p.strip()] + if not parts: + return tuple() + try: + values = tuple(int(float(p)) for p in parts) + except ValueError: + return tuple() + if len(values) != expected_len: + return tuple() + return values + + +class ScientificDoubleSpinBox(QDoubleSpinBox): + """`QDoubleSpinBox` variant that accepts scientific notation. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + """ + + _ACCEPTABLE_RE = QRegularExpression( + r"^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?$" + ) + _INTERMEDIATE_RE = QRegularExpression( + r"^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))?(?:[eE][+-]?\d*)?$" + ) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setDecimals(10) + self.setKeyboardTracking(False) + self.setAccelerated(True) + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + + def validate( + self, input: str | None, pos: int + ) -> tuple[QValidator.State, str, int]: + """Validate decimals and scientific notation while the user types. + + Parameters + ---------- + input : str, optional + Current text being edited. + pos : int + Cursor position. + + Returns + ------- + state : QValidator.State + Validation state. + text : str + Normalized text. + pos : int + Cursor position. + """ + normalized = input or "" + if normalized in {"", "+", "-", ".", "+.", "-."}: + return (QValidator.State.Intermediate, normalized, pos) + if self._ACCEPTABLE_RE.match(normalized).hasMatch(): + return (QValidator.State.Acceptable, normalized, pos) + if self._INTERMEDIATE_RE.match(normalized).hasMatch(): + return (QValidator.State.Intermediate, normalized, pos) + return (QValidator.State.Invalid, normalized, pos) + + def valueFromText(self, text: str | None) -> float: + """Parse the current text into a float value. + + Parameters + ---------- + text : str, optional + Text to parse. + + Returns + ------- + float + Parsed numeric value. + """ + return float(text or 0.0) + + def textFromValue(self, v: float) -> str: + """Format values compactly, using scientific notation when helpful. + + Parameters + ---------- + v : float + Value to format. + + Returns + ------- + str + Formatted text. + """ + return f"{v:.12g}" + + def stepBy(self, steps: int) -> None: + """Apply additive stepping using the configured single-step size. + + Parameters + ---------- + steps : int + Number of steps to apply. + """ + self.setValue(self.value() + (steps * self.singleStep())) diff --git a/src/confusius/_napari/_registration/_panel_workers.py b/src/confusius/_napari/_registration/_panel_workers.py new file mode 100644 index 00000000..d1d44d48 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel_workers.py @@ -0,0 +1,214 @@ +"""Worker entry points for the napari registration panel.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from threading import Event +from typing import TYPE_CHECKING, Literal + +import numpy as np +import xarray as xr + +from confusius._napari._registration._progress import NapariRegistrationProgressReporter +from confusius.registration import register_volume, register_volumewise + +if TYPE_CHECKING: + import numpy.typing as npt + + from confusius.registration import RegistrationDiagnostics, RegistrationProgress + + +def _run_register_volume( + moving: xr.DataArray, + fixed: xr.DataArray, + *, + transform_type: Literal["translation", "rigid", "affine", "bspline"], + metric: Literal["correlation", "mattes_mi"], + learning_rate: float | Literal["auto"], + number_of_iterations: int, + use_multi_resolution: bool, + resample_interpolation: Literal["linear", "bspline"], + mesh_size: tuple[int, int, int] = (10, 10, 10), + number_of_histogram_bins: int = 50, + convergence_minimum_value: float = 1e-6, + convergence_window_size: int = 10, + center_initialization: Literal["center_geometry", "center_moments"] + | None = "center_geometry", + initial_transform: "npt.NDArray[np.floating]" | None = None, + shrink_factors: Sequence[int] = (6, 2, 1), + smoothing_sigmas: Sequence[int] = (6, 2, 1), + fill_value: float | None = None, + progress_plotter: Callable[..., "RegistrationProgress"] | None = None, + abort_event: Event | None = None, +) -> tuple[ + xr.DataArray, + "npt.NDArray[np.floating]" | xr.DataArray, + "RegistrationDiagnostics", +]: + """Run `register_volume` from the GUI. + + Parameters + ---------- + moving : xarray.DataArray + Moving volume. + fixed : xarray.DataArray + Fixed reference volume. + transform_type : {"translation", "rigid", "affine", "bspline"} + Registration model. + metric : {"correlation", "mattes_mi"} + Similarity metric. + learning_rate : float or {"auto"} + Optimizer learning rate. + number_of_iterations : int + Maximum number of optimizer iterations. + use_multi_resolution : bool + Whether to enable the registration pyramid. + resample_interpolation : {"linear", "bspline"} + Interpolator for the resampled output. + mesh_size : tuple of int, default: (10, 10, 10) + B-spline mesh size. + number_of_histogram_bins : int + Histogram bins for Mattes MI metric. + convergence_minimum_value : float + Convergence threshold. + convergence_window_size : int + Window size for convergence estimation. + center_initialization : {"center_geometry", "center_moments"} or None + Center-based transform initializer. + initial_transform : numpy.ndarray, optional + Pre-computed affine transform used as a warm start before optimization. + shrink_factors : sequence of int + Shrink factors per resolution level. + smoothing_sigmas : sequence of int + Smoothing sigmas per resolution level. + fill_value : float or None + Fill value for resampled output outside input domain. + progress_plotter : callable, optional + Optional progress-plotter factory forwarded to `register_volume`. + abort_event : threading.Event, optional + Cooperative cancellation flag forwarded to `register_volume`. + + Returns + ------- + registered : xarray.DataArray + Resampled registered volume. + transform : numpy.ndarray or xarray.DataArray + Estimated transform. + diagnostics : confusius.registration.RegistrationDiagnostics + Optimizer diagnostics. + """ + return register_volume( + moving, + fixed, + transform_type=transform_type, + metric=metric, + learning_rate=learning_rate, + number_of_iterations=number_of_iterations, + use_multi_resolution=use_multi_resolution, + resample=True, + resample_interpolation=resample_interpolation, + mesh_size=mesh_size, + number_of_histogram_bins=number_of_histogram_bins, + convergence_minimum_value=convergence_minimum_value, + convergence_window_size=convergence_window_size, + initialization=( + center_initialization if initial_transform is None else initial_transform + ), + shrink_factors=shrink_factors, + smoothing_sigmas=smoothing_sigmas, + fill_value=fill_value, + show_progress=progress_plotter is not None, + progress_plotter=progress_plotter, + abort_event=abort_event, + ) + + +def _run_register_volumewise( + data: xr.DataArray, + *, + reference_time: int, + n_jobs: int, + transform: Literal["translation", "rigid", "affine"], + metric: Literal["correlation", "mattes_mi"], + learning_rate: float | Literal["auto"] = 0.01, + number_of_iterations: int = 100, + use_multi_resolution: bool, + resample_interpolation: Literal["linear", "bspline"], + number_of_histogram_bins: int = 50, + convergence_minimum_value: float = 1e-6, + convergence_window_size: int = 10, + initialization: Literal["center_geometry", "center_moments"] + | None = "center_geometry", + shrink_factors: Sequence[int] = (6, 2, 1), + smoothing_sigmas: Sequence[int] = (6, 2, 1), + keep_diagnostics: bool = False, + abort_event: Event | None = None, + progress_reporter: NapariRegistrationProgressReporter | None = None, +) -> xr.DataArray: + """Run `register_volumewise` from the GUI. + + Parameters + ---------- + data : xarray.DataArray + Time-series data to motion-correct. + reference_time : int + Reference frame index. + n_jobs : int + Number of joblib workers to use. + transform : {"translation", "rigid", "affine"} + Registration model. + metric : {"correlation", "mattes_mi"} + Similarity metric. + learning_rate : float or {"auto"}, default: 0.01 + Optimizer learning rate. + number_of_iterations : int + Maximum number of optimizer iterations per frame. + use_multi_resolution : bool + Whether to enable the registration pyramid. + resample_interpolation : {"linear", "bspline"} + Interpolator for the resampled output. + number_of_histogram_bins : int + Histogram bins for Mattes MI metric. + convergence_minimum_value : float + Convergence threshold. + convergence_window_size : int + Window size for convergence estimation. + initialization : {"center_geometry", "center_moments"} or None + Transform initializer. + shrink_factors : tuple of int or None + Shrink factors per resolution level. + smoothing_sigmas : tuple of int or None + Smoothing sigmas per resolution level. + keep_diagnostics : bool + Store detailed optimization diagnostics. + abort_event : threading.Event, optional + Cooperative cancellation flag forwarded to `register_volumewise`. + progress_reporter : NapariRegistrationProgressReporter, optional + GUI-thread bridge-backed reporter forwarded to `register_volumewise`. + + Returns + ------- + xarray.DataArray + Registered time series. + """ + return register_volumewise( + data, + reference_time=reference_time, + n_jobs=n_jobs, + transform=transform, + metric=metric, + learning_rate=learning_rate, + number_of_iterations=number_of_iterations, + use_multi_resolution=use_multi_resolution, + resample_interpolation=resample_interpolation, + number_of_histogram_bins=number_of_histogram_bins, + convergence_minimum_value=convergence_minimum_value, + convergence_window_size=convergence_window_size, + initialization=initialization, + shrink_factors=shrink_factors, + smoothing_sigmas=smoothing_sigmas, + keep_diagnostics=keep_diagnostics, + show_progress=False, + abort_event=abort_event, + progress_reporter=progress_reporter, + ) From 6759e8e33cc194c6dfb99080e8899af6b35e10e5 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 17:31:27 +0100 Subject: [PATCH 50/72] refactor(registration): rename transform getters and merge panel transforms Rename the remaining noun-style transform helpers to imperative verb phrases for consistency with the project convention: - affine_transform_from_payload -> get_affine_transform_from_payload - bspline_transform_from_payload -> get_bspline_transform_from_payload - output_grid_from_payload -> get_output_grid_from_payload - _affine_payload_from_layer -> _get_affine_payload_from_layer - _spatial_manual_affine_from_layer -> _get_spatial_manual_affine_from_layer Merge _transforms.py into _panel_transform_helpers.py. The split was arbitrary: all transform-payload TypedDicts, construction, deserialization, and I/O helpers, plus the layer-specific helpers, are used exclusively by the napari registration panel. The merged module removes a cross-import and an unneeded module boundary. --- src/confusius/_napari/_registration/_panel.py | 1239 ++++++++++++----- .../_registration/_panel_transform_helpers.py | 495 ++++++- .../_napari/_registration/_panel_utils.py | 4 + .../_napari/_registration/_transforms.py | 482 ------- .../test_napari/test_registration_panel.py | 70 +- 5 files changed, 1387 insertions(+), 903 deletions(-) delete mode 100644 src/confusius/_napari/_registration/_transforms.py diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 18acc7cb..4db90ef6 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -5,7 +5,7 @@ from collections.abc import Callable from pathlib import Path from threading import Event -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, cast import numpy as np import xarray as xr @@ -41,42 +41,20 @@ from confusius._napari._registration._metric_plotter import ( RegistrationMetricPlotter, ) -from confusius._napari._registration._progress import ( - NapariProgressBridge, - NapariRegistrationProgressReporter, - NapariRegistrationProgressReporterBridge, - make_napari_progress_factory, -) -from confusius._napari._registration._transforms import ( +from confusius._napari._registration._panel_transform_helpers import ( AffineTransformPayload, TransformPayload, - affine_transform_from_payload, - bspline_transform_from_payload, + _get_affine_payload_from_layer, + _get_spatial_manual_affine_from_layer, + _make_manual_transform_payload, + get_affine_transform_from_payload, + get_bspline_transform_from_payload, + get_output_grid_from_payload, load_transform_payload, make_affine_transform_payload, make_bspline_transform_payload, - output_grid_from_payload, save_transform_payload, ) -from confusius.plotting.napari import plot_napari -from confusius.registration import ( - resample_like, - resample_volume, -) - -if TYPE_CHECKING: - import napari - import numpy.typing as npt - from napari.layers import Image, Layer - - from confusius.registration import RegistrationDiagnostics, RegistrationProgress - - -from confusius._napari._registration._panel_transform_helpers import ( - _affine_payload_from_layer, - _make_manual_transform_payload, - _spatial_manual_affine_from_layer, -) from confusius._napari._registration._panel_utils import ( ScientificDoubleSpinBox, _apply_registration_scale, @@ -92,6 +70,132 @@ _run_register_volume, _run_register_volumewise, ) +from confusius._napari._registration._progress import ( + NapariProgressBridge, + NapariRegistrationProgressReporter, + NapariRegistrationProgressReporterBridge, + make_napari_progress_factory, +) +from confusius.plotting.napari import plot_napari +from confusius.registration import ( + resample_like, + resample_volume, +) + +if TYPE_CHECKING: + import napari + import numpy.typing as npt + from napari.layers import Image, Layer + + from confusius.registration import RegistrationDiagnostics, RegistrationProgress + + +ScaleMode = Literal["off", "dB", "sqrt"] +"""Allowed registration intensity-scaling modes used by the panel.""" + +MetricName = Literal["correlation", "mattes_mi"] +"""Allowed registration metric names exposed by the panel.""" + +VolumeTransformType = Literal["translation", "rigid", "affine", "bspline"] +"""Allowed transform models for between-scan registration.""" + +VolumewiseTransformType = Literal["translation", "rigid", "affine"] +"""Allowed transform models for within-scan registration.""" + +ResampleInterpolation = Literal["linear", "bspline"] +"""Allowed interpolation modes for resampling previews and outputs.""" + +CenterInitialization = Literal["center_geometry", "center_moments"] +"""Allowed built-in center-based initialization modes.""" + +RegistrationOperation = Literal["register_volume", "register_volumewise"] +"""Allowed registration workflows handled by the panel.""" + +TransformSourceKind = Literal["loaded", "layer", "manual"] +"""Kinds of transform sources offered in the transforms UI.""" + +TransformSourceData = tuple[TransformSourceKind, str] +"""Validated transform-source selector payload `(kind, name)`.""" + +InitializationSelection = CenterInitialization | TransformSourceData | None +"""Validated initialization selection from the registration UI.""" + +RegistrationParameterMode = Literal["volume", "volumewise"] +"""Registration-parameter mode used for UI snapshot and restore helpers.""" + + +class ModeParameters(TypedDict): + """Session-scoped UI parameters for one registration mode.""" + + transform: str + metric: MetricName + scale: ScaleMode + initialization: InitializationSelection + learning_rate_auto: bool + learning_rate_value: float + number_of_iterations: int + number_of_histogram_bins: int + mesh_size: tuple[int, int, int] + convergence_minimum_value: float + convergence_window_size: int + use_multi_resolution: bool + shrink_factors: str + smoothing_sigmas: str + resample_interpolation: ResampleInterpolation + fill_value_auto: bool + fill_value: float + reference_time: int + n_jobs: int + keep_diagnostics: bool + advanced_open: bool + + +class RegistrationRunPayloadBase(TypedDict): + """Shared UI snapshot fields captured before a registration worker starts.""" + + moving_layer_name: str + metric: MetricName + scale: ScaleMode + learning_rate: float | Literal["auto"] + number_of_iterations: int + use_multi_resolution: bool + resample_interpolation: ResampleInterpolation + number_of_histogram_bins: int + convergence_minimum_value: float + convergence_window_size: int + initialization: InitializationSelection + shrink_factors: tuple[int, ...] | None + smoothing_sigmas: tuple[int, ...] | None + keep_diagnostics: bool + fill_value: float | None + + +class VolumeRegistrationRunPayload(RegistrationRunPayloadBase): + """UI snapshot for between-scan registration.""" + + operation: Literal["register_volume"] + transform: VolumeTransformType + mesh_size: tuple[int, int, int] + fixed_layer_name: str + initial_transform_source: NotRequired[str] + + +class VolumewiseRegistrationRunPayload(RegistrationRunPayloadBase): + """UI snapshot for within-scan registration.""" + + operation: Literal["register_volumewise"] + transform: VolumewiseTransformType + mesh_size: tuple[int, int, int] + reference_time: int + n_jobs: int + + +class ApplyTransformPayload(TypedDict): + """UI snapshot for applying an existing transform.""" + + moving_layer_name: str + target_layer_name: str + transform_source: str class RegistrationPanel(QWidget): @@ -126,16 +230,31 @@ def __init__(self, viewer: napari.Viewer) -> None: # across subsequent runs, and torn down with the progress state. self._metric_plotter: RegistrationMetricPlotter | None = None self._metric_dock: QDockWidget | None = None - self._active_mode: Literal["register_volume", "register_volumewise"] = ( + self._active_operation: Literal["register_volume", "register_volumewise"] = ( "register_volume" ) - self._mode_parameters: dict[str, dict[str, Any]] = {} + self._registration_parameters_by_operation: dict[ + RegistrationOperation, ModeParameters + ] = {} self._setup_ui() self.viewer.layers.events.inserted.connect(self._refresh_layers) self.viewer.layers.events.removed.connect(self._refresh_layers) def _make_form_label(self, text: str, *, tooltip: str | None = None) -> QLabel: - """Return a form label with an optional tooltip.""" + """Return a form label with an optional tooltip. + + Parameters + ---------- + text : str + Label text. + tooltip : str, optional + Tooltip shown when hovering the label. + + Returns + ------- + QLabel + Configured label widget. + """ label = QLabel(text) if tooltip is not None: label.setToolTip(tooltip) @@ -149,7 +268,24 @@ def _make_advanced_row( *, tooltip: str | None = None, ) -> QWidget: - """Create a row container for advanced parameters that can be shown/hidden together.""" + """Create a show/hide-able row container for one advanced parameter. + + Parameters + ---------- + layout : QFormLayout + Parent form layout receiving the row. + label : str + Row-label text. + widget : QWidget + Input widget shown on the row. + tooltip : str, optional + Tooltip shown on the row label. + + Returns + ------- + QWidget + Container widget added to `layout`. + """ container = QWidget() row_layout = QHBoxLayout(container) row_layout.setContentsMargins(0, 0, 0, 0) @@ -741,16 +877,11 @@ def _setup_ui(self) -> None: lambda checked: self._learning_rate_edit.setEnabled(not checked) ) - self._mode_parameters = { - "register_volume": self._snapshot_mode_parameters(is_volumewise=False), - "register_volumewise": { - **self._snapshot_mode_parameters(is_volumewise=False), - "transform": "rigid", - "learning_rate_auto": False, - "learning_rate_value": 0.01, - "n_jobs": -1, - "keep_diagnostics": False, - }, + self._registration_parameters_by_operation = { + "register_volume": self._default_registration_parameters(mode="volume"), + "register_volumewise": self._default_registration_parameters( + mode="volumewise" + ), } self._refresh_layers() @@ -767,11 +898,11 @@ def _sync_manual_transform_event_connections(self) -> None: self._manual_transform_event_layers = [] for layer in self.viewer.layers: - if not _layer_supports_registration_source(cast("Layer", layer)): + if not _layer_supports_registration_source(layer): continue - _get_source_dataarray(cast("Layer", layer)) + _get_source_dataarray(layer) layer.events.affine.connect(self._refresh_transform_controls) - self._manual_transform_event_layers.append(cast("Layer", layer)) + self._manual_transform_event_layers.append(layer) def _refresh_layers(self) -> None: """Repopulate the layer selectors from the viewer.""" @@ -781,7 +912,7 @@ def _refresh_layers(self) -> None: layer_names = [ layer.name for layer in self.viewer.layers - if _layer_supports_registration_source(cast("Layer", layer)) + if _layer_supports_registration_source(layer) ] self._moving_combo.blockSignals(True) @@ -811,6 +942,24 @@ def _refresh_layers(self) -> None: self._refresh_transform_controls() self._validate_registration_selection() + def _get_layer_by_name(self, name: str) -> Layer | None: + """Return a viewer layer by name, if present. + + Parameters + ---------- + name : str + Layer name to look up in the viewer. + + Returns + ------- + napari.layers.Layer or None + Matching layer when present, otherwise `None`. + """ + try: + return cast("Layer", self.viewer.layers[name]) + except KeyError: + return None + def _selected_layer(self, combo: QComboBox) -> Layer | None: """Return the currently selected viewer layer for a combo box. @@ -827,20 +976,198 @@ def _selected_layer(self, combo: QComboBox) -> Layer | None: name = combo.currentText() if not name: return None - try: - return cast("Layer", self.viewer.layers[name]) - except KeyError: + return self._get_layer_by_name(name) + + def _current_scale_mode(self) -> ScaleMode: + """Return the validated registration scale mode from the combo box. + + Returns + ------- + {"off", "dB", "sqrt"} + Selected registration scale mode. + + Raises + ------ + ValueError + If the combo box contains an unexpected value. + """ + value = self._scale_combo.currentData() + if value in {"off", "dB", "sqrt"}: + return value + raise ValueError(f"Unknown registration scale mode: {value!r}.") + + def _current_metric(self) -> MetricName: + """Return the validated registration metric from the combo box. + + Returns + ------- + {"correlation", "mattes_mi"} + Selected registration metric. + + Raises + ------ + ValueError + If the combo box contains an unexpected value. + """ + value = self._metric_combo.currentText() + if value == "correlation": + return "correlation" + if value == "mattes_mi": + return "mattes_mi" + raise ValueError(f"Unknown registration metric: {value!r}.") + + def _current_resample_interpolation(self) -> ResampleInterpolation: + """Return the validated resampling interpolation from the combo box. + + Returns + ------- + {"linear", "bspline"} + Selected resampling interpolation. + + Raises + ------ + ValueError + If the combo box contains an unexpected value. + """ + value = self._interpolation_combo.currentText() + if value == "linear": + return "linear" + if value == "bspline": + return "bspline" + raise ValueError(f"Unknown resampling interpolation: {value!r}.") + + def _current_transform_model(self) -> VolumeTransformType | VolumewiseTransformType: + """Return the validated transform model for the active mode. + + Returns + ------- + {"translation", "rigid", "affine", "bspline"} + Selected transform model, constrained by the active workflow. + + Raises + ------ + ValueError + If the combo box contains an unexpected value. + """ + value = self._transform_combo.currentText() + if self._operation() == "register_volume": + if value == "translation": + return "translation" + if value == "rigid": + return "rigid" + if value == "affine": + return "affine" + if value == "bspline": + return "bspline" + else: + if value == "translation": + return "translation" + if value == "rigid": + return "rigid" + if value == "affine": + return "affine" + raise ValueError(f"Unknown transform model: {value!r}.") + + def _transform_source_data(self, value: object) -> TransformSourceData | None: + """Return validated transform-source combo data. + + Parameters + ---------- + value : object + Raw combo-box payload to validate. + + Returns + ------- + tuple[str, str] or None + Validated `(kind, name)` pair, or `None` when the payload does not + match the expected transform-source schema. + """ + if not isinstance(value, tuple) or len(value) != 2: + return None + source_kind, source_name = value + if not isinstance(source_name, str): + return None + if source_kind == "loaded": + return ("loaded", source_name) + if source_kind == "layer": + return ("layer", source_name) + if source_kind == "manual": + return ("manual", source_name) + return None + + def _transform_payload_from_metadata( + self, payload: object + ) -> TransformPayload | None: + """Return a validated transform payload stored in layer metadata. + + Parameters + ---------- + payload : object + Raw metadata payload to validate. + + Returns + ------- + TransformPayload or None + Validated transform payload, or `None` when the metadata does not + contain a supported transform payload. + """ + if not isinstance(payload, dict): return None + payload_mapping = cast("dict[str, object]", payload) + kind = payload_mapping.get("kind") + if kind == "affine": + get_affine_transform_from_payload(payload_mapping) + return cast("TransformPayload", payload_mapping) + if kind == "bspline": + get_bspline_transform_from_payload(payload_mapping) + return cast("TransformPayload", payload_mapping) + return None + + def _set_image_layer_data(self, layer: Image, data: npt.NDArray[Any]) -> None: + """Assign image data despite the current napari stub mismatch. + + Parameters + ---------- + layer : napari.layers.Image + Image layer whose data should be replaced. + data : numpy.ndarray + Replacement array. + """ + cast("Any", layer).data = data def _transform_source_label( self, payload: TransformPayload, *, suffix: str | None = None ) -> str: - """Return a user-facing label for a transform payload.""" + """Return a user-facing label for a transform payload. + + Parameters + ---------- + payload : TransformPayload + Transform payload to label. + suffix : str, optional + Unused legacy suffix parameter kept to avoid wider churn. + + Returns + ------- + str + Label shown in transform selectors. + """ del suffix return payload["name"] def _make_unique_layer_name(self, base_name: str) -> str: - """Return a viewer-unique layer name based on `base_name`.""" + """Return a viewer-unique layer name based on `base_name`. + + Parameters + ---------- + base_name : str + Desired layer name. + + Returns + ------- + str + Unique layer name for the current viewer. + """ existing_names = {layer.name for layer in self.viewer.layers} if base_name not in existing_names: return base_name @@ -852,7 +1179,18 @@ def _make_unique_layer_name(self, base_name: str) -> str: index += 1 def _make_unique_transform_name(self, base_name: str) -> str: - """Return a viewer-unique transform payload name based on `base_name`.""" + """Return a viewer-unique transform payload name based on `base_name`. + + Parameters + ---------- + base_name : str + Desired transform name. + + Returns + ------- + str + Unique transform payload name for the current viewer. + """ existing_names = { payload["name"] for payload in self._available_transform_payloads() } @@ -895,20 +1233,22 @@ def _volumewise_moving_preview_layer_name(self) -> str: return "Moving" def _available_transform_payloads(self) -> list[TransformPayload]: - """Return all transform payloads currently available in the UI.""" + """Return all transform payloads currently available in the UI. + + Returns + ------- + list of TransformPayload + Loaded payload plus any validated payloads found on viewer layers. + """ payloads: list[TransformPayload] = [] if self._loaded_transform_payload is not None: payloads.append(self._loaded_transform_payload) for layer in self.viewer.layers: - payload = layer.metadata.get("confusius_transform") - if isinstance(payload, dict): - kind = payload.get("kind") - if kind == "affine": - affine_transform_from_payload(payload) - payloads.append(cast("TransformPayload", payload)) - elif kind == "bspline": - bspline_transform_from_payload(payload) - payloads.append(cast("TransformPayload", payload)) + payload = self._transform_payload_from_metadata( + layer.metadata.get("confusius_transform") + ) + if payload is not None: + payloads.append(payload) return payloads def _refresh_transform_controls(self) -> None: @@ -929,21 +1269,14 @@ def _refresh_transform_controls(self) -> None: ) ) for layer in self.viewer.layers: - payload = layer.metadata.get("confusius_transform") - if not isinstance(payload, dict): - continue - kind = payload.get("kind") - if kind == "affine": - affine_transform_from_payload(payload) - elif kind == "bspline": - bspline_transform_from_payload(payload) - else: + payload = self._transform_payload_from_metadata( + layer.metadata.get("confusius_transform") + ) + if payload is None: continue transform_options.append( ( - self._transform_source_label( - cast("TransformPayload", payload), suffix=layer.name - ), + self._transform_source_label(payload, suffix=layer.name), ("layer", layer.name), ) ) @@ -956,7 +1289,7 @@ def _refresh_transform_controls(self) -> None: spatial_dims = [str(dim) for dim in data.dims if dim in SPATIAL_DIMS] if not spatial_dims: continue - manual_affine = _spatial_manual_affine_from_layer( + manual_affine = _get_spatial_manual_affine_from_layer( layer, spatial_dims=spatial_dims, ) @@ -989,11 +1322,8 @@ def _refresh_transform_controls(self) -> None: if self._loaded_transform_payload["kind"] != "affine": continue elif source_kind == "layer": - try: - layer = cast("Layer", self.viewer.layers[source_name]) - except KeyError: - continue - if _affine_payload_from_layer(layer) is None: + layer = self._get_layer_by_name(source_name) + if layer is None or _get_affine_payload_from_layer(layer) is None: continue self._initialization_combo.addItem(label, data) for label, data in manual_initialization_options: @@ -1025,8 +1355,10 @@ def _refresh_transform_controls(self) -> None: def _selected_transform_payload(self) -> TransformPayload | None: """Return the currently selected transform payload.""" - source_data = self._transform_source_combo.currentData() - if not isinstance(source_data, tuple) or len(source_data) != 2: + source_data = self._transform_source_data( + self._transform_source_combo.currentData() + ) + if source_data is None: return None source_kind, source_name = source_data @@ -1034,21 +1366,13 @@ def _selected_transform_payload(self) -> TransformPayload | None: return self._loaded_transform_payload if not source_name: return None - try: - layer = cast("Layer", self.viewer.layers[source_name]) - except KeyError: + layer = self._get_layer_by_name(source_name) + if layer is None: return None if source_kind == "layer": - payload = layer.metadata.get("confusius_transform") - if isinstance(payload, dict): - kind = payload.get("kind") - if kind == "affine": - affine_transform_from_payload(payload) - return cast("TransformPayload", payload) - if kind == "bspline": - bspline_transform_from_payload(payload) - return cast("TransformPayload", payload) - return None + return self._transform_payload_from_metadata( + layer.metadata.get("confusius_transform") + ) if source_kind == "manual": return _make_manual_transform_payload(layer) return None @@ -1059,13 +1383,15 @@ def _selected_center_initialization( """Return the selected built-in centering initialization, if any.""" value = self._initialization_combo.currentData() if value in {"center_geometry", "center_moments"}: - return cast("Literal['center_geometry', 'center_moments']", value) + return value return None def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: """Return the payload selected for registration initialization, if any.""" - source_data = self._initialization_combo.currentData() - if not isinstance(source_data, tuple) or len(source_data) != 2: + source_data = self._transform_source_data( + self._initialization_combo.currentData() + ) + if source_data is None: return None source_kind, source_name = source_data @@ -1078,25 +1404,23 @@ def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: return None if source_kind != "layer" or not source_name: return None - try: - layer = cast("Layer", self.viewer.layers[source_name]) - except KeyError: + layer = self._get_layer_by_name(source_name) + if layer is None: return None - return _affine_payload_from_layer(layer) + return _get_affine_payload_from_layer(layer) def _selected_manual_initialization_layer(self) -> Layer | None: """Return the layer selected for manual napari initialization, if any.""" - source_data = self._initialization_combo.currentData() - if not isinstance(source_data, tuple) or len(source_data) != 2: + source_data = self._transform_source_data( + self._initialization_combo.currentData() + ) + if source_data is None: return None source_kind, source_name = source_data if source_kind != "manual" or not source_name: return None - try: - return cast("Layer", self.viewer.layers[source_name]) - except KeyError: - return None + return self._get_layer_by_name(source_name) def _selected_initial_transform( self, @@ -1108,7 +1432,7 @@ def _selected_initial_transform( """Return the selected initialization affine and its source label.""" payload = self._selected_initial_transform_payload() if payload is not None: - return affine_transform_from_payload(payload), payload["name"] + return get_affine_transform_from_payload(payload), payload["name"] layer = self._selected_manual_initialization_layer() if layer is None: @@ -1122,11 +1446,11 @@ def _selected_initial_transform( ) spatial_dims = [str(dim) for dim in moving.dims if dim in SPATIAL_DIMS] - moving_affine = _spatial_manual_affine_from_layer( + moving_affine = _get_spatial_manual_affine_from_layer( moving_layer, spatial_dims=spatial_dims, ) - fixed_affine = _spatial_manual_affine_from_layer( + fixed_affine = _get_spatial_manual_affine_from_layer( fixed_layer, spatial_dims=spatial_dims, ) @@ -1355,12 +1679,58 @@ def _update_transform_dependent_visibility(self, transform: str) -> None: self._operation() == "register_volume" and transform == "bspline" ) - def _snapshot_mode_parameters(self, *, is_volumewise: bool) -> dict[str, Any]: - """Capture the current parameter state for one registration mode.""" + def _default_registration_parameters( + self, *, mode: RegistrationParameterMode + ) -> ModeParameters: + """Return the default parameter state for one registration mode. + + Parameters + ---------- + mode : {"volume", "volumewise"} + Registration workflow whose defaults should be returned. + + Returns + ------- + ModeParameters + Default parameter values for the requested workflow. + """ + is_volumewise = mode == "volumewise" + return { + "transform": "rigid", + "metric": "correlation", + "scale": "dB", + "initialization": "center_geometry", + "learning_rate_auto": not is_volumewise, + "learning_rate_value": 0.01 if is_volumewise else 0.1, + "number_of_iterations": 100, + "number_of_histogram_bins": 50, + "mesh_size": (10, 10, 10), + "convergence_minimum_value": 1e-6, + "convergence_window_size": 10, + "use_multi_resolution": False, + "shrink_factors": "6, 2, 1", + "smoothing_sigmas": "6, 2, 1", + "resample_interpolation": "linear", + "fill_value_auto": True, + "fill_value": 0.0, + "reference_time": 0, + "n_jobs": -1, + "keep_diagnostics": False, + "advanced_open": False, + } + + def _get_registration_parameters(self) -> ModeParameters: + """Return the current parameter state shown in the panel. + + Returns + ------- + ModeParameters + Current parameter values read from the visible widgets. + """ return { "transform": self._transform_combo.currentText() or "rigid", - "metric": self._metric_combo.currentText(), - "scale": cast("str", self._scale_combo.currentData()), + "metric": self._current_metric(), + "scale": self._current_scale_mode(), "initialization": self._initialization_combo.currentData(), "learning_rate_auto": self._learning_rate_auto_check.isChecked(), "learning_rate_value": self._learning_rate_edit.value(), @@ -1376,29 +1746,37 @@ def _snapshot_mode_parameters(self, *, is_volumewise: bool) -> dict[str, Any]: "use_multi_resolution": self._multi_resolution_check.isChecked(), "shrink_factors": self._shrink_factors_edit.text(), "smoothing_sigmas": self._smoothing_sigmas_edit.text(), - "resample_interpolation": self._interpolation_combo.currentText(), + "resample_interpolation": self._current_resample_interpolation(), "fill_value_auto": self._fill_value_auto_check.isChecked(), "fill_value": self._fill_value_spin.value(), "reference_time": self._reference_time_spin.value(), "n_jobs": self._n_jobs_spin.value(), "keep_diagnostics": self._keep_diagnostics_check.isChecked(), "advanced_open": self._advanced_toggle.isChecked(), - "is_volumewise": is_volumewise, } - def _apply_mode_parameters( - self, params: dict[str, Any], *, is_volumewise: bool + def _set_registration_parameters( + self, params: ModeParameters, *, mode: RegistrationParameterMode ) -> None: - """Restore the parameter state for one registration mode.""" + """Restore the parameter state for one registration mode. + + Parameters + ---------- + params : ModeParameters + Parameter values to push back into the widgets. + mode : {"volume", "volumewise"} + Registration workflow whose UI should be restored. + """ self._transform_combo.blockSignals(True) self._transform_combo.clear() + is_volumewise = mode == "volumewise" if is_volumewise: self._transform_combo.addItems(["translation", "rigid", "affine"]) else: self._transform_combo.addItems( ["translation", "rigid", "affine", "bspline"] ) - transform = cast("str", params.get("transform", "rigid")) + transform = params["transform"] transform_index = self._transform_combo.findText(transform) if transform_index < 0: transform_index = self._transform_combo.findText("rigid") @@ -1406,8 +1784,8 @@ def _apply_mode_parameters( self._transform_combo.setCurrentIndex(transform_index) self._transform_combo.blockSignals(False) - self._metric_combo.setCurrentText(cast("str", params["metric"])) - scale_mode = cast("str", params.get("scale", "dB")) + self._metric_combo.setCurrentText(params["metric"]) + scale_mode = params["scale"] scale_index = self._scale_combo.findData(scale_mode) if scale_index >= 0: self._scale_combo.setCurrentIndex(scale_index) @@ -1417,39 +1795,27 @@ def _apply_mode_parameters( self._initialization_combo.setCurrentIndex(i) break self._learning_rate_auto_check.setChecked( - False if is_volumewise else cast("bool", params["learning_rate_auto"]) + False if is_volumewise else params["learning_rate_auto"] ) - self._learning_rate_edit.setValue(cast("float", params["learning_rate_value"])) - self._iterations_spin.setValue(cast("int", params["number_of_iterations"])) - self._histogram_bins_spin.setValue( - cast("int", params["number_of_histogram_bins"]) - ) - mesh_size = cast("tuple[int, int, int]", params["mesh_size"]) + self._learning_rate_edit.setValue(params["learning_rate_value"]) + self._iterations_spin.setValue(params["number_of_iterations"]) + self._histogram_bins_spin.setValue(params["number_of_histogram_bins"]) + mesh_size = params["mesh_size"] self._mesh_size_z_spin.setValue(mesh_size[0]) self._mesh_size_y_spin.setValue(mesh_size[1]) self._mesh_size_x_spin.setValue(mesh_size[2]) - self._convergence_min_edit.setValue( - cast("float", params["convergence_minimum_value"]) - ) - self._convergence_window_spin.setValue( - cast("int", params["convergence_window_size"]) - ) - self._multi_resolution_check.setChecked( - cast("bool", params["use_multi_resolution"]) - ) - self._shrink_factors_edit.setText(cast("str", params["shrink_factors"])) - self._smoothing_sigmas_edit.setText(cast("str", params["smoothing_sigmas"])) - self._interpolation_combo.setCurrentText( - cast("str", params["resample_interpolation"]) - ) - self._fill_value_auto_check.setChecked(cast("bool", params["fill_value_auto"])) - self._fill_value_spin.setValue(cast("float", params["fill_value"])) - self._reference_time_spin.setValue(cast("int", params["reference_time"])) - self._n_jobs_spin.setValue(cast("int", params["n_jobs"])) - self._keep_diagnostics_check.setChecked( - cast("bool", params["keep_diagnostics"]) - ) - self._advanced_toggle.setChecked(cast("bool", params["advanced_open"])) + self._convergence_min_edit.setValue(params["convergence_minimum_value"]) + self._convergence_window_spin.setValue(params["convergence_window_size"]) + self._multi_resolution_check.setChecked(params["use_multi_resolution"]) + self._shrink_factors_edit.setText(params["shrink_factors"]) + self._smoothing_sigmas_edit.setText(params["smoothing_sigmas"]) + self._interpolation_combo.setCurrentText(params["resample_interpolation"]) + self._fill_value_auto_check.setChecked(params["fill_value_auto"]) + self._fill_value_spin.setValue(params["fill_value"]) + self._reference_time_spin.setValue(params["reference_time"]) + self._n_jobs_spin.setValue(params["n_jobs"]) + self._keep_diagnostics_check.setChecked(params["keep_diagnostics"]) + self._advanced_toggle.setChecked(params["advanced_open"]) self._on_advanced_toggled(self._advanced_toggle.isChecked()) self._update_metric_dependent_visibility(self._metric_combo.currentText()) self._update_multi_resolution_enabled(self._multi_resolution_check.isChecked()) @@ -1458,13 +1824,12 @@ def _apply_mode_parameters( def _on_mode_changed(self) -> None: """Update the panel when the registration mode changes.""" new_mode = self._operation() - previous_mode = self._active_mode - previous_is_volumewise = previous_mode == "register_volumewise" + previous_mode = self._active_operation is_volumewise = new_mode == "register_volumewise" - if previous_mode in self._mode_parameters: - self._mode_parameters[previous_mode] = self._snapshot_mode_parameters( - is_volumewise=previous_is_volumewise + if previous_mode in self._registration_parameters_by_operation: + self._registration_parameters_by_operation[previous_mode] = ( + self._get_registration_parameters() ) self._fixed_label.setVisible(not is_volumewise) @@ -1478,11 +1843,11 @@ def _on_mode_changed(self) -> None: self._fill_value_row.setVisible(not is_volumewise) self._keep_diagnostics_row.setVisible(is_volumewise) - self._apply_mode_parameters( - self._mode_parameters[new_mode], - is_volumewise=is_volumewise, + self._set_registration_parameters( + self._registration_parameters_by_operation[new_mode], + mode="volumewise" if is_volumewise else "volume", ) - self._active_mode = new_mode + self._active_operation = new_mode self._update_reference_time_bounds() self._validate_registration_selection() @@ -1562,10 +1927,12 @@ def _setup_volumewise_progress( **moving_display_kwargs, ) else: - cast("Any", moving_preview_layer).data = np.asarray(moving.data) + self._set_image_layer_data( + moving_preview_layer, np.asarray(moving.data) + ) moving_preview_layer.colormap = moving_display_kwargs["colormap"] - moving_preview_layer.gamma = cast( - "float", moving_display_kwargs.get("gamma", 1.0) + moving_preview_layer.gamma = float( + moving_display_kwargs.get("gamma", 1.0) ) moving_preview_layer.contrast_limits = contrast_limits @@ -1731,10 +2098,7 @@ def _setup_volume_progress( moving, fixed, seed_transform, - interpolation=cast( - "Literal['linear', 'bspline']", - "linear", - ), + interpolation="linear", ) preview_contrast_limits = tuple(calc_data_range(preview.data)) except Exception as exc: # noqa: BLE001 @@ -1768,10 +2132,12 @@ def _setup_volume_progress( **fixed_display_kwargs, ) else: - cast("Any", fixed_preview_layer).data = np.asarray(fixed.data) + self._set_image_layer_data( + fixed_preview_layer, np.asarray(fixed.data) + ) fixed_preview_layer.colormap = fixed_display_kwargs["colormap"] - fixed_preview_layer.gamma = cast( - "float", fixed_display_kwargs.get("gamma", 1.0) + fixed_preview_layer.gamma = float( + fixed_display_kwargs.get("gamma", 1.0) ) fixed_preview_layer.visible = True @@ -1790,11 +2156,13 @@ def _setup_volume_progress( **moving_display_kwargs, ) else: - cast("Any", moving_preview_layer).data = np.asarray(preview.data) + self._set_image_layer_data( + moving_preview_layer, np.asarray(preview.data) + ) moving_preview_layer.colormap = moving_display_kwargs["colormap"] moving_preview_layer.blending = moving_display_kwargs["blending"] - moving_preview_layer.gamma = cast( - "float", moving_display_kwargs.get("gamma", 1.0) + moving_preview_layer.gamma = float( + moving_display_kwargs.get("gamma", 1.0) ) moving_preview_layer.contrast_limits = preview_contrast_limits moving_preview_layer.visible = False @@ -1852,7 +2220,7 @@ def _update_progress_layer(self, arr: object) -> None: return if arr.shape != layer.data.shape: return - cast("Any", layer).data = arr + self._set_image_layer_data(layer, arr) def _teardown_volume_progress(self) -> None: """Remove the progress preview layer and bridge references, if any. @@ -2045,10 +2413,10 @@ def _apply_transform(self) -> None: try: moving = _get_source_dataarray(moving_layer) if payload["kind"] == "affine": - transform = affine_transform_from_payload(payload) + transform = get_affine_transform_from_payload(payload) else: - transform = bspline_transform_from_payload(payload) - output_grid = output_grid_from_payload(payload) + transform = get_bspline_transform_from_payload(payload) + output_grid = get_output_grid_from_payload(payload) except Exception as exc: # noqa: BLE001 self._set_error(str(exc)) return @@ -2060,11 +2428,9 @@ def _apply_transform(self) -> None: spacing=output_grid["spacing"], origin=output_grid["origin"], dims=output_grid["dims"], - interpolation=cast( - "Literal['linear', 'bspline']", self._interpolation_combo.currentText() - ), + interpolation=self._current_resample_interpolation(), ) - apply_payload = { + apply_payload: ApplyTransformPayload = { "moving_layer_name": moving_layer.name, "target_layer_name": payload["target_layer_name"], "transform_source": payload["name"], @@ -2111,32 +2477,13 @@ def _run_registration(self) -> None: shrink_factors = None smoothing_sigmas = None - payload: dict[str, Any] = { - "operation": operation, - "moving_layer_name": moving_layer.name, - "transform": self._transform_combo.currentText(), - "metric": self._metric_combo.currentText(), - "scale": cast("str", self._scale_combo.currentData()), - "learning_rate": learning_rate, - "number_of_iterations": self._iterations_spin.value(), - "use_multi_resolution": use_multi_res, - "resample_interpolation": self._interpolation_combo.currentText(), - "mesh_size": ( - self._mesh_size_z_spin.value(), - self._mesh_size_y_spin.value(), - self._mesh_size_x_spin.value(), - ), - "number_of_histogram_bins": self._histogram_bins_spin.value(), - "convergence_minimum_value": convergence_minimum_value, - "convergence_window_size": self._convergence_window_spin.value(), - "initialization": self._initialization_combo.currentData(), - "shrink_factors": shrink_factors, - "smoothing_sigmas": smoothing_sigmas, - "keep_diagnostics": self._keep_diagnostics_check.isChecked(), - "fill_value": None - if self._fill_value_auto_check.isChecked() - else self._fill_value_spin.value(), - } + metric = self._current_metric() + scale_mode = self._current_scale_mode() + resample_interpolation = self._current_resample_interpolation() + transform = self._current_transform_model() + initialization = cast( + "InitializationSelection", self._initialization_combo.currentData() + ) self._abort_event = Event() if operation == "register_volume": @@ -2155,14 +2502,8 @@ def _run_registration(self) -> None: moving = _prepare_between_scan_data(moving) fixed = _prepare_between_scan_data(fixed) - moving = _apply_registration_scale( - moving, - cast("Literal['off', 'dB', 'sqrt']", payload["scale"]), - ) - fixed = _apply_registration_scale( - fixed, - cast("Literal['off', 'dB', 'sqrt']", payload["scale"]), - ) + moving = _apply_registration_scale(moving, scale_mode) + fixed = _apply_registration_scale(fixed, scale_mode) initial_transform: npt.NDArray[np.floating] | None = None try: @@ -2176,10 +2517,39 @@ def _run_registration(self) -> None: except Exception as exc: # noqa: BLE001 self._set_error(str(exc)) return - if initial_transform_source is not None: - payload["initial_transform_source"] = initial_transform_source - payload["fixed_layer_name"] = fixed_layer.name + if transform not in {"translation", "rigid", "affine", "bspline"}: + self._set_error(f"Unknown transform model: {transform!r}.") + return + volume_payload: VolumeRegistrationRunPayload = { + "operation": "register_volume", + "moving_layer_name": moving_layer.name, + "transform": transform, + "metric": metric, + "scale": scale_mode, + "learning_rate": learning_rate, + "number_of_iterations": self._iterations_spin.value(), + "use_multi_resolution": use_multi_res, + "resample_interpolation": resample_interpolation, + "number_of_histogram_bins": self._histogram_bins_spin.value(), + "convergence_minimum_value": convergence_minimum_value, + "convergence_window_size": self._convergence_window_spin.value(), + "initialization": initialization, + "shrink_factors": shrink_factors, + "smoothing_sigmas": smoothing_sigmas, + "keep_diagnostics": self._keep_diagnostics_check.isChecked(), + "fill_value": None + if self._fill_value_auto_check.isChecked() + else self._fill_value_spin.value(), + "mesh_size": ( + self._mesh_size_z_spin.value(), + self._mesh_size_y_spin.value(), + self._mesh_size_x_spin.value(), + ), + "fixed_layer_name": fixed_layer.name, + } + if initial_transform_source is not None: + volume_payload["initial_transform_source"] = initial_transform_source progress_plotter = self._setup_volume_progress( moving_layer=cast("Image", moving_layer), @@ -2188,204 +2558,185 @@ def _run_registration(self) -> None: fixed=fixed, layer_name=self._make_unique_layer_name( self._volume_result_layer_name( - payload["moving_layer_name"], - payload["fixed_layer_name"], - transform_model=payload["transform"], + volume_payload["moving_layer_name"], + volume_payload["fixed_layer_name"], + transform_model=volume_payload["transform"], ) ), initial_transform=initial_transform, - scale_mode=payload["scale"], + scale_mode=volume_payload["scale"], ) worker = thread_worker(_run_register_volume)( moving, fixed, - transform_type=cast( - "Literal['translation', 'rigid', 'affine', 'bspline']", - payload["transform"], - ), - metric=cast("Literal['correlation', 'mattes_mi']", payload["metric"]), + transform_type=volume_payload["transform"], + metric=volume_payload["metric"], learning_rate=learning_rate, - number_of_iterations=payload["number_of_iterations"], - use_multi_resolution=payload["use_multi_resolution"], - resample_interpolation=cast( - "Literal['linear', 'bspline']", payload["resample_interpolation"] - ), - mesh_size=payload["mesh_size"] or (10, 10, 10), - number_of_histogram_bins=payload["number_of_histogram_bins"], - convergence_minimum_value=payload["convergence_minimum_value"], - convergence_window_size=payload["convergence_window_size"], + number_of_iterations=volume_payload["number_of_iterations"], + use_multi_resolution=volume_payload["use_multi_resolution"], + resample_interpolation=volume_payload["resample_interpolation"], + mesh_size=volume_payload["mesh_size"], + number_of_histogram_bins=volume_payload["number_of_histogram_bins"], + convergence_minimum_value=volume_payload["convergence_minimum_value"], + convergence_window_size=volume_payload["convergence_window_size"], center_initialization=self._selected_center_initialization(), initial_transform=initial_transform, - shrink_factors=payload["shrink_factors"] or (6, 2, 1), - smoothing_sigmas=payload["smoothing_sigmas"] or (6, 2, 1), - fill_value=payload["fill_value"], + shrink_factors=volume_payload["shrink_factors"] or (6, 2, 1), + smoothing_sigmas=volume_payload["smoothing_sigmas"] or (6, 2, 1), + fill_value=volume_payload["fill_value"], progress_plotter=progress_plotter, abort_event=self._abort_event, ) + self._worker = worker + self._begin_work() + worker.returned.connect( + lambda result: self._on_volume_registration_finished( + volume_payload, result + ) + ) else: if TIME_DIM not in moving.dims: self._set_error( "register_volumewise requires a layer with a time dimension." ) return + if transform == "bspline": + self._set_error(f"Unknown transform model: {transform!r}.") + return - payload["reference_time"] = self._reference_time_spin.value() - payload["n_jobs"] = self._n_jobs_spin.value() - moving = _apply_registration_scale( - moving, - cast("Literal['off', 'dB', 'sqrt']", payload["scale"]), - ) + volumewise_payload: VolumewiseRegistrationRunPayload = { + "operation": "register_volumewise", + "moving_layer_name": moving_layer.name, + "transform": transform, + "metric": metric, + "scale": scale_mode, + "learning_rate": learning_rate, + "number_of_iterations": self._iterations_spin.value(), + "use_multi_resolution": use_multi_res, + "resample_interpolation": resample_interpolation, + "number_of_histogram_bins": self._histogram_bins_spin.value(), + "convergence_minimum_value": convergence_minimum_value, + "convergence_window_size": self._convergence_window_spin.value(), + "initialization": initialization, + "shrink_factors": shrink_factors, + "smoothing_sigmas": smoothing_sigmas, + "keep_diagnostics": self._keep_diagnostics_check.isChecked(), + "fill_value": None + if self._fill_value_auto_check.isChecked() + else self._fill_value_spin.value(), + "mesh_size": ( + self._mesh_size_z_spin.value(), + self._mesh_size_y_spin.value(), + self._mesh_size_x_spin.value(), + ), + "reference_time": self._reference_time_spin.value(), + "n_jobs": self._n_jobs_spin.value(), + } + moving = _apply_registration_scale(moving, volumewise_payload["scale"]) progress_reporter = self._setup_volumewise_progress( moving_layer=cast("Image", moving_layer), moving=moving, layer_name=self._make_unique_layer_name( - self._volumewise_result_layer_name(payload["moving_layer_name"]) + self._volumewise_result_layer_name( + volumewise_payload["moving_layer_name"] + ) ), - scale_mode=payload["scale"], + scale_mode=volumewise_payload["scale"], ) worker = thread_worker(_run_register_volumewise)( moving, - reference_time=payload["reference_time"], - n_jobs=payload["n_jobs"], - transform=cast( - "Literal['translation', 'rigid', 'affine']", payload["transform"] - ), - metric=cast("Literal['correlation', 'mattes_mi']", payload["metric"]), + reference_time=volumewise_payload["reference_time"], + n_jobs=volumewise_payload["n_jobs"], + transform=volumewise_payload["transform"], + metric=volumewise_payload["metric"], learning_rate=learning_rate, - number_of_iterations=payload["number_of_iterations"], - use_multi_resolution=payload["use_multi_resolution"], - resample_interpolation=cast( - "Literal['linear', 'bspline']", payload["resample_interpolation"] - ), - number_of_histogram_bins=payload["number_of_histogram_bins"], - convergence_minimum_value=payload["convergence_minimum_value"], - convergence_window_size=payload["convergence_window_size"], + number_of_iterations=volumewise_payload["number_of_iterations"], + use_multi_resolution=volumewise_payload["use_multi_resolution"], + resample_interpolation=volumewise_payload["resample_interpolation"], + number_of_histogram_bins=volumewise_payload["number_of_histogram_bins"], + convergence_minimum_value=volumewise_payload[ + "convergence_minimum_value" + ], + convergence_window_size=volumewise_payload["convergence_window_size"], initialization=self._selected_center_initialization(), - shrink_factors=payload["shrink_factors"] or (6, 2, 1), - smoothing_sigmas=payload["smoothing_sigmas"] or (6, 2, 1), - keep_diagnostics=payload["keep_diagnostics"], + shrink_factors=volumewise_payload["shrink_factors"] or (6, 2, 1), + smoothing_sigmas=volumewise_payload["smoothing_sigmas"] or (6, 2, 1), + keep_diagnostics=volumewise_payload["keep_diagnostics"], abort_event=self._abort_event, progress_reporter=progress_reporter, ) - - self._worker = worker - self._begin_work() - worker.returned.connect( - lambda result: self._on_registration_finished(payload, result) - ) + self._worker = worker + self._begin_work() + worker.returned.connect( + lambda result: self._on_volumewise_registration_finished( + volumewise_payload, result + ) + ) worker.errored.connect(self._on_registration_failed) worker.finished.connect(self._end_work) worker.start() - def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> None: - """Add a successful registration result back to the viewer. - - Parameters - ---------- - payload : dict[str, Any] - UI parameter snapshot captured before the worker started. - result : Any - Worker return value. - """ - operation = cast(str, payload["operation"]) - - if operation == "register_volume": - registered, transform, diagnostics = cast( - "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics]", - result, - ) - registered = registered.copy(deep=False) - registered.attrs = registered.attrs.copy() - registered.attrs["registration_transform"] = transform - registered.attrs["registration_diagnostics"] = diagnostics - registered.attrs["registration_operation"] = operation - registered.attrs["registration_status"] = diagnostics.status - layer_name = self._volume_result_layer_name( - cast("str", payload["moving_layer_name"]), - cast("str", payload["fixed_layer_name"]), - transform_model=cast("str", payload["transform"]), - ) - metadata: dict[str, Any] = { - "registration_transform": transform, - "registration_diagnostics": diagnostics, - "registration_status": diagnostics.status, - } - transform_name = self._make_unique_transform_name( - f"{payload['moving_layer_name']} → {payload['fixed_layer_name']} ({payload['transform']})" - ) - if isinstance(transform, np.ndarray): - affine_transform = np.asarray(transform, dtype=float) - metadata["confusius_transform"] = make_affine_transform_payload( - affine_transform, - reference=registered, - source_layer_name=cast(str, payload["moving_layer_name"]), - target_layer_name=cast(str, payload["fixed_layer_name"]), - operation=operation, - transform_model=cast(str, payload["transform"]), - metric=cast(str, payload["metric"]), - diagnostics=diagnostics, - name=transform_name, - ) - else: - metadata["confusius_transform"] = make_bspline_transform_payload( - transform, - reference=registered, - source_layer_name=cast(str, payload["moving_layer_name"]), - target_layer_name=cast(str, payload["fixed_layer_name"]), - operation=operation, - transform_model=cast(str, payload["transform"]), - metric=cast(str, payload["metric"]), - diagnostics=diagnostics, - name=transform_name, - ) - else: - registered = cast("xr.DataArray", result).copy(deep=False) - registered.attrs = registered.attrs.copy() - registered.attrs["registration_operation"] = operation - layer_name = self._volumewise_result_layer_name( - cast("str", payload["moving_layer_name"]) - ) - metadata = { - "motion_params": registered.attrs.get("motion_params"), - "reference_time": payload["reference_time"], - } - - metadata["registration_operation"] = operation + def _coerce_volume_registration_payload( + self, payload: dict[str, Any] | VolumeRegistrationRunPayload + ) -> VolumeRegistrationRunPayload: + """Return a typed between-scan registration payload.""" + if payload.get("operation") != "register_volume": + raise ValueError("Expected a register_volume payload.") + return cast("VolumeRegistrationRunPayload", payload) + + def _coerce_volumewise_registration_payload( + self, payload: dict[str, Any] | VolumewiseRegistrationRunPayload + ) -> VolumewiseRegistrationRunPayload: + """Return a typed within-scan registration payload.""" + if payload.get("operation") != "register_volumewise": + raise ValueError("Expected a register_volumewise payload.") + return cast("VolumewiseRegistrationRunPayload", payload) + + def _finalize_registration_layer( + self, + *, + payload: VolumeRegistrationRunPayload | VolumewiseRegistrationRunPayload, + registered: xr.DataArray, + layer_name: str, + metadata: dict[str, Any], + registration_status: Literal["completed", "aborted"], + ) -> None: + """Attach registration metadata and add or update the result layer.""" + metadata["registration_operation"] = payload["operation"] metadata["registration_parameters"] = payload.copy() - source_layer_name = cast(str, payload["moving_layer_name"]) - try: - source_layer = self.viewer.layers[source_layer_name] - except KeyError: - display_kwargs: dict[str, Any] = {} - else: - display_kwargs = _image_display_kwargs_from_layer(source_layer) - if _should_reset_gamma(cast("str", payload.get("scale", "off"))): + source_layer = self._get_layer_by_name(payload["moving_layer_name"]) + display_kwargs = ( + _image_display_kwargs_from_layer(source_layer) + if source_layer is not None + else {} + ) + if _should_reset_gamma(payload.get("scale", "off")): display_kwargs["gamma"] = 1.0 - # The result layer is the registered stand-in for the moving layer: - # it must use the same cyan + additive styling so the red/cyan - # overlay persists after the run. - if operation == "register_volume": + if payload["operation"] == "register_volume": display_kwargs["colormap"] = "cyan" display_kwargs["blending"] = "additive" contrast_limits = tuple(calc_data_range(registered.data)) - if operation == "register_volume" and self._progress_layer is not None: + if ( + payload["operation"] == "register_volume" + and self._progress_layer is not None + ): layer = self._progress_layer - cast("Any", layer).data = np.asarray(registered.data) + self._set_image_layer_data(layer, np.asarray(registered.data)) if hasattr(layer, "contrast_limits"): layer.contrast_limits = contrast_limits self._progress_bridge = None self._progress_layer = None elif ( - operation == "register_volumewise" + payload["operation"] == "register_volumewise" and self._volumewise_progress_layer is not None ): layer = self._volumewise_progress_layer - cast("Any", layer).data = np.asarray(registered.data) + self._set_image_layer_data(layer, np.asarray(registered.data)) if hasattr(layer, "contrast_limits"): layer.contrast_limits = contrast_limits self._teardown_volumewise_progress(remove_layer=False) @@ -2403,21 +2754,7 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non self.viewer.layers.selection.active = layer self._refresh_transform_controls() - motion_params = metadata.get("motion_params") - volumewise_aborted = False - if operation == "register_volumewise" and motion_params is not None: - try: - statuses = motion_params["status"] - except Exception: # noqa: BLE001 - statuses = None - if statuses is not None: - volumewise_aborted = bool((statuses == "aborted").any()) - registration_status = ( - cast("str", metadata["registration_status"]) - if operation == "register_volume" - else ("aborted" if volumewise_aborted else "completed") - ) - if operation == "register_volumewise": + if payload["operation"] == "register_volumewise": self._progress.setValue(self._progress.maximum()) if registration_status == "aborted": @@ -2427,8 +2764,152 @@ def _on_registration_finished(self, payload: dict[str, Any], result: Any) -> Non else: show_info(f"Added registered layer: {layer.name}") + def _on_registration_finished( + self, + payload: dict[str, Any], + result: object, + ) -> None: + """Dispatch a finished registration callback to the typed handler. + + Parameters + ---------- + payload : dict[str, Any] + Untyped compatibility payload captured when the worker started. + result : object + Worker result to forward to the operation-specific handler. + + Raises + ------ + ValueError + If `payload["operation"]` is not recognized. + """ + if payload.get("operation") == "register_volume": + self._on_volume_registration_finished( + self._coerce_volume_registration_payload(payload), + cast( + "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics]", + result, + ), + ) + return + if payload.get("operation") == "register_volumewise": + self._on_volumewise_registration_finished( + self._coerce_volumewise_registration_payload(payload), + cast("xr.DataArray", result), + ) + return + raise ValueError( + f"Unknown registration operation: {payload.get('operation')!r}." + ) + + def _on_volume_registration_finished( + self, + payload: VolumeRegistrationRunPayload, + result: tuple[ + xr.DataArray, + npt.NDArray[np.floating] | xr.DataArray, + RegistrationDiagnostics, + ], + ) -> None: + """Add a between-scan registration result back to the viewer. + + Parameters + ---------- + payload : VolumeRegistrationRunPayload + Typed UI snapshot captured before the worker started. + result : tuple + Registered volume, estimated transform, and diagnostics. + """ + registered, transform, diagnostics = result + registered = registered.copy(deep=False) + registered.attrs = registered.attrs.copy() + registered.attrs["registration_transform"] = transform + registered.attrs["registration_diagnostics"] = diagnostics + registered.attrs["registration_operation"] = payload["operation"] + registered.attrs["registration_status"] = diagnostics.status + metadata: dict[str, Any] = { + "registration_transform": transform, + "registration_diagnostics": diagnostics, + "registration_status": diagnostics.status, + } + transform_name = self._make_unique_transform_name( + f"{payload['moving_layer_name']} → {payload['fixed_layer_name']} ({payload['transform']})" + ) + if isinstance(transform, np.ndarray): + metadata["confusius_transform"] = make_affine_transform_payload( + np.asarray(transform, dtype=float), + reference=registered, + source_layer_name=payload["moving_layer_name"], + target_layer_name=payload["fixed_layer_name"], + operation=payload["operation"], + transform_model=payload["transform"], + metric=payload["metric"], + diagnostics=diagnostics, + name=transform_name, + ) + else: + metadata["confusius_transform"] = make_bspline_transform_payload( + transform, + reference=registered, + source_layer_name=payload["moving_layer_name"], + target_layer_name=payload["fixed_layer_name"], + operation=payload["operation"], + transform_model=payload["transform"], + metric=payload["metric"], + diagnostics=diagnostics, + name=transform_name, + ) + self._finalize_registration_layer( + payload=payload, + registered=registered, + layer_name=self._volume_result_layer_name( + payload["moving_layer_name"], + payload["fixed_layer_name"], + transform_model=payload["transform"], + ), + metadata=metadata, + registration_status=diagnostics.status, + ) + + def _on_volumewise_registration_finished( + self, + payload: VolumewiseRegistrationRunPayload, + result: xr.DataArray, + ) -> None: + """Add a within-scan registration result back to the viewer. + + Parameters + ---------- + payload : VolumewiseRegistrationRunPayload + Typed UI snapshot captured before the worker started. + result : xarray.DataArray + Motion-corrected time series returned by the worker. + """ + registered = result.copy(deep=False) + registered.attrs = registered.attrs.copy() + registered.attrs["registration_operation"] = payload["operation"] + motion_params = registered.attrs.get("motion_params") + registration_status = "completed" + if motion_params is not None: + try: + statuses = motion_params["status"] + except Exception: # noqa: BLE001 + statuses = None + if statuses is not None and bool((statuses == "aborted").any()): + registration_status = "aborted" + self._finalize_registration_layer( + payload=payload, + registered=registered, + layer_name=self._volumewise_result_layer_name(payload["moving_layer_name"]), + metadata={ + "motion_params": motion_params, + "reference_time": payload["reference_time"], + }, + registration_status=registration_status, + ) + def _on_apply_transform_finished( - self, payload: dict[str, str], result: xr.DataArray + self, payload: ApplyTransformPayload, result: xr.DataArray ) -> None: """Add a resampled layer produced from an existing affine transform. diff --git a/src/confusius/_napari/_registration/_panel_transform_helpers.py b/src/confusius/_napari/_registration/_panel_transform_helpers.py index 74621942..3aec9be5 100644 --- a/src/confusius/_napari/_registration/_panel_transform_helpers.py +++ b/src/confusius/_napari/_registration/_panel_transform_helpers.py @@ -1,28 +1,497 @@ -"""Transform-related helpers for the napari registration panel.""" +"""Transform payload and panel-specific transform helpers for the napari registration panel.""" from __future__ import annotations +import json from collections.abc import Sequence -from typing import TYPE_CHECKING, cast +from pathlib import Path +from typing import TYPE_CHECKING, Literal, SupportsFloat, SupportsIndex, TypedDict, cast import numpy as np +import numpy.typing as npt +import xarray as xr from confusius._dims import SPATIAL_DIMS -from confusius._napari._registration._transforms import ( - AffineTransformPayload, - affine_transform_from_payload, -) from confusius._napari._registration._panel_utils import ( _get_source_dataarray, _prepare_between_scan_data, ) +from confusius.registration.bspline import validate_bspline_dataarray if TYPE_CHECKING: - import numpy.typing as npt + from collections.abc import Mapping + from napari.layers import Layer + from confusius.registration import RegistrationDiagnostics + + +class TransformDiagnosticsPayload(TypedDict): + """JSON-serializable registration diagnostics summary.""" + + metric: str + final_metric_value: float + n_iterations: int + stop_condition: str + status: str + + +class OutputGridPayload(TypedDict): + """JSON-serializable resampling grid description.""" + + dims: list[str] + shape: list[int] + spacing: list[float] + origin: list[float] + units: list[str | None] + + +class BSplineDataArrayPayload(TypedDict): + """JSON-serializable B-spline control-point DataArray.""" + + dims: list[str] + data: list[object] + coords: dict[str, list[float]] + attrs: dict[str, object] + + +class AffineTransformPayload(TypedDict): + """JSON-serializable affine transform payload used by the napari plugin.""" + + kind: Literal["affine"] + name: str + affine: list[list[float]] + source_layer_name: str + target_layer_name: str + operation: str + transform_model: str + metric: str + output_grid: OutputGridPayload + diagnostics: TransformDiagnosticsPayload + + +class BSplineTransformPayload(TypedDict): + """B-spline transform payload used by the napari plugin.""" + + kind: Literal["bspline"] + name: str + bspline: BSplineDataArrayPayload + source_layer_name: str + target_layer_name: str + operation: str + transform_model: str + metric: str + output_grid: OutputGridPayload + diagnostics: TransformDiagnosticsPayload + + +TransformPayload = AffineTransformPayload | BSplineTransformPayload +"""Union of affine and B-spline transform payloads.""" + + +def make_output_grid_payload(reference: "xr.DataArray") -> OutputGridPayload: + """Return the resampling grid defined by a reference DataArray. + + Parameters + ---------- + reference : xarray.DataArray + Spatial DataArray defining the output grid. + + Returns + ------- + OutputGridPayload + JSON-serializable output-grid description. + """ + dims = [str(dim) for dim in reference.dims] + return { + "dims": dims, + "shape": [int(reference.sizes[dim]) for dim in dims], + "spacing": [float(reference.fusi.spacing[dim]) for dim in dims], + "origin": [float(reference.fusi.origin[dim]) for dim in dims], + "units": [ + cast("str | None", reference.coords[dim].attrs.get("units")) + if dim in reference.coords + else None + for dim in dims + ], + } + + +def _make_diagnostics_payload( + diagnostics: "RegistrationDiagnostics", +) -> TransformDiagnosticsPayload: + """Return a JSON-serializable diagnostics summary.""" + return { + "metric": diagnostics.metric, + "final_metric_value": float(diagnostics.final_metric_value), + "n_iterations": int(diagnostics.n_iterations), + "stop_condition": diagnostics.stop_condition, + "status": diagnostics.status, + } + + +def make_affine_transform_payload( + affine: npt.NDArray[np.floating], + *, + reference: "xr.DataArray", + source_layer_name: str, + target_layer_name: str, + operation: str, + transform_model: str, + metric: str, + diagnostics: "RegistrationDiagnostics", + name: str | None = None, +) -> AffineTransformPayload: + """Build a JSON-serializable payload for a registered affine transform. + + Parameters + ---------- + affine : (N+1, N+1) numpy.ndarray + Affine transform in homogeneous coordinates. + reference : xarray.DataArray + Fixed/reference DataArray defining the output resampling grid. + source_layer_name : str + Name of the moving/source layer used when estimating the transform. + target_layer_name : str + Name of the fixed/target layer used when estimating the transform. + operation : str + Registration operation that produced the transform. + transform_model : str + Transform model used during registration. + metric : str + Similarity metric used during registration. + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics. + name : str, optional + Human-friendly transform name. If not provided, a default name is generated. + + Returns + ------- + AffineTransformPayload + JSON-serializable affine transform payload. + """ + affine = np.asarray(affine, dtype=float) + payload_name = ( + name or f"{source_layer_name} → {target_layer_name} ({transform_model})" + ) + return { + "kind": "affine", + "name": payload_name, + "affine": affine.tolist(), + "source_layer_name": source_layer_name, + "target_layer_name": target_layer_name, + "operation": operation, + "transform_model": transform_model, + "metric": metric, + "output_grid": make_output_grid_payload(reference), + "diagnostics": _make_diagnostics_payload(diagnostics), + } + + +def _serialize_bspline_dataarray(transform: "xr.DataArray") -> BSplineDataArrayPayload: + """Return a JSON-serializable B-spline DataArray payload.""" + validate_bspline_dataarray(transform) + return { + "dims": [str(dim) for dim in transform.dims], + "data": np.asarray(transform, dtype=float).tolist(), + "coords": { + str(dim): np.asarray(transform.coords[dim], dtype=float).tolist() + for dim in transform.dims + if dim in transform.coords + }, + "attrs": json.loads(json.dumps(transform.attrs)), + } + + +def _deserialize_bspline_dataarray(payload: BSplineDataArrayPayload) -> xr.DataArray: + """Reconstruct a B-spline DataArray from its JSON payload.""" + dims = [str(dim) for dim in payload["dims"]] + coords = { + str(dim): xr.DataArray(np.asarray(values, dtype=float), dims=[str(dim)]) + for dim, values in payload["coords"].items() + } + transform = xr.DataArray( + np.asarray(payload["data"], dtype=float), + dims=dims, + coords=coords, + attrs=dict(payload["attrs"]), + ) + validate_bspline_dataarray(transform) + return transform + + +def make_bspline_transform_payload( + transform: "xr.DataArray", + *, + reference: "xr.DataArray", + source_layer_name: str, + target_layer_name: str, + operation: str, + transform_model: str, + metric: str, + diagnostics: "RegistrationDiagnostics", + name: str | None = None, +) -> BSplineTransformPayload: + """Build a JSON-serializable payload for a registered B-spline transform. + + Parameters + ---------- + transform : xarray.DataArray + B-spline control-point grid. + reference : xarray.DataArray + Fixed/reference DataArray defining the output resampling grid. + source_layer_name : str + Name of the moving/source layer used when estimating the transform. + target_layer_name : str + Name of the fixed/target layer used when estimating the transform. + operation : str + Registration operation that produced the transform. + transform_model : str + Transform model used during registration. + metric : str + Similarity metric used during registration. + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics. + name : str, optional + Human-friendly transform name. If not provided, a default name is generated. + + Returns + ------- + BSplineTransformPayload + JSON-serializable B-spline transform payload. + """ + payload_name = ( + name or f"{source_layer_name} → {target_layer_name} ({transform_model})" + ) + return { + "kind": "bspline", + "name": payload_name, + "bspline": _serialize_bspline_dataarray(transform), + "source_layer_name": source_layer_name, + "target_layer_name": target_layer_name, + "operation": operation, + "transform_model": transform_model, + "metric": metric, + "output_grid": make_output_grid_payload(reference), + "diagnostics": _make_diagnostics_payload(diagnostics), + } + + +def get_affine_transform_from_payload( + payload: "Mapping[str, object]", +) -> npt.NDArray[np.float64]: + """Return the affine matrix stored in a payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + (N+1, N+1) numpy.ndarray + Affine matrix. + """ + if payload.get("kind") != "affine": + raise ValueError("Transform payload is not an affine transform.") + + affine = np.asarray(payload.get("affine"), dtype=float) + if affine.ndim != 2 or affine.shape[0] != affine.shape[1] or affine.shape[0] < 3: + raise ValueError( + "Affine payload must contain a square homogeneous matrix of shape " + "(N+1, N+1)." + ) + return affine + + +def get_bspline_transform_from_payload(payload: "Mapping[str, object]") -> xr.DataArray: + """Return the B-spline transform stored in a payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + xarray.DataArray + B-spline control-point grid. + """ + if payload.get("kind") != "bspline": + raise ValueError("Transform payload is not a B-spline transform.") + + bspline = payload.get("bspline") + if not isinstance(bspline, dict): + raise ValueError("B-spline payload must contain a serialized DataArray.") + return _deserialize_bspline_dataarray(cast("BSplineDataArrayPayload", bspline)) + + +def get_output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: + """Return the output grid stored in a transform payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + OutputGridPayload + Output-grid description stored in the payload. + """ + grid = payload.get("output_grid") + if not isinstance(grid, dict): + raise ValueError("Transform payload does not contain an output grid.") + + grid_dict = cast("dict[str, object]", grid) + dims = grid_dict.get("dims") + shape = grid_dict.get("shape") + spacing = grid_dict.get("spacing") + origin = grid_dict.get("origin") + units = grid_dict.get("units") + if not all(isinstance(v, list) for v in (dims, shape, spacing, origin, units)): + raise ValueError("Transform payload output grid is malformed.") + + dims_list = cast("list[object]", dims) + shape_list = cast("list[SupportsIndex]", shape) + spacing_list = cast("list[SupportsFloat]", spacing) + origin_list = cast("list[SupportsFloat]", origin) + units_list = cast("list[object]", units) + + return { + "dims": [str(v) for v in dims_list], + "shape": [int(v) for v in shape_list], + "spacing": [float(v) for v in spacing_list], + "origin": [float(v) for v in origin_list], + "units": [None if v is None else str(v) for v in units_list], + } + + +def _save_bspline_transform_payload( + path: str | Path, payload: BSplineTransformPayload +) -> None: + """Save a B-spline transform payload as Zarr. + + Parameters + ---------- + path : str or pathlib.Path + Output Zarr path. + payload : BSplineTransformPayload + Transform payload to save. + """ + path = Path(path) + if path.suffix != ".zarr": + raise ValueError("B-spline transform files must have .zarr extension.") + + transform = get_bspline_transform_from_payload(payload) + ds = transform.to_dataset(name="bspline_transform") + payload_metadata = { + key: value for key, value in payload.items() if key not in {"kind", "bspline"} + } + ds.attrs["confusius_transform_kind"] = "bspline" + ds.attrs["confusius_transform_payload_json"] = json.dumps(payload_metadata) + ds.to_zarr(path, mode="w") + + +def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload: + """Load a B-spline transform payload from Zarr. + + Parameters + ---------- + path : str or pathlib.Path + Input Zarr path. + + Returns + ------- + BSplineTransformPayload + Loaded B-spline transform payload. + """ + ds = xr.open_zarr(path) + try: + if ds.attrs.get("confusius_transform_kind") != "bspline": + raise ValueError( + "Zarr transform store does not contain a ConfUSIus B-spline transform." + ) + payload_metadata = json.loads( + cast("str", ds.attrs["confusius_transform_payload_json"]) + ) + if not isinstance(payload_metadata, dict): + raise ValueError("Stored transform payload metadata is malformed.") + transform = ds["bspline_transform"].load() + finally: + ds.close() + + validate_bspline_dataarray(transform) + payload: BSplineTransformPayload = { + "kind": "bspline", + "bspline": _serialize_bspline_dataarray(transform), + "name": str(payload_metadata["name"]), + "source_layer_name": str(payload_metadata["source_layer_name"]), + "target_layer_name": str(payload_metadata["target_layer_name"]), + "operation": str(payload_metadata["operation"]), + "transform_model": str(payload_metadata["transform_model"]), + "metric": str(payload_metadata["metric"]), + "output_grid": get_output_grid_from_payload(payload_metadata), + "diagnostics": cast( + "TransformDiagnosticsPayload", payload_metadata["diagnostics"] + ), + } + return payload + + +def save_transform_payload(path: str | Path, payload: TransformPayload) -> None: + """Save a transform payload to disk. + + Parameters + ---------- + path : str or pathlib.Path + Output path. + payload : TransformPayload + Transform payload to save. + + Notes + ----- + Affine payloads are saved as JSON. B-spline payloads are saved as Zarr. + """ + if payload["kind"] == "affine": + Path(path).write_text(json.dumps(payload, indent=2) + "\n") + return + _save_bspline_transform_payload(path, payload) + + +def load_transform_payload(path: str | Path) -> TransformPayload: + """Load an affine or B-spline transform payload from disk. + + Parameters + ---------- + path : str or pathlib.Path + Input path. + + Returns + ------- + TransformPayload + Loaded transform payload. + """ + path = Path(path) + if path.suffix == ".zarr": + return _load_bspline_transform_payload(path) + + payload = json.loads(path.read_text()) + if not isinstance(payload, dict): + raise ValueError("Transform file must contain a JSON object.") + + kind = payload.get("kind") + if kind != "affine": + raise ValueError( + "JSON transform files currently support affine payloads only. " + "Use .zarr for B-spline transforms." + ) + get_affine_transform_from_payload(payload) + get_output_grid_from_payload(payload) + return cast("TransformPayload", payload) + -def _affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: +def _get_affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: """Return the stored affine transform payload for a napari layer. Parameters @@ -38,13 +507,13 @@ def _affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: payload = layer.metadata.get("confusius_transform") if not isinstance(payload, dict) or payload.get("kind") != "affine": return None - affine_transform_from_payload(payload) + get_affine_transform_from_payload(payload) return cast("AffineTransformPayload", payload) -def _spatial_manual_affine_from_layer( +def _get_spatial_manual_affine_from_layer( layer: "Layer", *, spatial_dims: Sequence[str] -) -> "npt.NDArray[np.float64]": +) -> npt.NDArray[np.float64]: """Return the spatial sub-affine from a napari layer's manual transform. Parameters @@ -124,7 +593,9 @@ def _make_manual_transform_payload(layer: "Layer") -> AffineTransformPayload: data = _get_source_dataarray(layer) spatial_data = _prepare_between_scan_data(data) spatial_dims = [str(dim) for dim in spatial_data.dims if dim in SPATIAL_DIMS] - manual_affine = _spatial_manual_affine_from_layer(layer, spatial_dims=spatial_dims) + manual_affine = _get_spatial_manual_affine_from_layer( + layer, spatial_dims=spatial_dims + ) pull_affine = np.linalg.inv(manual_affine) return { "kind": "affine", diff --git a/src/confusius/_napari/_registration/_panel_utils.py b/src/confusius/_napari/_registration/_panel_utils.py index ab6e6fc4..cd0c035b 100644 --- a/src/confusius/_napari/_registration/_panel_utils.py +++ b/src/confusius/_napari/_registration/_panel_utils.py @@ -293,6 +293,10 @@ def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: def _should_reset_gamma(scale_mode: str) -> bool: """Return whether registration preview/result gamma should be reset. + When using intensity scaling, the gamma of the preview and result layers is forced + to 1.0 to avoid double scaling. When scaling is off, the original layer gamma is + preserved. + Parameters ---------- scale_mode : str diff --git a/src/confusius/_napari/_registration/_transforms.py b/src/confusius/_napari/_registration/_transforms.py deleted file mode 100644 index 91f7afc1..00000000 --- a/src/confusius/_napari/_registration/_transforms.py +++ /dev/null @@ -1,482 +0,0 @@ -"""Transform payload helpers for the napari registration panel.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import TYPE_CHECKING, Literal, SupportsFloat, SupportsIndex, TypedDict, cast - -import numpy as np -import numpy.typing as npt -import xarray as xr - -from confusius.registration.bspline import validate_bspline_dataarray - -if TYPE_CHECKING: - from collections.abc import Mapping - - from confusius.registration import RegistrationDiagnostics - - -class TransformDiagnosticsPayload(TypedDict): - """JSON-serializable registration diagnostics summary.""" - - metric: str - final_metric_value: float - n_iterations: int - stop_condition: str - status: str - - -class OutputGridPayload(TypedDict): - """JSON-serializable resampling grid description.""" - - dims: list[str] - shape: list[int] - spacing: list[float] - origin: list[float] - units: list[str | None] - - -class BSplineDataArrayPayload(TypedDict): - """JSON-serializable B-spline control-point DataArray.""" - - dims: list[str] - data: list[object] - coords: dict[str, list[float]] - attrs: dict[str, object] - - -class AffineTransformPayload(TypedDict): - """JSON-serializable affine transform payload used by the napari plugin.""" - - kind: Literal["affine"] - name: str - affine: list[list[float]] - source_layer_name: str - target_layer_name: str - operation: str - transform_model: str - metric: str - output_grid: OutputGridPayload - diagnostics: TransformDiagnosticsPayload - - -class BSplineTransformPayload(TypedDict): - """B-spline transform payload used by the napari plugin.""" - - kind: Literal["bspline"] - name: str - bspline: BSplineDataArrayPayload - source_layer_name: str - target_layer_name: str - operation: str - transform_model: str - metric: str - output_grid: OutputGridPayload - diagnostics: TransformDiagnosticsPayload - - -TransformPayload = AffineTransformPayload | BSplineTransformPayload - - -def make_output_grid_payload(reference: "xr.DataArray") -> OutputGridPayload: - """Return the resampling grid defined by a reference DataArray. - - Parameters - ---------- - reference : xarray.DataArray - Spatial DataArray defining the output grid. - - Returns - ------- - OutputGridPayload - JSON-serializable output-grid description. - """ - dims = [str(dim) for dim in reference.dims] - return { - "dims": dims, - "shape": [int(reference.sizes[dim]) for dim in dims], - "spacing": [float(reference.fusi.spacing[dim]) for dim in dims], - "origin": [float(reference.fusi.origin[dim]) for dim in dims], - "units": [ - cast("str | None", reference.coords[dim].attrs.get("units")) - if dim in reference.coords - else None - for dim in dims - ], - } - - -def _make_diagnostics_payload( - diagnostics: "RegistrationDiagnostics", -) -> TransformDiagnosticsPayload: - """Return a JSON-serializable diagnostics summary.""" - return { - "metric": diagnostics.metric, - "final_metric_value": float(diagnostics.final_metric_value), - "n_iterations": int(diagnostics.n_iterations), - "stop_condition": diagnostics.stop_condition, - "status": diagnostics.status, - } - - -def make_affine_transform_payload( - affine: npt.NDArray[np.floating], - *, - reference: "xr.DataArray", - source_layer_name: str, - target_layer_name: str, - operation: str, - transform_model: str, - metric: str, - diagnostics: "RegistrationDiagnostics", - name: str | None = None, -) -> AffineTransformPayload: - """Build a JSON-serializable payload for a registered affine transform. - - Parameters - ---------- - affine : (N+1, N+1) numpy.ndarray - Affine transform in homogeneous coordinates. - reference : xarray.DataArray - Fixed/reference DataArray defining the output resampling grid. - source_layer_name : str - Name of the moving/source layer used when estimating the transform. - target_layer_name : str - Name of the fixed/target layer used when estimating the transform. - operation : str - Registration operation that produced the transform. - transform_model : str - Transform model used during registration. - metric : str - Similarity metric used during registration. - diagnostics : confusius.registration.RegistrationDiagnostics - Per-call registration diagnostics. - name : str, optional - Human-friendly transform name. If not provided, a default name is generated. - - Returns - ------- - AffineTransformPayload - JSON-serializable affine transform payload. - """ - affine = np.asarray(affine, dtype=float) - payload_name = ( - name or f"{source_layer_name} → {target_layer_name} ({transform_model})" - ) - return { - "kind": "affine", - "name": payload_name, - "affine": affine.tolist(), - "source_layer_name": source_layer_name, - "target_layer_name": target_layer_name, - "operation": operation, - "transform_model": transform_model, - "metric": metric, - "output_grid": make_output_grid_payload(reference), - "diagnostics": _make_diagnostics_payload(diagnostics), - } - - -def _serialize_bspline_dataarray(transform: "xr.DataArray") -> BSplineDataArrayPayload: - """Return a JSON-serializable B-spline DataArray payload.""" - validate_bspline_dataarray(transform) - return { - "dims": [str(dim) for dim in transform.dims], - "data": np.asarray(transform, dtype=float).tolist(), - "coords": { - str(dim): np.asarray(transform.coords[dim], dtype=float).tolist() - for dim in transform.dims - if dim in transform.coords - }, - "attrs": json.loads(json.dumps(transform.attrs)), - } - - -def _deserialize_bspline_dataarray(payload: BSplineDataArrayPayload) -> xr.DataArray: - """Reconstruct a B-spline DataArray from its JSON payload.""" - dims = [str(dim) for dim in payload["dims"]] - coords = { - str(dim): xr.DataArray(np.asarray(values, dtype=float), dims=[str(dim)]) - for dim, values in payload["coords"].items() - } - transform = xr.DataArray( - np.asarray(payload["data"], dtype=float), - dims=dims, - coords=coords, - attrs=dict(payload["attrs"]), - ) - validate_bspline_dataarray(transform) - return transform - - -def make_bspline_transform_payload( - transform: "xr.DataArray", - *, - reference: "xr.DataArray", - source_layer_name: str, - target_layer_name: str, - operation: str, - transform_model: str, - metric: str, - diagnostics: "RegistrationDiagnostics", - name: str | None = None, -) -> BSplineTransformPayload: - """Build a JSON-serializable payload for a registered B-spline transform. - - Parameters - ---------- - transform : xarray.DataArray - B-spline control-point grid. - reference : xarray.DataArray - Fixed/reference DataArray defining the output resampling grid. - source_layer_name : str - Name of the moving/source layer used when estimating the transform. - target_layer_name : str - Name of the fixed/target layer used when estimating the transform. - operation : str - Registration operation that produced the transform. - transform_model : str - Transform model used during registration. - metric : str - Similarity metric used during registration. - diagnostics : confusius.registration.RegistrationDiagnostics - Per-call registration diagnostics. - name : str, optional - Human-friendly transform name. If not provided, a default name is generated. - - Returns - ------- - BSplineTransformPayload - JSON-serializable B-spline transform payload. - """ - payload_name = ( - name or f"{source_layer_name} → {target_layer_name} ({transform_model})" - ) - return { - "kind": "bspline", - "name": payload_name, - "bspline": _serialize_bspline_dataarray(transform), - "source_layer_name": source_layer_name, - "target_layer_name": target_layer_name, - "operation": operation, - "transform_model": transform_model, - "metric": metric, - "output_grid": make_output_grid_payload(reference), - "diagnostics": _make_diagnostics_payload(diagnostics), - } - - -def affine_transform_from_payload( - payload: "Mapping[str, object]", -) -> npt.NDArray[np.float64]: - """Return the affine matrix stored in a payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - (N+1, N+1) numpy.ndarray - Affine matrix. - """ - if payload.get("kind") != "affine": - raise ValueError("Transform payload is not an affine transform.") - - affine = np.asarray(payload.get("affine"), dtype=float) - if affine.ndim != 2 or affine.shape[0] != affine.shape[1] or affine.shape[0] < 3: - raise ValueError( - "Affine payload must contain a square homogeneous matrix of shape " - "(N+1, N+1)." - ) - return affine - - -def bspline_transform_from_payload(payload: "Mapping[str, object]") -> xr.DataArray: - """Return the B-spline transform stored in a payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - xarray.DataArray - B-spline control-point grid. - """ - if payload.get("kind") != "bspline": - raise ValueError("Transform payload is not a B-spline transform.") - - bspline = payload.get("bspline") - if not isinstance(bspline, dict): - raise ValueError("B-spline payload must contain a serialized DataArray.") - return _deserialize_bspline_dataarray(cast("BSplineDataArrayPayload", bspline)) - - -def output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: - """Return the output grid stored in a transform payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - OutputGridPayload - Output-grid description stored in the payload. - """ - grid = payload.get("output_grid") - if not isinstance(grid, dict): - raise ValueError("Transform payload does not contain an output grid.") - - grid_dict = cast("dict[str, object]", grid) - dims = grid_dict.get("dims") - shape = grid_dict.get("shape") - spacing = grid_dict.get("spacing") - origin = grid_dict.get("origin") - units = grid_dict.get("units") - if not all(isinstance(v, list) for v in (dims, shape, spacing, origin, units)): - raise ValueError("Transform payload output grid is malformed.") - - dims_list = cast("list[object]", dims) - shape_list = cast("list[SupportsIndex]", shape) - spacing_list = cast("list[SupportsFloat]", spacing) - origin_list = cast("list[SupportsFloat]", origin) - units_list = cast("list[object]", units) - - return { - "dims": [str(v) for v in dims_list], - "shape": [int(v) for v in shape_list], - "spacing": [float(v) for v in spacing_list], - "origin": [float(v) for v in origin_list], - "units": [None if v is None else str(v) for v in units_list], - } - - -def _save_bspline_transform_payload( - path: str | Path, payload: BSplineTransformPayload -) -> None: - """Save a B-spline transform payload as Zarr. - - Parameters - ---------- - path : str or pathlib.Path - Output Zarr path. - payload : BSplineTransformPayload - Transform payload to save. - """ - path = Path(path) - if path.suffix != ".zarr": - raise ValueError("B-spline transform files must have .zarr extension.") - - transform = bspline_transform_from_payload(payload) - ds = transform.to_dataset(name="bspline_transform") - payload_metadata = { - key: value for key, value in payload.items() if key not in {"kind", "bspline"} - } - ds.attrs["confusius_transform_kind"] = "bspline" - ds.attrs["confusius_transform_payload_json"] = json.dumps(payload_metadata) - ds.to_zarr(path, mode="w") - - -def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload: - """Load a B-spline transform payload from Zarr. - - Parameters - ---------- - path : str or pathlib.Path - Input Zarr path. - - Returns - ------- - BSplineTransformPayload - Loaded B-spline transform payload. - """ - ds = xr.open_zarr(path) - try: - if ds.attrs.get("confusius_transform_kind") != "bspline": - raise ValueError( - "Zarr transform store does not contain a ConfUSIus B-spline transform." - ) - payload_metadata = json.loads( - cast("str", ds.attrs["confusius_transform_payload_json"]) - ) - if not isinstance(payload_metadata, dict): - raise ValueError("Stored transform payload metadata is malformed.") - transform = ds["bspline_transform"].load() - finally: - ds.close() - - validate_bspline_dataarray(transform) - payload: BSplineTransformPayload = { - "kind": "bspline", - "bspline": _serialize_bspline_dataarray(transform), - "name": str(payload_metadata["name"]), - "source_layer_name": str(payload_metadata["source_layer_name"]), - "target_layer_name": str(payload_metadata["target_layer_name"]), - "operation": str(payload_metadata["operation"]), - "transform_model": str(payload_metadata["transform_model"]), - "metric": str(payload_metadata["metric"]), - "output_grid": output_grid_from_payload(payload_metadata), - "diagnostics": cast( - "TransformDiagnosticsPayload", payload_metadata["diagnostics"] - ), - } - return payload - - -def save_transform_payload(path: str | Path, payload: TransformPayload) -> None: - """Save a transform payload to disk. - - Parameters - ---------- - path : str or pathlib.Path - Output path. - payload : TransformPayload - Transform payload to save. - - Notes - ----- - Affine payloads are saved as JSON. B-spline payloads are saved as Zarr. - """ - if payload["kind"] == "affine": - Path(path).write_text(json.dumps(payload, indent=2) + "\n") - return - _save_bspline_transform_payload(path, payload) - - -def load_transform_payload(path: str | Path) -> TransformPayload: - """Load an affine or B-spline transform payload from disk. - - Parameters - ---------- - path : str or pathlib.Path - Input path. - - Returns - ------- - TransformPayload - Loaded transform payload. - """ - path = Path(path) - if path.suffix == ".zarr": - return _load_bspline_transform_payload(path) - - payload = json.loads(path.read_text()) - if not isinstance(payload, dict): - raise ValueError("Transform file must contain a JSON object.") - - kind = payload.get("kind") - if kind != "affine": - raise ValueError( - "JSON transform files currently support affine payloads only. " - "Use .zarr for B-spline transforms." - ) - affine_transform_from_payload(payload) - output_grid_from_payload(payload) - return cast("TransformPayload", payload) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 602b2012..97f7983a 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -10,13 +10,13 @@ import xarray as xr from qtpy.QtWidgets import QApplication -from confusius._napari._registration._transforms import ( - affine_transform_from_payload, - bspline_transform_from_payload, +from confusius._napari._registration._panel_transform_helpers import ( + get_affine_transform_from_payload, + get_bspline_transform_from_payload, + get_output_grid_from_payload, load_transform_payload, make_affine_transform_payload, make_bspline_transform_payload, - output_grid_from_payload, save_transform_payload, ) from confusius.registration import resample_like @@ -185,7 +185,9 @@ def test_spinbox_defaults_and_minima(self, registration_panel): def test_scale_defaults_to_db(self, registration_panel): assert registration_panel._scale_combo.currentText() == "decibel" - def test_scale_preprocessing_resets_gamma_for_previews(self, viewer, registration_panel): + def test_scale_preprocessing_resets_gamma_for_previews( + self, viewer, registration_panel + ): moving_data = xr.DataArray( np.ones((4, 6), dtype=np.float32), dims=["y", "x"], @@ -605,7 +607,11 @@ def test_initial_transform_dropdown_lists_available_transforms( diagnostics=_FakeDiagnostics(), ) - viewer.add_image(reference.values, name="Registered", metadata={"confusius_transform": payload}) + viewer.add_image( + reference.values, + name="Registered", + metadata={"confusius_transform": payload}, + ) registration_panel._refresh_transform_controls() assert registration_panel._initialization_combo.itemText(0) == "center_geometry" @@ -628,7 +634,9 @@ def test_initial_transform_dropdown_lists_manual_napari_transforms( "x": xr.DataArray(np.arange(6), dims=["x"]), }, ) - layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + layer = viewer.add_image( + moving.values, name="moving", metadata={"xarray": moving} + ) manual_affine = np.eye(4) manual_affine[0, 3] = 1.0 layer.affine = manual_affine @@ -636,8 +644,7 @@ def test_initial_transform_dropdown_lists_manual_napari_transforms( registration_panel._refresh_transform_controls() assert any( - registration_panel._initialization_combo.itemData(i) - == ("manual", "moving") + registration_panel._initialization_combo.itemData(i) == ("manual", "moving") for i in range(registration_panel._initialization_combo.count()) ) @@ -653,7 +660,9 @@ def test_transform_source_dropdown_lists_manual_napari_transforms( "x": xr.DataArray(np.arange(6), dims=["x"]), }, ) - layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + layer = viewer.add_image( + moving.values, name="moving", metadata={"xarray": moving} + ) manual_affine = np.eye(4) manual_affine[0, 3] = 1.0 layer.affine = manual_affine @@ -678,12 +687,13 @@ def test_initial_transform_dropdown_updates_when_manual_transform_changes( "x": xr.DataArray(np.arange(6), dims=["x"]), }, ) - layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + layer = viewer.add_image( + moving.values, name="moving", metadata={"xarray": moving} + ) registration_panel._refresh_layers() assert not any( - registration_panel._initialization_combo.itemData(i) - == ("manual", "moving") + registration_panel._initialization_combo.itemData(i) == ("manual", "moving") for i in range(registration_panel._initialization_combo.count()) ) @@ -693,8 +703,7 @@ def test_initial_transform_dropdown_updates_when_manual_transform_changes( QApplication.processEvents() assert any( - registration_panel._initialization_combo.itemData(i) - == ("manual", "moving") + registration_panel._initialization_combo.itemData(i) == ("manual", "moving") for i in range(registration_panel._initialization_combo.count()) ) @@ -726,8 +735,10 @@ def test_affine_payload_roundtrip(self, tmp_path): assert loaded["source_layer_name"] == "moving" assert loaded["name"] == "moving → fixed (rigid)" - assert output_grid_from_payload(loaded)["shape"] == [4, 6] - np.testing.assert_array_equal(affine_transform_from_payload(loaded), np.eye(3)) + assert get_output_grid_from_payload(loaded)["shape"] == [4, 6] + np.testing.assert_array_equal( + get_affine_transform_from_payload(loaded), np.eye(3) + ) def test_bspline_payload_roundtrip(self, tmp_path): reference = xr.DataArray( @@ -756,9 +767,9 @@ def test_bspline_payload_roundtrip(self, tmp_path): assert loaded["name"] == "moving → fixed (bspline)" assert loaded["kind"] == "bspline" - assert output_grid_from_payload(loaded)["shape"] == [3, 4] + assert get_output_grid_from_payload(loaded)["shape"] == [3, 4] xr.testing.assert_identical( - bspline_transform_from_payload(loaded), + get_bspline_transform_from_payload(loaded), transform.astype(float), ) @@ -803,7 +814,9 @@ def test_bspline_transform_is_not_offered_for_initialization( assert "moving → fixed (bspline)" in transform_items assert "moving → fixed (bspline)" not in initialization_items - def test_apply_transform_uses_bspline_payload(self, viewer, registration_panel, monkeypatch): + def test_apply_transform_uses_bspline_payload( + self, viewer, registration_panel, monkeypatch + ): moving = xr.DataArray( np.zeros((3, 4), dtype=np.float32), dims=["y", "x"], @@ -833,7 +846,9 @@ def test_apply_transform_uses_bspline_payload(self, viewer, registration_panel, metadata={"xarray": moving, "confusius_transform": payload}, ) registration_panel._refresh_transform_controls() - registration_panel._transform_source_combo.setCurrentText("moving → fixed (bspline)") + registration_panel._transform_source_combo.setCurrentText( + "moving → fixed (bspline)" + ) registration_panel._transform_target_combo.setCurrentText("moving") captured: dict[str, object] = {} @@ -948,10 +963,7 @@ def test_setup_updates_progress_bar_and_output_layer( np.asarray(frame.values), ) - - def test_frame_completion_updates_frame_progress( - self, viewer, registration_panel - ): + def test_frame_completion_updates_frame_progress(self, viewer, registration_panel): moving = xr.DataArray( np.zeros((3, 4, 6), dtype=np.float32), dims=["time", "y", "x"], @@ -1027,7 +1039,7 @@ def test_volume_result_adds_new_layer_with_transform_metadata( assert layer.metadata["registration_diagnostics"] is diagnostics assert layer.metadata["registration_status"] == "completed" np.testing.assert_array_equal( - affine_transform_from_payload(layer.metadata["confusius_transform"]), + get_affine_transform_from_payload(layer.metadata["confusius_transform"]), transform, ) assert ( @@ -1071,7 +1083,7 @@ def test_volume_result_adds_bspline_transform_metadata( assert layer.metadata["registration_status"] == "completed" assert layer.metadata["confusius_transform"]["kind"] == "bspline" xr.testing.assert_identical( - bspline_transform_from_payload(layer.metadata["confusius_transform"]), + get_bspline_transform_from_payload(layer.metadata["confusius_transform"]), transform.astype(float), ) @@ -1371,9 +1383,7 @@ def test_volumewise_result_adds_registered_layer(self, viewer, registration_pane == "register_volumewise" ) - def test_volumewise_finished_keeps_preview_layers( - self, viewer, registration_panel - ): + def test_volumewise_finished_keeps_preview_layers(self, viewer, registration_panel): moving = xr.DataArray( np.zeros((3, 4, 6), dtype=np.float32), dims=["time", "y", "x"], From c817caa36b7edbb31f9edaba717b5fa06e62e53c Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 18:00:43 +0100 Subject: [PATCH 51/72] refactor(registration): rename panel utils to imperative form Rename the remaining noun-style panel utility helpers to imperative verb phrases for consistency with the project convention: - _default_dims_for_ndim -> get_default_dims_for_ndim - _layer_supports_registration_source -> is_registration_source_layer - _image_display_kwargs_from_layer -> get_image_display_kwargs_from_layer - _should_reset_gamma -> gamma_needs_reset Rename _parse_sequence to parse_comma_separated_ints to make the input format and parsed type explicit, and complete its one-line docstring with full Parameters and Returns sections. Add missing Parameters and Returns sections to is_registration_source_layer. --- src/confusius/_napari/_registration/_panel.py | 34 ++++++++-------- .../_napari/_registration/_panel_utils.py | 39 +++++++++++++++---- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 4db90ef6..4e2e5716 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -59,12 +59,12 @@ ScientificDoubleSpinBox, _apply_registration_scale, _get_source_dataarray, - _image_display_kwargs_from_layer, - _layer_supports_registration_source, - _parse_sequence, _prepare_between_scan_data, _preserve_view, - _should_reset_gamma, + gamma_needs_reset, + get_image_display_kwargs_from_layer, + is_registration_source_layer, + parse_comma_separated_ints, ) from confusius._napari._registration._panel_workers import ( _run_register_volume, @@ -898,7 +898,7 @@ def _sync_manual_transform_event_connections(self) -> None: self._manual_transform_event_layers = [] for layer in self.viewer.layers: - if not _layer_supports_registration_source(layer): + if not is_registration_source_layer(layer): continue _get_source_dataarray(layer) layer.events.affine.connect(self._refresh_transform_controls) @@ -912,7 +912,7 @@ def _refresh_layers(self) -> None: layer_names = [ layer.name for layer in self.viewer.layers - if _layer_supports_registration_source(layer) + if is_registration_source_layer(layer) ] self._moving_combo.blockSignals(True) @@ -1887,9 +1887,9 @@ def _setup_volumewise_progress( """Create a progress bridge and output layer for volumewise registration.""" self._teardown_volumewise_progress(remove_layer=True) - moving_display_kwargs = _image_display_kwargs_from_layer(moving_layer) + moving_display_kwargs = get_image_display_kwargs_from_layer(moving_layer) moving_display_kwargs["colormap"] = "red" - if _should_reset_gamma(scale_mode): + if gamma_needs_reset(scale_mode): moving_display_kwargs["gamma"] = 1.0 display_kwargs = dict(moving_display_kwargs) @@ -2062,13 +2062,13 @@ def _setup_volume_progress( """ self._teardown_volume_progress() - fixed_display_kwargs = _image_display_kwargs_from_layer(fixed_layer) + fixed_display_kwargs = get_image_display_kwargs_from_layer(fixed_layer) fixed_display_kwargs["colormap"] = "red" - moving_display_kwargs = _image_display_kwargs_from_layer(moving_layer) + moving_display_kwargs = get_image_display_kwargs_from_layer(moving_layer) moving_display_kwargs["colormap"] = "cyan" moving_display_kwargs["blending"] = "additive" - if _should_reset_gamma(scale_mode): + if gamma_needs_reset(scale_mode): fixed_display_kwargs["gamma"] = 1.0 moving_display_kwargs["gamma"] = 1.0 @@ -2076,7 +2076,7 @@ def _setup_volume_progress( # Render the preview in cyan with additive blending. napari sums the # RGB channels of the two layers, so red+cyan highlights - # misregistered regions as a pure colour. `_image_display_kwargs_from_layer` + # misregistered regions as a pure colour. `get_image_display_kwargs_from_layer` # copies the moving layer's colormap, so we override it explicitly # rather than rely on `setdefault`. display_kwargs["colormap"] = "cyan" @@ -2470,8 +2470,10 @@ def _run_registration(self) -> None: convergence_minimum_value = self._convergence_min_edit.value() # Parse advanced parameters - shrink_factors = _parse_sequence(self._shrink_factors_edit.text()) - smoothing_sigmas = _parse_sequence(self._smoothing_sigmas_edit.text()) + shrink_factors = parse_comma_separated_ints(self._shrink_factors_edit.text()) + smoothing_sigmas = parse_comma_separated_ints( + self._smoothing_sigmas_edit.text() + ) use_multi_res = self._multi_resolution_check.isChecked() if not use_multi_res: shrink_factors = None @@ -2710,11 +2712,11 @@ def _finalize_registration_layer( source_layer = self._get_layer_by_name(payload["moving_layer_name"]) display_kwargs = ( - _image_display_kwargs_from_layer(source_layer) + get_image_display_kwargs_from_layer(source_layer) if source_layer is not None else {} ) - if _should_reset_gamma(payload.get("scale", "off")): + if gamma_needs_reset(payload.get("scale", "off")): display_kwargs["gamma"] = 1.0 if payload["operation"] == "register_volume": display_kwargs["colormap"] = "cyan" diff --git a/src/confusius/_napari/_registration/_panel_utils.py b/src/confusius/_napari/_registration/_panel_utils.py index cd0c035b..5388ade7 100644 --- a/src/confusius/_napari/_registration/_panel_utils.py +++ b/src/confusius/_napari/_registration/_panel_utils.py @@ -61,7 +61,7 @@ def _preserve_view(viewer: "napari.Viewer") -> Iterator[None]: camera.angles = angles -def _default_dims_for_ndim(ndim: int) -> tuple[str, ...]: +def get_default_dims_for_ndim(ndim: int) -> tuple[str, ...]: """Return fallback dimension names for a raw napari layer. Parameters @@ -133,7 +133,7 @@ def _reconstruct_layer_dataarray(layer: "Layer") -> xr.DataArray: axis_labels = tuple( str(label) if label not in (None, "") else default for label, default in zip( - raw_labels, _default_dims_for_ndim(ndim), strict=False + raw_labels, get_default_dims_for_ndim(ndim), strict=False ) ) @@ -162,7 +162,7 @@ def _reconstruct_layer_dataarray(layer: "Layer") -> xr.DataArray: return xr.DataArray(data, dims=axis_labels, coords=coords) -def _layer_supports_registration_source(layer: "Layer") -> bool: +def is_registration_source_layer(layer: "Layer") -> bool: """Return whether `layer` can be converted to a registration source. ConfUSIus-managed layers carry the original `xarray.DataArray` in metadata. For @@ -170,6 +170,16 @@ def _layer_supports_registration_source(layer: "Layer") -> bool: non-NumPy layers (for example the video panel's frame-on-demand array) are intentionally excluded: forcing `np.asarray` on them can trigger expensive decoding or backend errors while the registration panel is merely refreshing. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose registration-source eligibility should be checked. + + Returns + ------- + bool + Whether `layer` can be converted into a registration source. """ if layer.metadata.get("xarray") is not None: return True @@ -270,7 +280,7 @@ def _apply_registration_scale( raise ValueError(f"Unknown registration scale mode: {scale_mode}.") -def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: +def get_image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: """Return image-display kwargs copied from an existing napari layer. Parameters @@ -290,7 +300,7 @@ def _image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: return kwargs -def _should_reset_gamma(scale_mode: str) -> bool: +def gamma_needs_reset(scale_mode: str) -> bool: """Return whether registration preview/result gamma should be reset. When using intensity scaling, the gamma of the preview and result layers is forced @@ -310,8 +320,23 @@ def _should_reset_gamma(scale_mode: str) -> bool: return scale_mode != "off" -def _parse_sequence(text: str, expected_len: int = 3) -> tuple[int, ...]: - """Parse comma-separated integers from a text field.""" +def parse_comma_separated_ints(text: str, expected_len: int = 3) -> tuple[int, ...]: + """Parse comma-separated integers from a text field. + + Parameters + ---------- + text : str + Comma-separated text to parse, e.g. ``"1, 2, 3"``. + expected_len : int, default: 3 + Required number of integers in the parsed result. + + Returns + ------- + tuple of int + Parsed integers of length `expected_len` on success, or an empty tuple + when the input is empty, contains a non-numeric value, or does not yield + exactly `expected_len` integers. + """ parts = [p.strip() for p in text.split(",") if p.strip()] if not parts: return tuple() From fc9efcb913b51ab043f79324f30d860c855d0386 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 18:02:58 +0100 Subject: [PATCH 52/72] chore: remove stray double backticks from docstrings --- src/confusius/_napari/_qc/_panel.py | 6 +-- .../_napari/_registration/_panel_utils.py | 2 +- src/confusius/_napari/_signals/_panel.py | 4 +- src/confusius/_napari/_signals/_plotter.py | 2 +- src/confusius/_napari/_signals/_store.py | 8 ++-- src/confusius/_napari/_time_overlay.py | 2 +- src/confusius/_napari/_video/_video_panel.py | 40 +++++++++---------- 7 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/confusius/_napari/_qc/_panel.py b/src/confusius/_napari/_qc/_panel.py index bdbdb51f..df589ab9 100644 --- a/src/confusius/_napari/_qc/_panel.py +++ b/src/confusius/_napari/_qc/_panel.py @@ -292,8 +292,8 @@ def _on_theme_changed(self) -> None: def _time_dim_index(self) -> int: """Return the viewer dimension index for the time dimension. - Searches all layers for xarray metadata containing a ``time`` - dimension and returns its index. Falls back to ``0`` when no + Searches all layers for xarray metadata containing a `time` + dimension and returns its index. Falls back to `0` when no suitable layer is found (same convention as the signals panel). """ for layer in self.viewer.layers: @@ -311,7 +311,7 @@ def _current_time_world(self) -> float: def _on_time_step_changed(self) -> None: """Forward the current napari time world coordinate to the cursor. - Reads the world coordinate directly from ``self.viewer.dims.point`` + Reads the world coordinate directly from `self.viewer.dims.point` for the time dimension, which is correct regardless of which layer is selected (mirrors the approach used in the signals panel). """ diff --git a/src/confusius/_napari/_registration/_panel_utils.py b/src/confusius/_napari/_registration/_panel_utils.py index 5388ade7..c38252bb 100644 --- a/src/confusius/_napari/_registration/_panel_utils.py +++ b/src/confusius/_napari/_registration/_panel_utils.py @@ -326,7 +326,7 @@ def parse_comma_separated_ints(text: str, expected_len: int = 3) -> tuple[int, . Parameters ---------- text : str - Comma-separated text to parse, e.g. ``"1, 2, 3"``. + Comma-separated text to parse, e.g. `"1, 2, 3"`. expected_len : int, default: 3 Required number of integers in the parsed result. diff --git a/src/confusius/_napari/_signals/_panel.py b/src/confusius/_napari/_signals/_panel.py index 60ee8629..03a46cdb 100644 --- a/src/confusius/_napari/_signals/_panel.py +++ b/src/confusius/_napari/_signals/_panel.py @@ -2,9 +2,9 @@ from __future__ import annotations -import napari from typing import Any, cast +import napari from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( QButtonGroup, @@ -433,7 +433,7 @@ def _on_xaxis_step_changed(self, event) -> None: def _on_frame_clicked(self, frame: float) -> None: """Navigate the viewer to the clicked x-axis coordinate. - ``frame`` is the x-axis plot value (a world coordinate, e.g. time + `frame` is the x-axis plot value (a world coordinate, e.g. time in seconds). Using `dims.set_point` avoids the double-conversion bug that occurs when setting `current_step` directly — the step index depends on `dims.range.step`, which changes when a video diff --git a/src/confusius/_napari/_signals/_plotter.py b/src/confusius/_napari/_signals/_plotter.py index 5e13a9e5..69044a0f 100644 --- a/src/confusius/_napari/_signals/_plotter.py +++ b/src/confusius/_napari/_signals/_plotter.py @@ -464,7 +464,7 @@ def _on_layer_change(self, event) -> None: """Handle layer insertion/removal or active-layer change events. When the newly active layer is not a valid signal source (e.g., a - video layer without xarray metadata), ``_current_layer`` is left + video layer without xarray metadata), `_current_layer` is left unchanged so that the plotter continues to reference the previous valid layer for signal extraction, cursor mapping, and click-to-navigate. diff --git a/src/confusius/_napari/_signals/_store.py b/src/confusius/_napari/_signals/_store.py index 7dbcff76..4ba88e60 100644 --- a/src/confusius/_napari/_signals/_store.py +++ b/src/confusius/_napari/_signals/_store.py @@ -73,17 +73,17 @@ class LiveSignal: Attributes ---------- id : str - Stable identifier (e.g. ``"mouse-0"``, ``"point-3"``, ``"label-5"``). + Stable identifier (e.g. `"mouse-0"`, `"point-3"`, `"label-5"`). name : str Display name used in legends (editable by the user). color : str Hex color for the plot line. visible : bool Whether the signal should be plotted. - source_type : ``"mouse"`` | ``"point"`` | ``"label"`` + source_type : `"mouse"` | `"point"` | `"label"` Kind of napari source that produces this signal. source_id : int | None - ``None`` for mouse, point index for points, label integer for labels. + `None` for mouse, point index for points, label integer for labels. """ id: str @@ -328,7 +328,7 @@ def get_live_signal(self, signal_id: str) -> LiveSignal | None: Returns ------- LiveSignal | None - The signal, or ``None`` if not found. + The signal, or `None` if not found. """ return self._live_signals.get(signal_id) diff --git a/src/confusius/_napari/_time_overlay.py b/src/confusius/_napari/_time_overlay.py index 80c4badf..953a7648 100644 --- a/src/confusius/_napari/_time_overlay.py +++ b/src/confusius/_napari/_time_overlay.py @@ -85,7 +85,7 @@ def _read_time_value(self) -> float | None: scale/translate approximation for non-uniform spacing. For layers without xarray metadata (e.g., video layers), returns - `None` so that the caller falls back to ``dims.point`` which is + `None` so that the caller falls back to `dims.point` which is correct as long as the layer's time scale is set properly. """ if self._ref_layer is None: diff --git a/src/confusius/_napari/_video/_video_panel.py b/src/confusius/_napari/_video/_video_panel.py index 2a06ddbb..f9f10098 100644 --- a/src/confusius/_napari/_video/_video_panel.py +++ b/src/confusius/_napari/_video/_video_panel.py @@ -34,14 +34,14 @@ class _VideoArray: """Array-like wrapper around `VideoReaderNP` for napari Image layers. - Provides the ``shape``, ``dtype``, and ``__getitem__`` interface that + Provides the `shape`, `dtype`, and `__getitem__` interface that napari requires for lazy, frame-on-demand display. Handles singleton-dimension padding so the video matches the fUSI scan's dimensionality. - The positions of H and W in the shape are controlled by ``h_dim`` and - ``w_dim``. When ``h_dim > w_dim`` (H appears after W in the layout), - the raw ``(H, W)`` frame is transposed before reshaping so that the + The positions of H and W in the shape are controlled by `h_dim` and + `w_dim`. When `h_dim > w_dim` (H appears after W in the layout), + the raw `(H, W)` frame is transposed before reshaping so that the data matches the expected axis order. Parameters @@ -51,20 +51,20 @@ class _VideoArray: dtype : numpy.dtype Data type of a decoded frame. frame_shape : tuple[int, ...] - Shape of a single decoded frame --- ``(H, W)`` or ``(H, W, C)``. + Shape of a single decoded frame --- `(H, W)` or `(H, W, C)`. n_pad : int, default: 0 Number of size-1 dimensions inserted between the time axis and the spatial axes. step : int, default: 1 Show every *step*-th frame (temporal subsampling). Logical - frame ``t`` maps to physical frame ``t * step``. + frame `t` maps to physical frame `t * step`. time_dim : int, default: 0 Position of the time axis in the output shape. h_dim : int or None, optional - Position of the video height axis. Defaults to ``n_core - 2`` - where ``n_core = 1 + n_pad + 2``. + Position of the video height axis. Defaults to `n_core - 2` + where `n_core = 1 + n_pad + 2`. w_dim : int or None, optional - Position of the video width axis. Defaults to ``n_core - 1``. + Position of the video width axis. Defaults to `n_core - 1`. """ def __init__( @@ -221,7 +221,7 @@ class VideoPanel(QWidget): layer and its own grid cell. All videos share a single reference fUSI scan (selected at first load) so they align on the same time and spatial axes. Videos are passed to napari as lazy, array-like - objects backed by ``VideoReaderNP`` (OpenCV frame-on-demand + objects backed by `VideoReaderNP` (OpenCV frame-on-demand decoding). A thin wrapper (`_VideoArray`) handles singleton dimension padding and dimension reordering. @@ -229,9 +229,9 @@ class VideoPanel(QWidget): is rebuilt with a new `_VideoArray` whose shape places H and W at the currently displayed dim positions. - The video layers receive the same ``axis_labels`` as the fUSI scan + The video layers receive the same `axis_labels` as the fUSI scan so that napari handles dimension reordering identically for both. - The time scale is ``frame_step / fps``, shared across all videos. + The time scale is `frame_step / fps`, shared across all videos. Spatial dimensions use a per-video isotropic scale matching the fUSI scan height. @@ -504,7 +504,7 @@ def _browse(self) -> None: self._load_from_path() def _load_from_path(self) -> None: - """Validate inputs and call ``_add_video``.""" + """Validate inputs and call `_add_video`.""" ref = self._get_ref_layer() if ref is None: show_error("Select a reference layer first.") @@ -616,8 +616,8 @@ def _rebuild_entry(self, entry: _VideoEntry) -> None: ) # Time scale = frame_step / fps. Each logical frame spans - # ``frame_step`` physical frames, so consecutive data points are - # ``frame_step / fps`` seconds apart. + # `frame_step` physical frames, so consecutive data points are + # `frame_step / fps` seconds apart. time_scale = frame_step / entry.fps if entry.fps > 0 else 1.0 # Isotropic spatial scale (video pixels are square). spatial_scale = self._compute_spatial_scale(displayed_v, entry.video_h) @@ -738,7 +738,7 @@ def _on_frame_step_changed(self, value: int) -> None: """Rebuild all video layers with a new frame step. The step is encoded in each `_VideoArray` shape (fewer logical - frames) and the layer's time scale (``value / fps``). napari + frames) and the layer's time scale (`value / fps`). napari auto-computes the correct slider range from shape and scale. The current world time is saved before the rebuild and restored @@ -769,7 +769,7 @@ def _on_frame_step_changed(self, value: int) -> None: def _lookup_coord(self, dim_idx: int) -> np.ndarray | None: """Return the reference xarray coordinate for *dim_idx*, or None. - Returns ``None`` when there is no reference layer, no xarray + Returns `None` when there is no reference layer, no xarray metadata, or the corresponding coordinate does not exist. """ ref = self._ref_layer @@ -795,7 +795,7 @@ def _compute_spatial_scale(self, vertical_dim: int, video_h: int) -> float: """Return the isotropic spatial scale for the video. The scale maps the video's height to the fUSI scan's extent - along ``vertical_dim`` and is then applied identically to both + along `vertical_dim` and is then applied identically to both displayed spatial axes so that video pixels remain square -- webcam pixels are isotropic and must not be stretched. @@ -827,8 +827,8 @@ def _compute_axis_center_translate( ) -> float: """Return the translation that centers the video on the fUSI. - The video's centre pixel along ``dim_idx`` (at index - ``(video_n - 1) / 2``) is placed at the midpoint of the fUSI + The video's centre pixel along `dim_idx` (at index + `(video_n - 1) / 2`) is placed at the midpoint of the fUSI coordinate range, so the video overlays the scan in both spatial axes. """ From dafe812e4f5a9f0af7372b012e6d88de0cc732ee Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 18:11:49 +0100 Subject: [PATCH 53/72] refactor(registration): restore private prefix on panel utils Add the leading underscore back to the five recently renamed panel utility helpers to keep the file consistent with its existing private naming convention: - get_default_dims_for_ndim -> _get_default_dims_for_ndim - is_registration_source_layer -> _is_registration_source_layer - get_image_display_kwargs_from_layer -> _get_image_display_kwargs_from_layer - gamma_needs_reset -> _gamma_needs_reset - parse_comma_separated_ints -> _parse_comma_separated_ints The functions remain module-internal to the napari registration module; only their call sites in _panel.py needed updating. --- src/confusius/_napari/_registration/_panel.py | 32 +++++++++---------- .../_napari/_registration/_panel_utils.py | 12 +++---- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 4e2e5716..8b571e8d 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -58,13 +58,13 @@ from confusius._napari._registration._panel_utils import ( ScientificDoubleSpinBox, _apply_registration_scale, + _gamma_needs_reset, + _get_image_display_kwargs_from_layer, _get_source_dataarray, + _is_registration_source_layer, + _parse_comma_separated_ints, _prepare_between_scan_data, _preserve_view, - gamma_needs_reset, - get_image_display_kwargs_from_layer, - is_registration_source_layer, - parse_comma_separated_ints, ) from confusius._napari._registration._panel_workers import ( _run_register_volume, @@ -898,7 +898,7 @@ def _sync_manual_transform_event_connections(self) -> None: self._manual_transform_event_layers = [] for layer in self.viewer.layers: - if not is_registration_source_layer(layer): + if not _is_registration_source_layer(layer): continue _get_source_dataarray(layer) layer.events.affine.connect(self._refresh_transform_controls) @@ -912,7 +912,7 @@ def _refresh_layers(self) -> None: layer_names = [ layer.name for layer in self.viewer.layers - if is_registration_source_layer(layer) + if _is_registration_source_layer(layer) ] self._moving_combo.blockSignals(True) @@ -1887,9 +1887,9 @@ def _setup_volumewise_progress( """Create a progress bridge and output layer for volumewise registration.""" self._teardown_volumewise_progress(remove_layer=True) - moving_display_kwargs = get_image_display_kwargs_from_layer(moving_layer) + moving_display_kwargs = _get_image_display_kwargs_from_layer(moving_layer) moving_display_kwargs["colormap"] = "red" - if gamma_needs_reset(scale_mode): + if _gamma_needs_reset(scale_mode): moving_display_kwargs["gamma"] = 1.0 display_kwargs = dict(moving_display_kwargs) @@ -2062,13 +2062,13 @@ def _setup_volume_progress( """ self._teardown_volume_progress() - fixed_display_kwargs = get_image_display_kwargs_from_layer(fixed_layer) + fixed_display_kwargs = _get_image_display_kwargs_from_layer(fixed_layer) fixed_display_kwargs["colormap"] = "red" - moving_display_kwargs = get_image_display_kwargs_from_layer(moving_layer) + moving_display_kwargs = _get_image_display_kwargs_from_layer(moving_layer) moving_display_kwargs["colormap"] = "cyan" moving_display_kwargs["blending"] = "additive" - if gamma_needs_reset(scale_mode): + if _gamma_needs_reset(scale_mode): fixed_display_kwargs["gamma"] = 1.0 moving_display_kwargs["gamma"] = 1.0 @@ -2076,7 +2076,7 @@ def _setup_volume_progress( # Render the preview in cyan with additive blending. napari sums the # RGB channels of the two layers, so red+cyan highlights - # misregistered regions as a pure colour. `get_image_display_kwargs_from_layer` + # misregistered regions as a pure colour. `_get_image_display_kwargs_from_layer` # copies the moving layer's colormap, so we override it explicitly # rather than rely on `setdefault`. display_kwargs["colormap"] = "cyan" @@ -2470,8 +2470,8 @@ def _run_registration(self) -> None: convergence_minimum_value = self._convergence_min_edit.value() # Parse advanced parameters - shrink_factors = parse_comma_separated_ints(self._shrink_factors_edit.text()) - smoothing_sigmas = parse_comma_separated_ints( + shrink_factors = _parse_comma_separated_ints(self._shrink_factors_edit.text()) + smoothing_sigmas = _parse_comma_separated_ints( self._smoothing_sigmas_edit.text() ) use_multi_res = self._multi_resolution_check.isChecked() @@ -2712,11 +2712,11 @@ def _finalize_registration_layer( source_layer = self._get_layer_by_name(payload["moving_layer_name"]) display_kwargs = ( - get_image_display_kwargs_from_layer(source_layer) + _get_image_display_kwargs_from_layer(source_layer) if source_layer is not None else {} ) - if gamma_needs_reset(payload.get("scale", "off")): + if _gamma_needs_reset(payload.get("scale", "off")): display_kwargs["gamma"] = 1.0 if payload["operation"] == "register_volume": display_kwargs["colormap"] = "cyan" diff --git a/src/confusius/_napari/_registration/_panel_utils.py b/src/confusius/_napari/_registration/_panel_utils.py index c38252bb..3dd2e976 100644 --- a/src/confusius/_napari/_registration/_panel_utils.py +++ b/src/confusius/_napari/_registration/_panel_utils.py @@ -61,7 +61,7 @@ def _preserve_view(viewer: "napari.Viewer") -> Iterator[None]: camera.angles = angles -def get_default_dims_for_ndim(ndim: int) -> tuple[str, ...]: +def _get_default_dims_for_ndim(ndim: int) -> tuple[str, ...]: """Return fallback dimension names for a raw napari layer. Parameters @@ -133,7 +133,7 @@ def _reconstruct_layer_dataarray(layer: "Layer") -> xr.DataArray: axis_labels = tuple( str(label) if label not in (None, "") else default for label, default in zip( - raw_labels, get_default_dims_for_ndim(ndim), strict=False + raw_labels, _get_default_dims_for_ndim(ndim), strict=False ) ) @@ -162,7 +162,7 @@ def _reconstruct_layer_dataarray(layer: "Layer") -> xr.DataArray: return xr.DataArray(data, dims=axis_labels, coords=coords) -def is_registration_source_layer(layer: "Layer") -> bool: +def _is_registration_source_layer(layer: "Layer") -> bool: """Return whether `layer` can be converted to a registration source. ConfUSIus-managed layers carry the original `xarray.DataArray` in metadata. For @@ -280,7 +280,7 @@ def _apply_registration_scale( raise ValueError(f"Unknown registration scale mode: {scale_mode}.") -def get_image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: +def _get_image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: """Return image-display kwargs copied from an existing napari layer. Parameters @@ -300,7 +300,7 @@ def get_image_display_kwargs_from_layer(layer: "Layer") -> dict[str, Any]: return kwargs -def gamma_needs_reset(scale_mode: str) -> bool: +def _gamma_needs_reset(scale_mode: str) -> bool: """Return whether registration preview/result gamma should be reset. When using intensity scaling, the gamma of the preview and result layers is forced @@ -320,7 +320,7 @@ def gamma_needs_reset(scale_mode: str) -> bool: return scale_mode != "off" -def parse_comma_separated_ints(text: str, expected_len: int = 3) -> tuple[int, ...]: +def _parse_comma_separated_ints(text: str, expected_len: int = 3) -> tuple[int, ...]: """Parse comma-separated integers from a text field. Parameters From 59fc10bfe6f95ce3ceb9bf980948c0bcc7cb47b9 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 20:21:08 +0100 Subject: [PATCH 54/72] refactor(napari): simplify panel helpers --- src/confusius/_napari/_qt.py | 33 + src/confusius/_napari/_registration/_panel.py | 733 +++--------------- .../_registration/_panel_parameters.py | 165 ++++ .../_napari/_registration/_panel_progress.py | 498 ++++++++++++ src/confusius/_napari/_signals/_panel.py | 24 +- .../test_napari/test_registration_panel.py | 120 ++- 6 files changed, 879 insertions(+), 694 deletions(-) create mode 100644 src/confusius/_napari/_qt.py create mode 100644 src/confusius/_napari/_registration/_panel_parameters.py create mode 100644 src/confusius/_napari/_registration/_panel_progress.py diff --git a/src/confusius/_napari/_qt.py b/src/confusius/_napari/_qt.py new file mode 100644 index 00000000..da4e5543 --- /dev/null +++ b/src/confusius/_napari/_qt.py @@ -0,0 +1,33 @@ +"""Shared Qt helpers for internal napari panels.""" + +from __future__ import annotations + +from qtpy.QtWidgets import QMainWindow, QWidget + + +def find_main_window(widget: QWidget) -> QMainWindow | None: + """Return the ancestor `QMainWindow` for a widget, if present. + + Parameters + ---------- + widget : QWidget + Starting widget to search from. + + Returns + ------- + QMainWindow or None + The containing main window, or `None` if no ancestor main window is + found or the Qt object was already deleted. + """ + try: + parent = widget.parent() + except RuntimeError: + return None + while parent is not None: + if isinstance(parent, QMainWindow): + return parent + try: + parent = parent.parent() + except RuntimeError: + return None + return None diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 8b571e8d..b5d8260a 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from pathlib import Path from threading import Event from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, cast @@ -12,7 +11,7 @@ from napari.layers.utils.layer_utils import calc_data_range from napari.qt.threading import thread_worker from napari.utils.notifications import show_error, show_info -from qtpy.QtCore import Qt, QTimer +from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QApplication, QButtonGroup, @@ -26,7 +25,6 @@ QHBoxLayout, QLabel, QLineEdit, - QMainWindow, QProgressBar, QPushButton, QRadioButton, @@ -41,6 +39,17 @@ from confusius._napari._registration._metric_plotter import ( RegistrationMetricPlotter, ) +from confusius._napari._registration._panel_parameters import ( + get_default_registration_parameters, + get_registration_parameters, + set_registration_parameters, +) +from confusius._napari._registration._panel_progress import ( + setup_volume_progress, + setup_volumewise_progress, + teardown_volume_progress, + teardown_volumewise_progress, +) from confusius._napari._registration._panel_transform_helpers import ( AffineTransformPayload, TransformPayload, @@ -64,7 +73,6 @@ _is_registration_source_layer, _parse_comma_separated_ints, _prepare_between_scan_data, - _preserve_view, ) from confusius._napari._registration._panel_workers import ( _run_register_volume, @@ -72,13 +80,10 @@ ) from confusius._napari._registration._progress import ( NapariProgressBridge, - NapariRegistrationProgressReporter, NapariRegistrationProgressReporterBridge, - make_napari_progress_factory, ) from confusius.plotting.napari import plot_napari from confusius.registration import ( - resample_like, resample_volume, ) @@ -87,7 +92,7 @@ import numpy.typing as npt from napari.layers import Image, Layer - from confusius.registration import RegistrationDiagnostics, RegistrationProgress + from confusius.registration import RegistrationDiagnostics ScaleMode = Literal["off", "dB", "sqrt"] @@ -406,7 +411,6 @@ def _setup_ui(self) -> None: self._n_jobs_spin = QSpinBox() self._n_jobs_spin.setRange(-128, 128) self._n_jobs_spin.setSpecialValueText("auto") - self._n_jobs_spin.setValue(-1) self._n_jobs_spin.setToolTip( "Number of workers for time-series registration. -1 uses all CPUs." ) @@ -443,17 +447,14 @@ def _setup_ui(self) -> None: self._mesh_size_z_spin = QSpinBox() self._mesh_size_z_spin.setRange(1, 512) - self._mesh_size_z_spin.setValue(10) self._mesh_size_z_spin.setMaximumWidth(48) self._mesh_size_z_spin.setToolTip("B-spline mesh size along z.") self._mesh_size_y_spin = QSpinBox() self._mesh_size_y_spin.setRange(1, 512) - self._mesh_size_y_spin.setValue(10) self._mesh_size_y_spin.setMaximumWidth(48) self._mesh_size_y_spin.setToolTip("B-spline mesh size along y.") self._mesh_size_x_spin = QSpinBox() self._mesh_size_x_spin.setRange(1, 512) - self._mesh_size_x_spin.setValue(10) self._mesh_size_x_spin.setMaximumWidth(48) self._mesh_size_x_spin.setToolTip("B-spline mesh size along x.") self._mesh_size_row = QWidget() @@ -538,11 +539,9 @@ def _setup_ui(self) -> None: learning_rate_row = QHBoxLayout() self._learning_rate_auto_check = QCheckBox("Auto") - self._learning_rate_auto_check.setChecked(True) self._learning_rate_edit = ScientificDoubleSpinBox() self._learning_rate_edit.setRange(1e-10, 1e3) self._learning_rate_edit.setSingleStep(0.1) - self._learning_rate_edit.setValue(0.1) self._learning_rate_edit.setToolTip( "Optimizer step size. Accepts decimal (0.1) or scientific notation (1e-5)." ) @@ -565,7 +564,6 @@ def _setup_ui(self) -> None: self._iterations_spin = QSpinBox() self._iterations_spin.setRange(1, 100_000) self._iterations_spin.setSingleStep(100) - self._iterations_spin.setValue(100) self._iterations_spin.setMaximumWidth(96) params_layout.addRow( self._make_form_label( @@ -587,7 +585,6 @@ def _setup_ui(self) -> None: self._advanced_toggle = QToolButton() self._advanced_toggle.setCheckable(True) - self._advanced_toggle.setChecked(False) self._advanced_toggle.setAutoRaise(True) self._advanced_toggle.setToolButtonStyle( Qt.ToolButtonStyle.ToolButtonTextBesideIcon @@ -608,7 +605,6 @@ def _setup_ui(self) -> None: self._histogram_bins_spin = QSpinBox() self._histogram_bins_spin.setRange(8, 512) - self._histogram_bins_spin.setValue(50) self._histogram_bins_spin.setToolTip( "Number of histogram bins for Mattes mutual information metric." ) @@ -622,7 +618,6 @@ def _setup_ui(self) -> None: self._convergence_min_edit = ScientificDoubleSpinBox() self._convergence_min_edit.setRange(1e-10, 1.0) self._convergence_min_edit.setSingleStep(1e-6) - self._convergence_min_edit.setValue(1e-6) self._convergence_min_edit.setToolTip( "Convergence threshold. Accepts decimal (0.000001) or scientific notation (1e-6)." ) @@ -635,7 +630,6 @@ def _setup_ui(self) -> None: self._convergence_window_spin = QSpinBox() self._convergence_window_spin.setRange(1, 100) - self._convergence_window_spin.setValue(10) self._convergence_window_spin.setToolTip( "Number of recent metric values for convergence estimation." ) @@ -653,7 +647,6 @@ def _setup_ui(self) -> None: self._multi_resolution_check.setToolTip( "Run registration from coarse to fine resolution levels." ) - self._multi_resolution_check.setChecked(False) self._multi_resolution_row = self._make_advanced_row( advanced_layout, "Multi-resolution", @@ -661,7 +654,7 @@ def _setup_ui(self) -> None: tooltip="Whether to optimize from coarse to fine resolution levels.", ) - self._shrink_factors_edit = QLineEdit("6, 2, 1") + self._shrink_factors_edit = QLineEdit() self._shrink_factors_edit.setToolTip( "Comma-separated shrink factors per resolution level for multi-resolution." ) @@ -672,7 +665,7 @@ def _setup_ui(self) -> None: tooltip="Comma-separated downsampling factors for each multi-resolution level.", ) - self._smoothing_sigmas_edit = QLineEdit("6, 2, 1") + self._smoothing_sigmas_edit = QLineEdit() self._smoothing_sigmas_edit.setToolTip( "Comma-separated smoothing sigmas per resolution level for multi-resolution." ) @@ -703,7 +696,6 @@ def _setup_ui(self) -> None: ) self._fill_value_auto_check = QCheckBox("minimum") - self._fill_value_auto_check.setChecked(True) self._fill_value_auto_check.setToolTip( "Automatically use the minimum intensity of the fixed image as fill value." ) @@ -713,7 +705,6 @@ def _setup_ui(self) -> None: ) self._fill_value_spin.setRange(-1e6, 1e6) self._fill_value_spin.setDecimals(3) - self._fill_value_spin.setValue(0.0) self._fill_value_spin.setEnabled(False) self._fill_value_spin.setToolTip( "Fill value for resampled output outside the input domain." @@ -766,9 +757,6 @@ def _setup_ui(self) -> None: self._update_transform_dependent_visibility ) self._on_advanced_toggled(False) - self._update_multi_resolution_enabled(False) - self._update_metric_dependent_visibility(self._metric_combo.currentText()) - self._update_transform_dependent_visibility(self._transform_combo.currentText()) self._register_panel = QWidget() register_layout = QVBoxLayout(self._register_panel) @@ -878,11 +866,16 @@ def _setup_ui(self) -> None: ) self._registration_parameters_by_operation = { - "register_volume": self._default_registration_parameters(mode="volume"), - "register_volumewise": self._default_registration_parameters( + "register_volume": get_default_registration_parameters(mode="volume"), + "register_volumewise": get_default_registration_parameters( mode="volumewise" ), } + set_registration_parameters( + self, + self._registration_parameters_by_operation["register_volume"], + mode="volume", + ) self._refresh_layers() self._on_panel_changed() @@ -1132,6 +1125,11 @@ def _set_image_layer_data(self, layer: Image, data: npt.NDArray[Any]) -> None: Image layer whose data should be replaced. data : numpy.ndarray Replacement array. + + Returns + ------- + None + Updates `layer` in place. """ cast("Any", layer).data = data @@ -1210,7 +1208,22 @@ def _volume_result_layer_name( *, transform_model: str | None = None, ) -> str: - """Return the napari layer name for between-scan registration output.""" + """Return the napari layer name for between-scan registration output. + + Parameters + ---------- + moving_name : str + Moving-layer name. Unused, but kept for call-site clarity. + fixed_name : str + Fixed-layer name. Unused, but kept for call-site clarity. + transform_model : str, optional + Transform model to include in the result-layer label. + + Returns + ------- + str + Result-layer name. + """ del moving_name, fixed_name model = transform_model or self._transform_combo.currentText() return f"Registered ({model})" @@ -1224,7 +1237,18 @@ def _volume_moving_preview_layer_name(self) -> str: return "Moving" def _volumewise_result_layer_name(self, moving_name: str) -> str: - """Return the napari layer name for within-scan registration output.""" + """Return the napari layer name for within-scan registration output. + + Parameters + ---------- + moving_name : str + Moving-layer name. Unused, but kept for call-site symmetry. + + Returns + ------- + str + Result-layer name. + """ del moving_name return "Motion corrected" @@ -1252,7 +1276,13 @@ def _available_transform_payloads(self) -> list[TransformPayload]: return payloads def _refresh_transform_controls(self) -> None: - """Refresh transform-related layer selectors.""" + """Refresh transform-related layer selectors. + + Returns + ------- + None + Updates transform, initialization, and target selectors in place. + """ source_data = self._transform_source_combo.currentData() initialization_data = self._initialization_combo.currentData() target_name = self._transform_target_combo.currentText() @@ -1354,7 +1384,14 @@ def _refresh_transform_controls(self) -> None: self._transform_target_combo.setCurrentIndex(target_index) def _selected_transform_payload(self) -> TransformPayload | None: - """Return the currently selected transform payload.""" + """Return the currently selected transform payload. + + Returns + ------- + TransformPayload or None + Selected transform payload, or `None` when no valid selection is + available. + """ source_data = self._transform_source_data( self._transform_source_combo.currentData() ) @@ -1380,14 +1417,28 @@ def _selected_transform_payload(self) -> TransformPayload | None: def _selected_center_initialization( self, ) -> Literal["center_geometry", "center_moments"] | None: - """Return the selected built-in centering initialization, if any.""" + """Return the selected built-in centering initialization, if any. + + Returns + ------- + {"center_geometry", "center_moments"} or None + Selected built-in initialization, or `None` when the selection is + an explicit transform or identity. + """ value = self._initialization_combo.currentData() if value in {"center_geometry", "center_moments"}: return value return None def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: - """Return the payload selected for registration initialization, if any.""" + """Return the payload selected for registration initialization, if any. + + Returns + ------- + AffineTransformPayload or None + Selected affine initialization payload, or `None` when the current + initialization does not point to an affine payload. + """ source_data = self._transform_source_data( self._initialization_combo.currentData() ) @@ -1410,7 +1461,14 @@ def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: return _get_affine_payload_from_layer(layer) def _selected_manual_initialization_layer(self) -> Layer | None: - """Return the layer selected for manual napari initialization, if any.""" + """Return the layer selected for manual napari initialization, if any. + + Returns + ------- + napari.layers.Layer or None + Selected manual-initialization layer, or `None` when the current + initialization is not a manual layer transform. + """ source_data = self._transform_source_data( self._initialization_combo.currentData() ) @@ -1679,148 +1737,6 @@ def _update_transform_dependent_visibility(self, transform: str) -> None: self._operation() == "register_volume" and transform == "bspline" ) - def _default_registration_parameters( - self, *, mode: RegistrationParameterMode - ) -> ModeParameters: - """Return the default parameter state for one registration mode. - - Parameters - ---------- - mode : {"volume", "volumewise"} - Registration workflow whose defaults should be returned. - - Returns - ------- - ModeParameters - Default parameter values for the requested workflow. - """ - is_volumewise = mode == "volumewise" - return { - "transform": "rigid", - "metric": "correlation", - "scale": "dB", - "initialization": "center_geometry", - "learning_rate_auto": not is_volumewise, - "learning_rate_value": 0.01 if is_volumewise else 0.1, - "number_of_iterations": 100, - "number_of_histogram_bins": 50, - "mesh_size": (10, 10, 10), - "convergence_minimum_value": 1e-6, - "convergence_window_size": 10, - "use_multi_resolution": False, - "shrink_factors": "6, 2, 1", - "smoothing_sigmas": "6, 2, 1", - "resample_interpolation": "linear", - "fill_value_auto": True, - "fill_value": 0.0, - "reference_time": 0, - "n_jobs": -1, - "keep_diagnostics": False, - "advanced_open": False, - } - - def _get_registration_parameters(self) -> ModeParameters: - """Return the current parameter state shown in the panel. - - Returns - ------- - ModeParameters - Current parameter values read from the visible widgets. - """ - return { - "transform": self._transform_combo.currentText() or "rigid", - "metric": self._current_metric(), - "scale": self._current_scale_mode(), - "initialization": self._initialization_combo.currentData(), - "learning_rate_auto": self._learning_rate_auto_check.isChecked(), - "learning_rate_value": self._learning_rate_edit.value(), - "number_of_iterations": self._iterations_spin.value(), - "number_of_histogram_bins": self._histogram_bins_spin.value(), - "mesh_size": ( - self._mesh_size_z_spin.value(), - self._mesh_size_y_spin.value(), - self._mesh_size_x_spin.value(), - ), - "convergence_minimum_value": self._convergence_min_edit.value(), - "convergence_window_size": self._convergence_window_spin.value(), - "use_multi_resolution": self._multi_resolution_check.isChecked(), - "shrink_factors": self._shrink_factors_edit.text(), - "smoothing_sigmas": self._smoothing_sigmas_edit.text(), - "resample_interpolation": self._current_resample_interpolation(), - "fill_value_auto": self._fill_value_auto_check.isChecked(), - "fill_value": self._fill_value_spin.value(), - "reference_time": self._reference_time_spin.value(), - "n_jobs": self._n_jobs_spin.value(), - "keep_diagnostics": self._keep_diagnostics_check.isChecked(), - "advanced_open": self._advanced_toggle.isChecked(), - } - - def _set_registration_parameters( - self, params: ModeParameters, *, mode: RegistrationParameterMode - ) -> None: - """Restore the parameter state for one registration mode. - - Parameters - ---------- - params : ModeParameters - Parameter values to push back into the widgets. - mode : {"volume", "volumewise"} - Registration workflow whose UI should be restored. - """ - self._transform_combo.blockSignals(True) - self._transform_combo.clear() - is_volumewise = mode == "volumewise" - if is_volumewise: - self._transform_combo.addItems(["translation", "rigid", "affine"]) - else: - self._transform_combo.addItems( - ["translation", "rigid", "affine", "bspline"] - ) - transform = params["transform"] - transform_index = self._transform_combo.findText(transform) - if transform_index < 0: - transform_index = self._transform_combo.findText("rigid") - if transform_index >= 0: - self._transform_combo.setCurrentIndex(transform_index) - self._transform_combo.blockSignals(False) - - self._metric_combo.setCurrentText(params["metric"]) - scale_mode = params["scale"] - scale_index = self._scale_combo.findData(scale_mode) - if scale_index >= 0: - self._scale_combo.setCurrentIndex(scale_index) - initialization_data = params.get("initialization") - for i in range(self._initialization_combo.count()): - if self._initialization_combo.itemData(i) == initialization_data: - self._initialization_combo.setCurrentIndex(i) - break - self._learning_rate_auto_check.setChecked( - False if is_volumewise else params["learning_rate_auto"] - ) - self._learning_rate_edit.setValue(params["learning_rate_value"]) - self._iterations_spin.setValue(params["number_of_iterations"]) - self._histogram_bins_spin.setValue(params["number_of_histogram_bins"]) - mesh_size = params["mesh_size"] - self._mesh_size_z_spin.setValue(mesh_size[0]) - self._mesh_size_y_spin.setValue(mesh_size[1]) - self._mesh_size_x_spin.setValue(mesh_size[2]) - self._convergence_min_edit.setValue(params["convergence_minimum_value"]) - self._convergence_window_spin.setValue(params["convergence_window_size"]) - self._multi_resolution_check.setChecked(params["use_multi_resolution"]) - self._shrink_factors_edit.setText(params["shrink_factors"]) - self._smoothing_sigmas_edit.setText(params["smoothing_sigmas"]) - self._interpolation_combo.setCurrentText(params["resample_interpolation"]) - self._fill_value_auto_check.setChecked(params["fill_value_auto"]) - self._fill_value_spin.setValue(params["fill_value"]) - self._reference_time_spin.setValue(params["reference_time"]) - self._n_jobs_spin.setValue(params["n_jobs"]) - self._keep_diagnostics_check.setChecked(params["keep_diagnostics"]) - self._advanced_toggle.setChecked(params["advanced_open"]) - self._on_advanced_toggled(self._advanced_toggle.isChecked()) - self._update_metric_dependent_visibility(self._metric_combo.currentText()) - self._update_multi_resolution_enabled(self._multi_resolution_check.isChecked()) - self._update_transform_dependent_visibility(self._transform_combo.currentText()) - def _on_mode_changed(self) -> None: """Update the panel when the registration mode changes.""" new_mode = self._operation() @@ -1829,7 +1745,7 @@ def _on_mode_changed(self) -> None: if previous_mode in self._registration_parameters_by_operation: self._registration_parameters_by_operation[previous_mode] = ( - self._get_registration_parameters() + get_registration_parameters(self) ) self._fixed_label.setVisible(not is_volumewise) @@ -1843,7 +1759,8 @@ def _on_mode_changed(self) -> None: self._fill_value_row.setVisible(not is_volumewise) self._keep_diagnostics_row.setVisible(is_volumewise) - self._set_registration_parameters( + set_registration_parameters( + self, self._registration_parameters_by_operation[new_mode], mode="volumewise" if is_volumewise else "volume", ) @@ -1876,450 +1793,6 @@ def _abort_registration(self) -> None: self._abort_btn.setText("Aborting…") self._set_error("Aborting registration…") - def _setup_volumewise_progress( - self, - *, - moving_layer: "Image", - moving: xr.DataArray, - layer_name: str, - scale_mode: str = "off", - ) -> NapariRegistrationProgressReporter: - """Create a progress bridge and output layer for volumewise registration.""" - self._teardown_volumewise_progress(remove_layer=True) - - moving_display_kwargs = _get_image_display_kwargs_from_layer(moving_layer) - moving_display_kwargs["colormap"] = "red" - if _gamma_needs_reset(scale_mode): - moving_display_kwargs["gamma"] = 1.0 - - display_kwargs = dict(moving_display_kwargs) - display_kwargs["colormap"] = "cyan" - display_kwargs["blending"] = "additive" - contrast_limits = tuple(calc_data_range(moving.data)) - preview_data = np.full( - moving.shape, - fill_value=float(np.min(moving.data)), - dtype=np.asarray(moving.data).dtype, - ) - preview = xr.DataArray( - preview_data, - dims=moving.dims, - coords=moving.coords, - attrs=moving.attrs.copy(), - ) - - # Adding the preview/progress layers makes napari recompute the camera - # and reset the dims; snapshot and restore so the run starts from the - # user's current view. - with _preserve_view(self.viewer): - try: - moving_preview_layer = cast( - "Image", - self.viewer.layers[self._volumewise_moving_preview_layer_name()], - ) - except KeyError: - _, moving_preview_layer = plot_napari( - moving, - viewer=self.viewer, - name=self._volumewise_moving_preview_layer_name(), - show_colorbar=False, - contrast_limits=contrast_limits, - **moving_display_kwargs, - ) - else: - self._set_image_layer_data( - moving_preview_layer, np.asarray(moving.data) - ) - moving_preview_layer.colormap = moving_display_kwargs["colormap"] - moving_preview_layer.gamma = float( - moving_display_kwargs.get("gamma", 1.0) - ) - moving_preview_layer.contrast_limits = contrast_limits - - try: - fixed_preview_layer = cast( - "Image", self.viewer.layers[self._volume_fixed_preview_layer_name()] - ) - except KeyError: - fixed_preview_layer = None - else: - fixed_preview_layer.visible = False - - _, layer = plot_napari( - preview, - viewer=self.viewer, - name=layer_name, - show_colorbar=False, - contrast_limits=contrast_limits, - **display_kwargs, - ) - bridge = NapariRegistrationProgressReporterBridge() - bridge.frame_progress.connect(self._update_volumewise_progress_bar) - bridge.frame_completed.connect(self._update_volumewise_progress_frame) - - self._volumewise_progress_bridge = bridge - self._volumewise_progress_layer = cast("Image", layer) - self._volumewise_moving_preview_layer = cast("Image", moving_preview_layer) - self._volumewise_progress_time_axis = moving.dims.index(TIME_DIM) - self._volumewise_progress_total = moving.sizes[TIME_DIM] - self._progress.setRange(0, self._volumewise_progress_total) - self._progress.setValue(0) - return NapariRegistrationProgressReporter( - bridge, - n_frames=moving.sizes[TIME_DIM], - ) - - def _update_volumewise_progress_bar( - self, - completed_frames: int, - total_frames: int, - ) -> None: - """Update the determinate progress bar for volumewise registration.""" - self._progress.setRange(0, max(total_frames, 1)) - self._progress.setValue(min(completed_frames, total_frames)) - - def _update_volumewise_progress_frame( - self, - frame_index: int, - arr: object, - ) -> None: - """Write one completed registered frame into the volumewise output layer.""" - layer = self._volumewise_progress_layer - time_axis = self._volumewise_progress_time_axis - if layer is None or time_axis is None or not isinstance(arr, np.ndarray): - return - - data = np.asarray(layer.data) - if time_axis >= data.ndim: - return - index = tuple( - frame_index if axis == time_axis else slice(None) - for axis in range(data.ndim) - ) - data[index] = arr - layer.refresh() - - def _teardown_volumewise_progress(self, *, remove_layer: bool) -> None: - """Reset volumewise progress-layer state.""" - if remove_layer and self._volumewise_progress_layer is not None: - try: - self.viewer.layers.remove(self._volumewise_progress_layer) - except (KeyError, ValueError): - pass - self._volumewise_progress_layer = None - self._volumewise_progress_bridge = None - if not remove_layer: - self._volumewise_progress_layer = None - self._volumewise_progress_time_axis = None - self._volumewise_progress_total = None - - def _setup_volume_progress( - self, - *, - moving_layer: "Image", - fixed_layer: "Image", - moving: xr.DataArray, - fixed: xr.DataArray, - layer_name: str, - initial_transform: npt.NDArray[np.floating] | None = None, - scale_mode: str = "off", - ) -> "Callable[..., RegistrationProgress] | None": - """Build a napari progress bridge and preview layer for register_volume. - - Creates an empty image layer on the fixed grid (with the final target - name) and wires a - [`NapariProgressBridge`][confusius._napari._registration._progress.NapariProgressBridge] - so that every iteration of SimpleITK's optimizer streams the resampled - array into that layer. The returned factory is forwarded to - `register_volume` via its `progress_plotter` argument. - - Parameters - ---------- - moving_layer : napari.layers.Layer - Moving source layer. Used for display defaults (colormap, - contrast limits) on the preview layer, since the resampled output - lives in the moved intensity space. - fixed_layer : napari.layers.Layer - Fixed reference layer. Defines the shape, scale, translate, and - coordinate system of the preview/output layer. - moving : xarray.DataArray - Spatial-only moving data used to seed the preview layer. - fixed : xarray.DataArray - Spatial-only DataArray view of `fixed_layer`, used to build the - empty preview grid. - layer_name : str - Name for the preview (and later final) layer. - initial_transform : numpy.ndarray, optional - Explicit affine initialization used to seed the preview layers. If - not provided, the preview starts from the identity transform. - - Returns - ------- - callable or None - Factory suited for `register_volume`'s `progress_plotter` - argument, or `None` when the preview layer could not be created - (in which case `register_volume` runs without live progress). - """ - self._teardown_volume_progress() - - fixed_display_kwargs = _get_image_display_kwargs_from_layer(fixed_layer) - fixed_display_kwargs["colormap"] = "red" - - moving_display_kwargs = _get_image_display_kwargs_from_layer(moving_layer) - moving_display_kwargs["colormap"] = "cyan" - moving_display_kwargs["blending"] = "additive" - if _gamma_needs_reset(scale_mode): - fixed_display_kwargs["gamma"] = 1.0 - moving_display_kwargs["gamma"] = 1.0 - - display_kwargs = dict(moving_display_kwargs) - - # Render the preview in cyan with additive blending. napari sums the - # RGB channels of the two layers, so red+cyan highlights - # misregistered regions as a pure colour. `_get_image_display_kwargs_from_layer` - # copies the moving layer's colormap, so we override it explicitly - # rather than rely on `setdefault`. - display_kwargs["colormap"] = "cyan" - display_kwargs["blending"] = "additive" - - # Seed the preview with the moving image resampled onto the fixed - # grid using an identity transform. This makes the first frame a - # meaningful "unaligned moving on fixed grid" view that the user can - # compare against the red fixed, instead of a zero-valued blank that - # would flash a full-FOV tint. The SimpleITK iteration events then - # overwrite the data in place as the registration progresses. - try: - seed_transform = ( - np.asarray(initial_transform, dtype=float) - if initial_transform is not None - else np.eye(fixed.ndim + 1, dtype=float) - ) - preview = resample_like( - moving, - fixed, - seed_transform, - interpolation="linear", - ) - preview_contrast_limits = tuple(calc_data_range(preview.data)) - except Exception as exc: # noqa: BLE001 - # Fall back to a zero-valued seed if the initial resample fails - # for any reason. The first iteration will populate the preview. - self._set_error(f"Could not seed progress layer: {exc}") - preview = xr.DataArray( - np.zeros(fixed.shape, dtype=np.float32), - coords=fixed.coords, - dims=fixed.dims, - attrs=fixed.attrs.copy(), - ) - preview_contrast_limits = tuple(calc_data_range(preview.data)) - - # Adding the preview/progress layers makes napari recompute the camera - # and reset the dims; snapshot and restore so the run starts from the - # user's current view. - with _preserve_view(self.viewer): - try: - try: - fixed_preview_layer = cast( - "Image", - self.viewer.layers[self._volume_fixed_preview_layer_name()], - ) - except KeyError: - _, fixed_preview_layer = plot_napari( - fixed, - viewer=self.viewer, - name=self._volume_fixed_preview_layer_name(), - show_colorbar=False, - **fixed_display_kwargs, - ) - else: - self._set_image_layer_data( - fixed_preview_layer, np.asarray(fixed.data) - ) - fixed_preview_layer.colormap = fixed_display_kwargs["colormap"] - fixed_preview_layer.gamma = float( - fixed_display_kwargs.get("gamma", 1.0) - ) - fixed_preview_layer.visible = True - - try: - moving_preview_layer = cast( - "Image", - self.viewer.layers[self._volume_moving_preview_layer_name()], - ) - except KeyError: - _, moving_preview_layer = plot_napari( - preview, - viewer=self.viewer, - name=self._volume_moving_preview_layer_name(), - show_colorbar=False, - contrast_limits=preview_contrast_limits, - **moving_display_kwargs, - ) - else: - self._set_image_layer_data( - moving_preview_layer, np.asarray(preview.data) - ) - moving_preview_layer.colormap = moving_display_kwargs["colormap"] - moving_preview_layer.blending = moving_display_kwargs["blending"] - moving_preview_layer.gamma = float( - moving_display_kwargs.get("gamma", 1.0) - ) - moving_preview_layer.contrast_limits = preview_contrast_limits - moving_preview_layer.visible = False - - _, layer = plot_napari( - preview, - viewer=self.viewer, - name=layer_name, - show_colorbar=False, - contrast_limits=preview_contrast_limits, - **display_kwargs, - ) - except Exception as exc: # noqa: BLE001 - self._set_error(f"Could not create progress layer: {exc}") - return None - - bridge = NapariProgressBridge() - bridge.iterated.connect(self._update_progress_layer) - # `finished` is informational: we tear the preview down on - # `_on_registration_finished` / `_on_registration_failed` instead, so - # no extra slot is required here. - self._progress_bridge = bridge - self._progress_layer = cast("Image", layer) - self._progress_fixed_layer = cast("Image", fixed_preview_layer) - self._progress_moving_layer = cast("Image", moving_preview_layer) - self._progress_moving_layer.visible = False - - # Lazily build the bottom-dock metric plotter. The widget is reused - # across runs; only the data buffer is reset. - self._ensure_metric_plotter() - plotter = self._metric_plotter - if plotter is not None: - plotter.reset() - bridge.metric_updated.connect(plotter.add_metric) - return make_napari_progress_factory(bridge) - - def _update_progress_layer(self, arr: object) -> None: - """Write an intermediate resampled array into the preview layer. - - Invoked on the GUI thread via `NapariProgressBridge.iterated`. The - payload is a numpy array in numpy axis order matching the fixed grid - shape. Shape/coordinate mismatches are silently ignored: they - indicate that another run's stale signal slipped through or that the - preview layer has already been torn down. - - Parameters - ---------- - arr : numpy.ndarray - Resampled moving image for the current iteration. - """ - layer = self._progress_layer - if layer is None: - return - if not isinstance(arr, np.ndarray): - return - if arr.shape != layer.data.shape: - return - self._set_image_layer_data(layer, arr) - - def _teardown_volume_progress(self) -> None: - """Remove the progress preview layer and bridge references, if any. - - Called by `_on_registration_finished` and `_on_registration_failed` - so the newly added result layer replaces the preview without leaving - duplicates behind. The moving layer's hidden state is not restored. - The metric plotter is kept (docked, with its final trace) so the - user can inspect the convergence curve after the run. - """ - if self._progress_layer is not None: - try: - self.viewer.layers.remove(self._progress_layer) - except (KeyError, ValueError): - pass - self._progress_layer = None - # Drop the bridge reference; the plotter connection becomes inert - # when the bridge is garbage-collected. - self._progress_bridge = None - - def _ensure_metric_plotter(self) -> RegistrationMetricPlotter | None: - """Return the right-dock metric plotter, creating and docking it on first use. - - Mirrors the lazy-dock pattern used by `SignalPanel`. The plotter widget is - reused across runs; `_setup_volume_progress` resets its data buffer - before each run. Returns `None` only when the dock could not be created - (in which case the registration still runs, just without a live metric - view). - """ - if self._metric_plotter is None: - self._metric_plotter = RegistrationMetricPlotter(self.viewer) - - if self._metric_dock is None or self._metric_plotter.parent() is None: - dock = self.viewer.window.add_dock_widget( - self._metric_plotter, - name="Registration Metric", - area="right", - ) - self._metric_dock = cast("QDockWidget", dock) - - # Mirror the HiDPI click-offset fix from the SignalPanel so the - # canvas paints at the right device-pixel ratio the first time. - def _settle_layout() -> None: - try: - main_win = self._find_main_window(dock) - except RuntimeError: - return - if main_win is None: - return - from qtpy.QtCore import QSize - - central = main_win.centralWidget() - if central is None: - return - central.setMinimumSize(QSize(0, 0)) - for w in central.findChildren(QWidget): - w.setMinimumSize(QSize(0, 0)) - for side_dock in main_win.findChildren(QDockWidget): - if side_dock is dock: - continue - side_dock.setMinimumHeight(0) - widget = side_dock.widget() - if widget is not None: - widget.setMinimumSize(QSize(0, 0)) - current = main_win.size() - if current.height() < 800: - main_win.resize(current.width(), 800) - main_win.resizeDocks([dock], [220], Qt.Orientation.Vertical) - - QTimer.singleShot(200, _settle_layout) - - return self._metric_plotter - - def _find_main_window(self, widget: QWidget) -> QMainWindow | None: - """Traverse up the widget hierarchy to find the QMainWindow. - - Parameters - ---------- - widget : QWidget - Starting widget to search from. - - Returns - ------- - QMainWindow or None - The main window if found, otherwise None. - """ - try: - parent = widget.parent() - except RuntimeError: - return None - while parent is not None: - if isinstance(parent, QMainWindow): - return parent - try: - parent = parent.parent() - except RuntimeError: - return None - return None - def _end_work(self) -> None: """Restore the idle UI state after background work.""" self._worker = None @@ -2553,7 +2026,8 @@ def _run_registration(self) -> None: if initial_transform_source is not None: volume_payload["initial_transform_source"] = initial_transform_source - progress_plotter = self._setup_volume_progress( + progress_plotter = setup_volume_progress( + self, moving_layer=cast("Image", moving_layer), fixed_layer=cast("Image", fixed_layer), moving=moving, @@ -2637,7 +2111,8 @@ def _run_registration(self) -> None: } moving = _apply_registration_scale(moving, volumewise_payload["scale"]) - progress_reporter = self._setup_volumewise_progress( + progress_reporter = setup_volumewise_progress( + self, moving_layer=cast("Image", moving_layer), moving=moving, layer_name=self._make_unique_layer_name( @@ -2741,7 +2216,7 @@ def _finalize_registration_layer( self._set_image_layer_data(layer, np.asarray(registered.data)) if hasattr(layer, "contrast_limits"): layer.contrast_limits = contrast_limits - self._teardown_volumewise_progress(remove_layer=False) + teardown_volumewise_progress(self, remove_layer=False) else: _, layer = plot_napari( registered, @@ -2950,7 +2425,7 @@ def _on_registration_failed(self, exc: BaseException) -> None: exc : BaseException Exception raised by the worker. """ - self._teardown_volume_progress() - self._teardown_volumewise_progress(remove_layer=True) + teardown_volume_progress(self) + teardown_volumewise_progress(self, remove_layer=True) self._set_error(str(exc)) show_error(str(exc)) diff --git a/src/confusius/_napari/_registration/_panel_parameters.py b/src/confusius/_napari/_registration/_panel_parameters.py new file mode 100644 index 00000000..07009a47 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel_parameters.py @@ -0,0 +1,165 @@ +"""Registration-parameter helpers for the napari registration panel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from confusius._napari._registration._panel import ( + ModeParameters, + RegistrationPanel, + RegistrationParameterMode, + ) + + +def get_default_registration_parameters( + *, mode: "RegistrationParameterMode" +) -> "ModeParameters": + """Return the default parameter state for one registration mode. + + Parameters + ---------- + mode : {"volume", "volumewise"} + Registration workflow whose defaults should be returned. + + Returns + ------- + ModeParameters + Default parameter values for the requested workflow. + """ + is_volumewise = mode == "volumewise" + return { + "transform": "rigid", + "metric": "correlation", + "scale": "dB", + "initialization": "center_geometry", + "learning_rate_auto": not is_volumewise, + "learning_rate_value": 0.01 if is_volumewise else 0.1, + "number_of_iterations": 100, + "number_of_histogram_bins": 50, + "mesh_size": (10, 10, 10), + "convergence_minimum_value": 1e-6, + "convergence_window_size": 10, + "use_multi_resolution": False, + "shrink_factors": "6, 2, 1", + "smoothing_sigmas": "6, 2, 1", + "resample_interpolation": "linear", + "fill_value_auto": True, + "fill_value": 0.0, + "reference_time": 0, + "n_jobs": -1, + "keep_diagnostics": False, + "advanced_open": False, + } + + +def get_registration_parameters(panel: "RegistrationPanel") -> "ModeParameters": + """Return the current parameter state shown in the panel. + + Parameters + ---------- + panel : RegistrationPanel + Panel whose widgets should be read. + + Returns + ------- + ModeParameters + Current parameter values read from the visible widgets. + """ + return { + "transform": panel._transform_combo.currentText() or "rigid", + "metric": panel._current_metric(), + "scale": panel._current_scale_mode(), + "initialization": panel._initialization_combo.currentData(), + "learning_rate_auto": panel._learning_rate_auto_check.isChecked(), + "learning_rate_value": panel._learning_rate_edit.value(), + "number_of_iterations": panel._iterations_spin.value(), + "number_of_histogram_bins": panel._histogram_bins_spin.value(), + "mesh_size": ( + panel._mesh_size_z_spin.value(), + panel._mesh_size_y_spin.value(), + panel._mesh_size_x_spin.value(), + ), + "convergence_minimum_value": panel._convergence_min_edit.value(), + "convergence_window_size": panel._convergence_window_spin.value(), + "use_multi_resolution": panel._multi_resolution_check.isChecked(), + "shrink_factors": panel._shrink_factors_edit.text(), + "smoothing_sigmas": panel._smoothing_sigmas_edit.text(), + "resample_interpolation": panel._current_resample_interpolation(), + "fill_value_auto": panel._fill_value_auto_check.isChecked(), + "fill_value": panel._fill_value_spin.value(), + "reference_time": panel._reference_time_spin.value(), + "n_jobs": panel._n_jobs_spin.value(), + "keep_diagnostics": panel._keep_diagnostics_check.isChecked(), + "advanced_open": panel._advanced_toggle.isChecked(), + } + + +def set_registration_parameters( + panel: "RegistrationPanel", + params: "ModeParameters", + *, + mode: "RegistrationParameterMode", +) -> None: + """Restore the parameter state for one registration mode. + + Parameters + ---------- + panel : RegistrationPanel + Panel whose widgets should be updated. + params : ModeParameters + Parameter values to push back into the widgets. + mode : {"volume", "volumewise"} + Registration workflow whose UI should be restored. + """ + panel._transform_combo.blockSignals(True) + panel._transform_combo.clear() + is_volumewise = mode == "volumewise" + if is_volumewise: + panel._transform_combo.addItems(["translation", "rigid", "affine"]) + else: + panel._transform_combo.addItems(["translation", "rigid", "affine", "bspline"]) + transform = params["transform"] + transform_index = panel._transform_combo.findText(transform) + if transform_index < 0: + transform_index = panel._transform_combo.findText("rigid") + if transform_index >= 0: + panel._transform_combo.setCurrentIndex(transform_index) + panel._transform_combo.blockSignals(False) + + panel._metric_combo.setCurrentText(params["metric"]) + scale_mode = params["scale"] + scale_index = panel._scale_combo.findData(scale_mode) + if scale_index >= 0: + panel._scale_combo.setCurrentIndex(scale_index) + initialization_data = params.get("initialization") + for i in range(panel._initialization_combo.count()): + if panel._initialization_combo.itemData(i) == initialization_data: + panel._initialization_combo.setCurrentIndex(i) + break + panel._learning_rate_auto_check.setChecked( + False if is_volumewise else params["learning_rate_auto"] + ) + panel._learning_rate_edit.setValue(params["learning_rate_value"]) + panel._iterations_spin.setValue(params["number_of_iterations"]) + panel._histogram_bins_spin.setValue(params["number_of_histogram_bins"]) + mesh_size = params["mesh_size"] + panel._mesh_size_z_spin.setValue(mesh_size[0]) + panel._mesh_size_y_spin.setValue(mesh_size[1]) + panel._mesh_size_x_spin.setValue(mesh_size[2]) + panel._convergence_min_edit.setValue(params["convergence_minimum_value"]) + panel._convergence_window_spin.setValue(params["convergence_window_size"]) + panel._multi_resolution_check.setChecked(params["use_multi_resolution"]) + panel._shrink_factors_edit.setText(params["shrink_factors"]) + panel._smoothing_sigmas_edit.setText(params["smoothing_sigmas"]) + panel._interpolation_combo.setCurrentText(params["resample_interpolation"]) + panel._fill_value_auto_check.setChecked(params["fill_value_auto"]) + panel._fill_value_spin.setValue(params["fill_value"]) + panel._reference_time_spin.setValue(params["reference_time"]) + panel._n_jobs_spin.setValue(params["n_jobs"]) + panel._keep_diagnostics_check.setChecked(params["keep_diagnostics"]) + panel._advanced_toggle.setChecked(params["advanced_open"]) + panel._on_advanced_toggled(panel._advanced_toggle.isChecked()) + panel._update_metric_dependent_visibility(panel._metric_combo.currentText()) + panel._update_multi_resolution_enabled(panel._multi_resolution_check.isChecked()) + panel._update_transform_dependent_visibility(panel._transform_combo.currentText()) diff --git a/src/confusius/_napari/_registration/_panel_progress.py b/src/confusius/_napari/_registration/_panel_progress.py new file mode 100644 index 00000000..c62bd200 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel_progress.py @@ -0,0 +1,498 @@ +"""Progress-layer helpers for the napari registration panel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import numpy as np +import xarray as xr +from napari.layers.utils.layer_utils import calc_data_range +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import QDockWidget, QWidget + +from confusius._dims import TIME_DIM +from confusius._napari._qt import find_main_window +from confusius._napari._registration._panel_utils import ( + _gamma_needs_reset, + _get_image_display_kwargs_from_layer, + _preserve_view, +) +from confusius._napari._registration._progress import ( + NapariProgressBridge, + NapariRegistrationProgressReporter, + NapariRegistrationProgressReporterBridge, + make_napari_progress_factory, +) +from confusius.plotting.napari import plot_napari +from confusius.registration import resample_like + +if TYPE_CHECKING: + import numpy.typing as npt + from napari.layers import Image + + from confusius._napari._registration._metric_plotter import ( + RegistrationMetricPlotter, + ) + from confusius._napari._registration._panel import RegistrationPanel + + +def setup_volumewise_progress( + panel: "RegistrationPanel", + *, + moving_layer: "Image", + moving: xr.DataArray, + layer_name: str, + scale_mode: str, +) -> NapariRegistrationProgressReporter: + """Create volumewise preview layers and a progress reporter. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose viewer and progress widgets are updated. + moving_layer : napari.layers.Image + Source moving layer shown in the viewer. + moving : xarray.DataArray + Moving data used to seed the preview layer. + layer_name : str + Name for the live output layer. + scale_mode : str + Registration scaling mode used to decide preview gamma handling. + + Returns + ------- + NapariRegistrationProgressReporter + Worker-side reporter that forwards completed-frame updates back to the + panel. + """ + teardown_volumewise_progress(panel, remove_layer=True) + + moving_display_kwargs = _get_image_display_kwargs_from_layer(moving_layer) + moving_display_kwargs["colormap"] = "red" + if _gamma_needs_reset(scale_mode): + moving_display_kwargs["gamma"] = 1.0 + + display_kwargs = dict(moving_display_kwargs) + display_kwargs["colormap"] = "cyan" + display_kwargs["blending"] = "additive" + contrast_limits = tuple(calc_data_range(moving.data)) + preview_data = np.full( + moving.shape, + fill_value=float(np.min(moving.data)), + dtype=np.asarray(moving.data).dtype, + ) + preview = xr.DataArray( + preview_data, + dims=moving.dims, + coords=moving.coords, + attrs=moving.attrs.copy(), + ) + + with _preserve_view(panel.viewer): + try: + moving_preview_layer = panel._get_layer_by_name( + panel._volumewise_moving_preview_layer_name() + ) + if moving_preview_layer is None: + _, moving_preview_layer = plot_napari( + moving, + viewer=panel.viewer, + name=panel._volumewise_moving_preview_layer_name(), + show_colorbar=False, + contrast_limits=contrast_limits, + **moving_display_kwargs, + ) + else: + moving_preview_layer = cast("Image", moving_preview_layer) + panel._set_image_layer_data( + moving_preview_layer, np.asarray(moving.data) + ) + moving_preview_layer.colormap = moving_display_kwargs["colormap"] + moving_preview_layer.gamma = float( + moving_display_kwargs.get("gamma", 1.0) + ) + moving_preview_layer.contrast_limits = contrast_limits + + fixed_preview_layer = panel._get_layer_by_name( + panel._volume_fixed_preview_layer_name() + ) + if fixed_preview_layer is not None: + fixed_preview_layer.visible = False + + _, layer = plot_napari( + preview, + viewer=panel.viewer, + name=layer_name, + show_colorbar=False, + contrast_limits=contrast_limits, + **display_kwargs, + ) + except Exception as exc: # noqa: BLE001 + panel._set_error(f"Could not create progress layer: {exc}") + raise + bridge = NapariRegistrationProgressReporterBridge() + bridge.frame_progress.connect( + lambda completed_frames, total_frames: update_volumewise_progress_bar( + panel, completed_frames, total_frames + ) + ) + bridge.frame_completed.connect( + lambda frame_index, frame_data: update_volumewise_progress_frame( + panel, frame_index, frame_data + ) + ) + + panel._volumewise_progress_bridge = bridge + panel._volumewise_progress_layer = cast("Image", layer) + panel._volumewise_moving_preview_layer = cast("Image", moving_preview_layer) + panel._volumewise_progress_time_axis = moving.dims.index(TIME_DIM) + panel._volumewise_progress_total = moving.sizes[TIME_DIM] + panel._progress.setRange(0, panel._volumewise_progress_total) + panel._progress.setValue(0) + return NapariRegistrationProgressReporter( + bridge, + n_frames=panel._volumewise_progress_total, + ) + + +def update_volumewise_progress_bar( + panel: "RegistrationPanel", completed_frames: int, total_frames: int +) -> None: + """Update the volumewise progress bar from completed-frame counts. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose progress bar is updated. + completed_frames : int + Number of frames completed so far. + total_frames : int + Total number of frames expected for the run. + + Returns + ------- + None + Updates the panel progress bar in place. + """ + panel._volumewise_progress_total = total_frames + panel._progress.setRange(0, total_frames) + panel._progress.setValue(min(completed_frames, total_frames)) + + +def update_volumewise_progress_frame( + panel: "RegistrationPanel", frame_index: int, frame_data: object +) -> None: + """Write a completed frame into the volumewise preview layer. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose volumewise preview layer is updated. + frame_index : int + Time index of the completed frame. + frame_data : object + Registered frame data emitted by the worker. + + Returns + ------- + None + Writes the completed frame into the preview layer when valid. + """ + layer = panel._volumewise_progress_layer + time_axis = panel._volumewise_progress_time_axis + if layer is None or time_axis is None: + return + if not isinstance(frame_data, np.ndarray): + return + if frame_index < 0 or frame_index >= layer.data.shape[time_axis]: + return + slicer: list[int | slice] = [slice(None) for _ in range(layer.data.ndim)] + slicer[time_axis] = frame_index + np.asarray(layer.data)[tuple(slicer)] = frame_data + layer.refresh() + + +def teardown_volumewise_progress( + panel: "RegistrationPanel", *, remove_layer: bool +) -> None: + """Drop volumewise progress-layer references and optionally remove the layer. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose volumewise progress state is cleared. + remove_layer : bool + Whether to also remove the live output layer from the viewer. + + Returns + ------- + None + Clears volumewise progress state stored on the panel. + """ + if remove_layer and panel._volumewise_progress_layer is not None: + try: + panel.viewer.layers.remove(panel._volumewise_progress_layer) + except (KeyError, ValueError): + pass + panel._volumewise_progress_bridge = None + panel._volumewise_progress_layer = None + panel._volumewise_progress_time_axis = None + panel._volumewise_progress_total = None + + +def setup_volume_progress( + panel: "RegistrationPanel", + *, + moving_layer: "Image", + fixed_layer: "Image", + moving: xr.DataArray, + fixed: xr.DataArray, + layer_name: str, + initial_transform: "npt.NDArray[np.floating] | None" = None, + scale_mode: str, +): + """Create between-scan preview layers and a progress-plotter factory. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose viewer and progress widgets are updated. + moving_layer : napari.layers.Image + Source moving layer shown in the viewer. + fixed_layer : napari.layers.Image + Source fixed layer shown in the viewer. + moving : xarray.DataArray + Moving data used to seed the live preview. + fixed : xarray.DataArray + Fixed data defining the output grid. + layer_name : str + Name for the live registered preview layer. + initial_transform : numpy.ndarray, optional + Initial affine used to seed the preview before optimization starts. + scale_mode : str + Registration scaling mode used to decide preview gamma handling. + + Returns + ------- + callable or None + Progress-factory callback for `register_volume`, or `None` if the + preview layer could not be created. + """ + teardown_volume_progress(panel) + + fixed_display_kwargs = _get_image_display_kwargs_from_layer(fixed_layer) + fixed_display_kwargs["colormap"] = "red" + + moving_display_kwargs = _get_image_display_kwargs_from_layer(moving_layer) + moving_display_kwargs["colormap"] = "cyan" + moving_display_kwargs["blending"] = "additive" + if _gamma_needs_reset(scale_mode): + fixed_display_kwargs["gamma"] = 1.0 + moving_display_kwargs["gamma"] = 1.0 + + display_kwargs = dict(moving_display_kwargs) + display_kwargs["colormap"] = "cyan" + display_kwargs["blending"] = "additive" + + try: + seed_transform = ( + np.asarray(initial_transform, dtype=float) + if initial_transform is not None + else np.eye(fixed.ndim + 1, dtype=float) + ) + preview = resample_like( + moving, + fixed, + seed_transform, + interpolation="linear", + ) + preview_contrast_limits = tuple(calc_data_range(preview.data)) + except Exception as exc: # noqa: BLE001 + panel._set_error(f"Could not seed progress layer: {exc}") + preview = xr.DataArray( + np.zeros(fixed.shape, dtype=np.float32), + coords=fixed.coords, + dims=fixed.dims, + attrs=fixed.attrs.copy(), + ) + preview_contrast_limits = tuple(calc_data_range(preview.data)) + + with _preserve_view(panel.viewer): + try: + fixed_preview_layer = panel._get_layer_by_name( + panel._volume_fixed_preview_layer_name() + ) + if fixed_preview_layer is None: + _, fixed_preview_layer = plot_napari( + fixed, + viewer=panel.viewer, + name=panel._volume_fixed_preview_layer_name(), + show_colorbar=False, + **fixed_display_kwargs, + ) + else: + fixed_preview_layer = cast("Image", fixed_preview_layer) + panel._set_image_layer_data(fixed_preview_layer, np.asarray(fixed.data)) + fixed_preview_layer.colormap = fixed_display_kwargs["colormap"] + fixed_preview_layer.gamma = float( + fixed_display_kwargs.get("gamma", 1.0) + ) + fixed_preview_layer.visible = True + + moving_preview_layer = panel._get_layer_by_name( + panel._volume_moving_preview_layer_name() + ) + if moving_preview_layer is None: + _, moving_preview_layer = plot_napari( + preview, + viewer=panel.viewer, + name=panel._volume_moving_preview_layer_name(), + show_colorbar=False, + contrast_limits=preview_contrast_limits, + **moving_display_kwargs, + ) + else: + moving_preview_layer = cast("Image", moving_preview_layer) + panel._set_image_layer_data( + moving_preview_layer, np.asarray(preview.data) + ) + moving_preview_layer.colormap = moving_display_kwargs["colormap"] + moving_preview_layer.blending = moving_display_kwargs["blending"] + moving_preview_layer.gamma = float( + moving_display_kwargs.get("gamma", 1.0) + ) + moving_preview_layer.contrast_limits = preview_contrast_limits + moving_preview_layer.visible = False + + _, layer = plot_napari( + preview, + viewer=panel.viewer, + name=layer_name, + show_colorbar=False, + contrast_limits=preview_contrast_limits, + **display_kwargs, + ) + except Exception as exc: # noqa: BLE001 + panel._set_error(f"Could not create progress layer: {exc}") + return None + + bridge = NapariProgressBridge() + bridge.iterated.connect(lambda arr: update_progress_layer(panel, arr)) + panel._progress_bridge = bridge + panel._progress_layer = cast("Image", layer) + panel._progress_fixed_layer = cast("Image", fixed_preview_layer) + panel._progress_moving_layer = cast("Image", moving_preview_layer) + panel._progress_moving_layer.visible = False + + ensure_metric_plotter(panel) + plotter = panel._metric_plotter + if plotter is not None: + plotter.reset() + bridge.metric_updated.connect(plotter.add_metric) + return make_napari_progress_factory(bridge) + + +def update_progress_layer(panel: "RegistrationPanel", arr: object) -> None: + """Write an intermediate resampled array into the volume preview layer. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose between-scan preview layer is updated. + arr : object + Intermediate resampled array emitted by the worker. + + Returns + ------- + None + Replaces the preview-layer data when the emitted array is valid. + """ + layer = panel._progress_layer + if layer is None: + return + if not isinstance(arr, np.ndarray): + return + if arr.shape != layer.data.shape: + return + panel._set_image_layer_data(layer, arr) + + +def teardown_volume_progress(panel: "RegistrationPanel") -> None: + """Remove the volume progress preview layer and bridge references. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose between-scan progress state is cleared. + + Returns + ------- + None + Clears between-scan progress state stored on the panel. + """ + if panel._progress_layer is not None: + try: + panel.viewer.layers.remove(panel._progress_layer) + except (KeyError, ValueError): + pass + panel._progress_layer = None + panel._progress_bridge = None + + +def ensure_metric_plotter( + panel: "RegistrationPanel", +) -> "RegistrationMetricPlotter | None": + """Return the right-dock metric plotter, creating it on first use. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose metric plotter should be available. + + Returns + ------- + RegistrationMetricPlotter or None + The docked metric plotter widget. + """ + if panel._metric_plotter is None: + from confusius._napari._registration._metric_plotter import ( + RegistrationMetricPlotter, + ) + + panel._metric_plotter = RegistrationMetricPlotter(panel.viewer) + + if panel._metric_dock is None or panel._metric_plotter.parent() is None: + dock = panel.viewer.window.add_dock_widget( + panel._metric_plotter, + name="Registration Metric", + area="right", + ) + panel._metric_dock = dock + + def _settle_layout() -> None: + main_win = find_main_window(dock) + if main_win is None: + return + from qtpy.QtCore import QSize + + central = main_win.centralWidget() + if central is None: + return + central.setMinimumSize(QSize(0, 0)) + for widget in central.findChildren(QWidget): + widget.setMinimumSize(QSize(0, 0)) + for side_dock in main_win.findChildren(QDockWidget): + if side_dock is dock: + continue + side_dock.setMinimumHeight(0) + widget = side_dock.widget() + if widget is not None: + widget.setMinimumSize(QSize(0, 0)) + current = main_win.size() + if current.height() < 800: + main_win.resize(current.width(), 800) + main_win.resizeDocks([dock], [220], Qt.Orientation.Vertical) + + QTimer.singleShot(200, _settle_layout) + + return panel._metric_plotter diff --git a/src/confusius/_napari/_signals/_panel.py b/src/confusius/_napari/_signals/_panel.py index 03a46cdb..2f5fed63 100644 --- a/src/confusius/_napari/_signals/_panel.py +++ b/src/confusius/_napari/_signals/_panel.py @@ -15,7 +15,6 @@ QGroupBox, QHBoxLayout, QLabel, - QMainWindow, QPushButton, QRadioButton, QVBoxLayout, @@ -23,6 +22,7 @@ ) from confusius._dims import SPATIAL_DIMS_WITH_POSE, TIME_DIM +from confusius._napari._qt import find_main_window from confusius._napari._signals._manager import SignalsManagerDialog from confusius._napari._signals._plotter import SignalPlotter from confusius._napari._signals._store import SignalStore @@ -286,7 +286,7 @@ def _ensure_plotter(self) -> SignalPlotter: # before the canvas first paints. This mirrors the pattern used in the QC # panel and prevents the HiDPI click-offset bug. def _settle_layout() -> None: - main_win = self._find_main_window(dock) + main_win = find_main_window(dock) if main_win is None: return # Zero minimum sizes on the central widget and all its children so the @@ -450,26 +450,6 @@ def _on_theme_changed(self) -> None: if self._signals_manager is not None: self._signals_manager.apply_theme(self._viewer.theme) - def _find_main_window(self, widget: QWidget) -> QMainWindow | None: - """Traverse up the widget hierarchy to find the QMainWindow. - - Parameters - ---------- - widget : QWidget - Starting widget to search from. - - Returns - ------- - QMainWindow | None - The main window if found, None otherwise. - """ - parent = widget.parent() - while parent is not None: - if isinstance(parent, QMainWindow): - return parent - parent = parent.parent() - return None - # ------------------------------------------------------------------ # Source management # ------------------------------------------------------------------ diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 97f7983a..2063fd1d 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -2,14 +2,20 @@ from __future__ import annotations -from dataclasses import dataclass, field from threading import Event +from typing import Any, cast import numpy as np import pytest import xarray as xr from qtpy.QtWidgets import QApplication +from confusius._napari._registration._panel_progress import ( + setup_volume_progress, + setup_volumewise_progress, + teardown_volume_progress, + update_progress_layer, +) from confusius._napari._registration._panel_transform_helpers import ( get_affine_transform_from_payload, get_bspline_transform_from_payload, @@ -19,7 +25,7 @@ make_bspline_transform_payload, save_transform_payload, ) -from confusius.registration import resample_like +from confusius.registration import RegistrationDiagnostics, resample_like @pytest.fixture @@ -34,14 +40,23 @@ def registration_panel(viewer): return RegistrationPanel(viewer) -@dataclass(frozen=True) -class _FakeDiagnostics: - metric: str = "correlation" - metric_values: np.ndarray = field(default_factory=lambda: np.array([-1.0])) - final_metric_value: float = -1.0 - n_iterations: int = 1 - stop_condition: str = "done" - status: str = "completed" +def _FakeDiagnostics( + *, + metric: str = "correlation", + metric_values: np.ndarray | None = None, + final_metric_value: float = -1.0, + n_iterations: int = 1, + stop_condition: str = "done", + status: str = "completed", +) -> RegistrationDiagnostics: + return RegistrationDiagnostics( + metric=cast("Any", metric), + metric_values=np.array([-1.0]) if metric_values is None else metric_values, + final_metric_value=final_metric_value, + n_iterations=n_iterations, + stop_condition=stop_condition, + status=cast("Any", status), + ) def _make_bspline_transform() -> xr.DataArray: @@ -206,7 +221,8 @@ def test_scale_preprocessing_resets_gamma_for_previews( moving.gamma = 0.4 fixed_layer.gamma = 0.6 - registration_panel._setup_volume_progress( + setup_volume_progress( + registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, @@ -218,8 +234,9 @@ def test_scale_preprocessing_resets_gamma_for_previews( assert viewer.layers["Moving"].gamma == pytest.approx(1.0) assert viewer.layers["Registered (rigid)"].gamma == pytest.approx(1.0) - registration_panel._teardown_volume_progress() - registration_panel._setup_volume_progress( + teardown_volume_progress(registration_panel) + setup_volume_progress( + registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, @@ -257,12 +274,14 @@ def test_setup_volume_progress_preserves_camera_view( viewer.camera.zoom = 7.0 before = (tuple(viewer.camera.center), viewer.camera.zoom, viewer.dims.ndisplay) - registration_panel._setup_volume_progress( + setup_volume_progress( + registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, layer_name="Registered (rigid)", + scale_mode="off", ) after = (tuple(viewer.camera.center), viewer.camera.zoom, viewer.dims.ndisplay) @@ -398,17 +417,18 @@ def _runner(*args, **kwargs): _fake_thread_worker, ) monkeypatch.setattr( - registration_panel, - "_setup_volume_progress", - lambda **_kwargs: None, + "confusius._napari._registration._panel.setup_volume_progress", + lambda *_args, **_kwargs: None, ) registration_panel._run_registration() - np.testing.assert_array_equal(captured["kwargs"]["initial_transform"], affine) - assert captured["kwargs"]["center_initialization"] is None - np.testing.assert_allclose(captured["args"][0].values, np.sqrt(moving.values)) - np.testing.assert_allclose(captured["args"][1].values, np.sqrt(fixed.values)) + kwargs = cast("dict[str, Any]", captured["kwargs"]) + args = cast("tuple[Any, ...]", captured["args"]) + np.testing.assert_array_equal(kwargs["initial_transform"], affine) + assert kwargs["center_initialization"] is None + np.testing.assert_allclose(args[0].values, np.sqrt(moving.values)) + np.testing.assert_allclose(args[1].values, np.sqrt(fixed.values)) assert registration_panel._worker is not None def test_between_scan_run_uses_selected_manual_napari_transform( @@ -486,13 +506,14 @@ def _runner(*args, **kwargs): _fake_thread_worker, ) monkeypatch.setattr( - registration_panel, - "_setup_volume_progress", - lambda **_kwargs: None, + "confusius._napari._registration._panel.setup_volume_progress", + lambda *_args, **_kwargs: None, ) registration_panel._run_registration() + kwargs = cast("dict[str, Any]", captured["kwargs"]) + args = cast("tuple[Any, ...]", captured["args"]) expected = np.array( [ [1.0, 0.0, 0.0, -0.5], @@ -501,9 +522,9 @@ def _runner(*args, **kwargs): [0.0, 0.0, 0.0, 1.0], ] ) - np.testing.assert_allclose(captured["kwargs"]["initial_transform"], expected) - assert captured["kwargs"]["center_initialization"] is None - assert captured["args"][0].dims == ("z", "y", "x") + np.testing.assert_allclose(kwargs["initial_transform"], expected) + assert kwargs["center_initialization"] is None + assert args[0].dims == ("z", "y", "x") assert registration_panel._worker is not None @@ -882,9 +903,11 @@ def _runner(*args, **kwargs): registration_panel._apply_transform() - assert captured["func"].__name__ == "resample_volume" + func = cast("Any", captured["func"]) + args = cast("tuple[Any, ...]", captured["args"]) + assert func.__name__ == "resample_volume" xr.testing.assert_identical( - captured["args"][1], + args[1], _make_bspline_transform().astype(float), ) assert registration_panel._worker is not None @@ -921,7 +944,8 @@ def test_setup_updates_progress_bar_and_output_layer( ) moving = _get_source_dataarray(moving_layer) - progress = registration_panel._setup_volumewise_progress( + progress = setup_volumewise_progress( + registration_panel, moving_layer=moving_layer, moving=moving, layer_name="Motion corrected", @@ -978,7 +1002,8 @@ def test_frame_completion_updates_frame_progress(self, viewer, registration_pane name="series", metadata={"xarray": moving}, ) - progress = registration_panel._setup_volumewise_progress( + progress = setup_volumewise_progress( + registration_panel, moving_layer=moving_layer, moving=moving, layer_name="Motion corrected", @@ -1112,12 +1137,14 @@ def test_volume_result_replaces_preview_layer( ) fixed_layer = viewer.add_image(np.ones((4, 6), dtype=np.float32), name="fixed") - factory = registration_panel._setup_volume_progress( + factory = setup_volume_progress( + registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, layer_name="Registered (rigid)", + scale_mode="off", ) assert factory is not None assert {"Fixed", "Moving", "Registered (rigid)"}.issubset( @@ -1217,13 +1244,15 @@ def test_setup_volume_progress_applies_initial_transform_to_preview_layers( dtype=float, ) - registration_panel._setup_volume_progress( + setup_volume_progress( + registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, layer_name="Registered (rigid)", initial_transform=initial_transform, + scale_mode="off", ) expected = resample_like(moving_data, fixed, initial_transform) @@ -1260,12 +1289,14 @@ def test_progress_layer_data_updates_on_iteration( ) fixed_layer = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="fixed") - registration_panel._setup_volume_progress( + setup_volume_progress( + registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, layer_name="Registered (rigid)", + scale_mode="off", ) # The preview is seeded with the moving image resampled onto the # fixed grid, so it's visible and meaningful from the start. @@ -1273,21 +1304,21 @@ def test_progress_layer_data_updates_on_iteration( assert preview_layer.visible next_arr = np.full((4, 6), 0.5, dtype=np.float32) - registration_panel._update_progress_layer(next_arr) + update_progress_layer(registration_panel, next_arr) np.testing.assert_array_equal( np.asarray(viewer.layers["Registered (rigid)"].data), next_arr ) # Shape mismatch is silently ignored. - registration_panel._update_progress_layer(np.zeros((3, 6), dtype=np.float32)) + update_progress_layer(registration_panel, np.zeros((3, 6), dtype=np.float32)) np.testing.assert_array_equal( np.asarray(viewer.layers["Registered (rigid)"].data), next_arr ) # Teardown removes only the in-flight registered layer while leaving # the reusable fixed / moving previews and originals untouched. - registration_panel._teardown_volume_progress() + teardown_volume_progress(registration_panel) assert registration_panel._progress_layer is None assert registration_panel._progress_bridge is None assert "Registered (rigid)" not in {layer.name for layer in viewer.layers} @@ -1321,12 +1352,14 @@ def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): assert registration_panel._metric_plotter is None assert registration_panel._metric_dock is None - registration_panel._setup_volume_progress( + setup_volume_progress( + registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, fixed=fixed, layer_name="Registered (rigid)", + scale_mode="off", ) assert registration_panel._metric_plotter is not None @@ -1340,13 +1373,13 @@ def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): assert bridge is not None bridge.metric_updated.emit(0.5) # Force a render so the throttled redraw is observed synchronously. - registration_panel._metric_plotter._render() # type: ignore[attr-defined] - assert registration_panel._metric_plotter.metric_values == [0.5] # type: ignore[attr-defined] + registration_panel._metric_plotter._render() + assert registration_panel._metric_plotter.metric_values == [0.5] # Tearing down keeps the plotter (so the user can inspect the trace). - registration_panel._teardown_volume_progress() + teardown_volume_progress(registration_panel) assert registration_panel._metric_plotter is not None - assert registration_panel._metric_plotter.metric_values == [0.5] # type: ignore[attr-defined] + assert registration_panel._metric_plotter.metric_values == [0.5] def test_volumewise_result_adds_registered_layer(self, viewer, registration_panel): registered = xr.DataArray( @@ -1398,7 +1431,8 @@ def test_volumewise_finished_keeps_preview_layers(self, viewer, registration_pan name="series", metadata={"xarray": moving}, ) - registration_panel._setup_volumewise_progress( + setup_volumewise_progress( + registration_panel, moving_layer=moving_layer, moving=moving, layer_name="Motion corrected", From de219a73d2a7a7ce1e77cf723baf7f9ace102eaf Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 20:27:19 +0100 Subject: [PATCH 55/72] feat(napari): refine registration tour highlights --- src/confusius/_napari/_tour.py | 292 ++++++++++++++++++++++++++++++++- 1 file changed, 290 insertions(+), 2 deletions(-) diff --git a/src/confusius/_napari/_tour.py b/src/confusius/_napari/_tour.py index 5cba5172..e40ed22a 100644 --- a/src/confusius/_napari/_tour.py +++ b/src/confusius/_napari/_tour.py @@ -12,7 +12,9 @@ from qtpy.QtCore import QEvent, QObject, QPoint, QRect, Qt, QTimer, Signal from qtpy.QtGui import QColor, QFont, QPainter, QPen from qtpy.QtWidgets import ( + QAbstractButton, QDockWidget, + QGroupBox, QHBoxLayout, QLabel, QPushButton, @@ -545,6 +547,7 @@ def build_default_tour( from confusius._napari._data._load_panel import DataPanel from confusius._napari._data._save_panel import SavePanel from confusius._napari._qc._panel import QCPanel + from confusius._napari._registration._panel import RegistrationPanel from confusius._napari._signals._panel import SignalPanel from confusius._napari._video._video_panel import VideoPanel @@ -642,6 +645,36 @@ def _find() -> QRect | None: return _find + def _panel_label( + label: str, + widget_type: type[QWidget], + text: str, + ) -> Callable[[], QWidget | None]: + def _find() -> QWidget | None: + panel = _panel_descendant(label, widget_type)() + if panel is None: + return None + for child in panel.findChildren(QLabel): + if child.text() == text: + return child + return None + + return _find + + def _panel_attr_ancestor_rect( + label: str, + widget_type: type[QWidget], + attr: str, + ancestor_type: type[QWidget], + ) -> Callable[[], QRect | None]: + def _find() -> QRect | None: + widget = _panel_attr(label, widget_type, attr)() + while widget is not None and not isinstance(widget, ancestor_type): + widget = widget.parentWidget() + return _widget_rect(widget) + + return _find + def _expand_section(label: str) -> Callable[[], None]: def _action() -> None: for btn, _icon in getattr(plugin_widget, "_accordion_btns", []): @@ -651,6 +684,37 @@ def _action() -> None: return _action + def _run_actions(*actions: Callable[[], None]) -> Callable[[], None]: + def _action() -> None: + for step_action in actions: + step_action() + + return _action + + def _registration_panel() -> RegistrationPanel | None: + panel = _panel_descendant("Registration", RegistrationPanel)() + return panel if isinstance(panel, RegistrationPanel) else None + + def _set_panel_button( + label: str, + attr: str, + *, + checked: bool = True, + ) -> Callable[[], None]: + def _action() -> None: + _expand_section(label)() + panel = _registration_panel() + if panel is None: + return + button = getattr(panel, attr, None) + if isinstance(button, QAbstractButton) and button.isChecked() != checked: + button.click() + + return _action + + def _select_sub_panel(label: str, attr: str) -> Callable[[], None]: + return _set_panel_button(label, attr) + # Record the open panel before the tour starts so we can restore it when # the tour is closed or skipped. initial_open: str | None = next( @@ -662,7 +726,36 @@ def _action() -> None: None, ) + registration_panel = _registration_panel() + initial_registration_state = None + if registration_panel is not None: + initial_registration_state = { + "register_panel": registration_panel._register_panel_radio.isChecked(), + "single_volume": registration_panel._single_volume_radio.isChecked(), + "advanced_open": registration_panel._advanced_toggle.isChecked(), + } + def _restore_state() -> None: + if initial_registration_state is not None: + _expand_section("Registration")() + panel = _registration_panel() + if panel is not None: + if ( + panel._register_panel_radio.isChecked() + != initial_registration_state["register_panel"] + ): + panel._transforms_panel_radio.click() + if ( + panel._single_volume_radio.isChecked() + != initial_registration_state["single_volume"] + ): + panel._time_series_radio.click() + if ( + panel._advanced_toggle.isChecked() + != initial_registration_state["advanced_open"] + ): + panel._advanced_toggle.click() + if initial_open is None: return for btn, _ in getattr(plugin_widget, "_accordion_btns", []): @@ -858,6 +951,200 @@ def _restore_state() -> None: tooltip_target=_dock_widget, pre_action=_expand_section("Signals"), ), + TourStep( + target=_accordion_panel("Registration"), + title="Registration", + body=( + "Use this section to align fUSI scans. The Register sub-panel " + "runs new registrations; the Transforms sub-panel saves, " + "loads, and reapplies them." + ), + anchor="left", + spotlight_rect=_accordion_tab_rect("Registration"), + tooltip_target=_dock_widget, + pre_action=_expand_section("Registration"), + ), + TourStep( + target=_panel_attr( + "Registration", RegistrationPanel, "_register_panel_radio" + ), + title="Register and Transforms", + body=( + "Use Register to compute a new alignment. Switch to " + "Transforms to reuse, save, load, or apply transforms." + ), + anchor="left", + spotlight_rect=_panel_attr_rect( + "Registration", + RegistrationPanel, + "_register_panel_radio", + "_transforms_panel_radio", + ), + tooltip_target=_dock_widget, + pre_action=_select_sub_panel("Registration", "_register_panel_radio"), + ), + TourStep( + target=_panel_attr( + "Registration", RegistrationPanel, "_single_volume_radio" + ), + title="Between scans and Within-scan", + body=( + "Pick Between scans to align one layer to another, or " + "Within-scan to motion-correct a time series against one " + "reference volume." + ), + anchor="left", + spotlight_rect=lambda: _united_rect( + _panel_label("Registration", RegistrationPanel, "Mode")(), + _panel_attr( + "Registration", RegistrationPanel, "_single_volume_radio" + )(), + _panel_attr("Registration", RegistrationPanel, "_time_series_radio")(), + ), + tooltip_target=_dock_widget, + pre_action=_run_actions( + _select_sub_panel("Registration", "_register_panel_radio"), + _set_panel_button("Registration", "_single_volume_radio"), + _set_panel_button("Registration", "_advanced_toggle", checked=False), + ), + ), + TourStep( + target=_panel_attr("Registration", RegistrationPanel, "_moving_combo"), + title="Moving and Fixed Layers", + body=( + "For Between scans, choose the Moving layer to align " + "and the Fixed layer that defines the target space." + ), + anchor="left", + spotlight_rect=_panel_attr_rect( + "Registration", + RegistrationPanel, + "_moving_label", + "_moving_combo", + "_fixed_label", + "_fixed_combo", + ), + tooltip_target=_dock_widget, + pre_action=_run_actions( + _select_sub_panel("Registration", "_register_panel_radio"), + _set_panel_button("Registration", "_single_volume_radio"), + _set_panel_button("Registration", "_advanced_toggle", checked=False), + ), + ), + TourStep( + target=_panel_attr("Registration", RegistrationPanel, "_transform_combo"), + title="Parameters", + body=( + "This box contains the registration settings: transform, metric, " + "scale, initialization, and the main optimizer controls." + ), + anchor="left", + spotlight_rect=_panel_attr_ancestor_rect( + "Registration", + RegistrationPanel, + "_transform_combo", + QGroupBox, + ), + tooltip_target=_dock_widget, + pre_action=_run_actions( + _select_sub_panel("Registration", "_register_panel_radio"), + _set_panel_button("Registration", "_single_volume_radio"), + _set_panel_button("Registration", "_advanced_toggle", checked=False), + ), + ), + TourStep( + target=_panel_attr("Registration", RegistrationPanel, "_advanced_toggle"), + title="Advanced Parameters", + body=( + "If you need finer control, open Advanced for extra optimizer, " + "multi-resolution, metric, and resampling settings." + ), + anchor="left", + spotlight_rect=_panel_attr_rect( + "Registration", + RegistrationPanel, + "_advanced_toggle", + ), + tooltip_target=_dock_widget, + pre_action=_run_actions( + _select_sub_panel("Registration", "_register_panel_radio"), + _set_panel_button("Registration", "_single_volume_radio"), + _set_panel_button("Registration", "_advanced_toggle", checked=False), + ), + ), + TourStep( + target=_panel_attr("Registration", RegistrationPanel, "_run_btn"), + title="Run Registration", + body=( + "Click Run registration to start. ConfUSIus shows progress " + "while the alignment runs and adds the result as a new layer." + ), + anchor="left", + tooltip_target=_dock_widget, + pre_action=_run_actions( + _select_sub_panel("Registration", "_register_panel_radio"), + _set_panel_button("Registration", "_single_volume_radio"), + _set_panel_button("Registration", "_advanced_toggle", checked=False), + ), + ), + TourStep( + target=_panel_attr( + "Registration", RegistrationPanel, "_reference_time_spin" + ), + title="Within-scan Inputs", + body=( + "For Within-scan, choose one layer with a time dimension, then " + "pick the Reference volume that every frame should align to." + ), + anchor="left", + spotlight_rect=_panel_attr_rect( + "Registration", + RegistrationPanel, + "_time_series_radio", + "_moving_label", + "_moving_combo", + "_reference_time_label", + "_reference_time_spin", + ), + tooltip_target=_dock_widget, + pre_action=_run_actions( + _select_sub_panel("Registration", "_register_panel_radio"), + _set_panel_button("Registration", "_time_series_radio"), + _set_panel_button("Registration", "_advanced_toggle", checked=False), + ), + ), + TourStep( + target=_panel_attr("Registration", RegistrationPanel, "_transform_combo"), + title="Within-scan Parameters", + body=( + "The parameter box is similar in Within-scan mode, but some " + "defaults are adjusted for motion correction." + ), + anchor="left", + spotlight_rect=_panel_attr_ancestor_rect( + "Registration", + RegistrationPanel, + "_transform_combo", + QGroupBox, + ), + tooltip_target=_dock_widget, + pre_action=_run_actions( + _select_sub_panel("Registration", "_register_panel_radio"), + _set_panel_button("Registration", "_time_series_radio"), + _set_panel_button("Registration", "_advanced_toggle", checked=False), + ), + ), + TourStep( + target=_panel_attr("Registration", RegistrationPanel, "_transforms_panel"), + title="Transforms", + body=( + "The Transforms tab lets you inspect available transforms and " + "save, load, or apply them to other layers." + ), + anchor="left", + tooltip_target=_dock_widget, + pre_action=_select_sub_panel("Registration", "_transforms_panel_radio"), + ), TourStep( target=_accordion_panel("Quality Control"), title="Quality Control", @@ -907,8 +1194,9 @@ def _restore_state() -> None: title="You're Ready to Explore!", body=( "You're all set to start exploring. Load a dataset in Data I/O, " - "overlay a behavioral video, explore signals, run a few QC checks, " - "and have fun digging into some fUSI data!" + "overlay a behavioral video, explore signals, align scans in " + "Registration, run a few QC checks, and have fun digging into " + "some fUSI data!" ), anchor="left", spotlight_rect=lambda: _widget_rect(plugin_widget), From 854be38f6d3edc47a97ea91ec8c6a846474e0423 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 20:28:22 +0100 Subject: [PATCH 56/72] refactor(registration): inline panel workers --- src/confusius/_napari/_registration/_panel.py | 28 +-- .../_napari/_registration/_panel_progress.py | 9 +- .../_napari/_registration/_panel_workers.py | 214 ------------------ .../test_napari/test_registration_panel.py | 36 ++- 4 files changed, 38 insertions(+), 249 deletions(-) delete mode 100644 src/confusius/_napari/_registration/_panel_workers.py diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index b5d8260a..6ae7797b 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -45,7 +45,7 @@ set_registration_parameters, ) from confusius._napari._registration._panel_progress import ( - setup_volume_progress, + create_volume_progress_plotter, setup_volumewise_progress, teardown_volume_progress, teardown_volumewise_progress, @@ -74,18 +74,12 @@ _parse_comma_separated_ints, _prepare_between_scan_data, ) -from confusius._napari._registration._panel_workers import ( - _run_register_volume, - _run_register_volumewise, -) from confusius._napari._registration._progress import ( NapariProgressBridge, NapariRegistrationProgressReporterBridge, ) from confusius.plotting.napari import plot_napari -from confusius.registration import ( - resample_volume, -) +from confusius.registration import register_volume, register_volumewise, resample_volume if TYPE_CHECKING: import napari @@ -2026,7 +2020,13 @@ def _run_registration(self) -> None: if initial_transform_source is not None: volume_payload["initial_transform_source"] = initial_transform_source - progress_plotter = setup_volume_progress( + initialization_arg = ( + initial_transform + if initial_transform is not None + else self._selected_center_initialization() + ) + + progress_plotter = create_volume_progress_plotter( self, moving_layer=cast("Image", moving_layer), fixed_layer=cast("Image", fixed_layer), @@ -2043,7 +2043,7 @@ def _run_registration(self) -> None: scale_mode=volume_payload["scale"], ) - worker = thread_worker(_run_register_volume)( + worker = thread_worker(register_volume)( moving, fixed, transform_type=volume_payload["transform"], @@ -2051,16 +2051,17 @@ def _run_registration(self) -> None: learning_rate=learning_rate, number_of_iterations=volume_payload["number_of_iterations"], use_multi_resolution=volume_payload["use_multi_resolution"], + resample=True, resample_interpolation=volume_payload["resample_interpolation"], mesh_size=volume_payload["mesh_size"], number_of_histogram_bins=volume_payload["number_of_histogram_bins"], convergence_minimum_value=volume_payload["convergence_minimum_value"], convergence_window_size=volume_payload["convergence_window_size"], - center_initialization=self._selected_center_initialization(), - initial_transform=initial_transform, + initialization=initialization_arg, shrink_factors=volume_payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=volume_payload["smoothing_sigmas"] or (6, 2, 1), fill_value=volume_payload["fill_value"], + show_progress=progress_plotter is not None, progress_plotter=progress_plotter, abort_event=self._abort_event, ) @@ -2123,7 +2124,7 @@ def _run_registration(self) -> None: scale_mode=volumewise_payload["scale"], ) - worker = thread_worker(_run_register_volumewise)( + worker = thread_worker(register_volumewise)( moving, reference_time=volumewise_payload["reference_time"], n_jobs=volumewise_payload["n_jobs"], @@ -2142,6 +2143,7 @@ def _run_registration(self) -> None: shrink_factors=volumewise_payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=volumewise_payload["smoothing_sigmas"] or (6, 2, 1), keep_diagnostics=volumewise_payload["keep_diagnostics"], + show_progress=False, abort_event=self._abort_event, progress_reporter=progress_reporter, ) diff --git a/src/confusius/_napari/_registration/_panel_progress.py b/src/confusius/_napari/_registration/_panel_progress.py index c62bd200..ee9297ca 100644 --- a/src/confusius/_napari/_registration/_panel_progress.py +++ b/src/confusius/_napari/_registration/_panel_progress.py @@ -27,6 +27,8 @@ from confusius.registration import resample_like if TYPE_CHECKING: + from collections.abc import Callable + import numpy.typing as npt from napari.layers import Image @@ -34,6 +36,7 @@ RegistrationMetricPlotter, ) from confusius._napari._registration._panel import RegistrationPanel + from confusius.registration import RegistrationProgress def setup_volumewise_progress( @@ -240,7 +243,7 @@ def teardown_volumewise_progress( panel._volumewise_progress_total = None -def setup_volume_progress( +def create_volume_progress_plotter( panel: "RegistrationPanel", *, moving_layer: "Image", @@ -250,7 +253,7 @@ def setup_volume_progress( layer_name: str, initial_transform: "npt.NDArray[np.floating] | None" = None, scale_mode: str, -): +) -> "Callable[..., RegistrationProgress] | None": """Create between-scan preview layers and a progress-plotter factory. Parameters @@ -275,7 +278,7 @@ def setup_volume_progress( Returns ------- callable or None - Progress-factory callback for `register_volume`, or `None` if the + Progress-plotter factory for `register_volume`, or `None` if the preview layer could not be created. """ teardown_volume_progress(panel) diff --git a/src/confusius/_napari/_registration/_panel_workers.py b/src/confusius/_napari/_registration/_panel_workers.py deleted file mode 100644 index d1d44d48..00000000 --- a/src/confusius/_napari/_registration/_panel_workers.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Worker entry points for the napari registration panel.""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence -from threading import Event -from typing import TYPE_CHECKING, Literal - -import numpy as np -import xarray as xr - -from confusius._napari._registration._progress import NapariRegistrationProgressReporter -from confusius.registration import register_volume, register_volumewise - -if TYPE_CHECKING: - import numpy.typing as npt - - from confusius.registration import RegistrationDiagnostics, RegistrationProgress - - -def _run_register_volume( - moving: xr.DataArray, - fixed: xr.DataArray, - *, - transform_type: Literal["translation", "rigid", "affine", "bspline"], - metric: Literal["correlation", "mattes_mi"], - learning_rate: float | Literal["auto"], - number_of_iterations: int, - use_multi_resolution: bool, - resample_interpolation: Literal["linear", "bspline"], - mesh_size: tuple[int, int, int] = (10, 10, 10), - number_of_histogram_bins: int = 50, - convergence_minimum_value: float = 1e-6, - convergence_window_size: int = 10, - center_initialization: Literal["center_geometry", "center_moments"] - | None = "center_geometry", - initial_transform: "npt.NDArray[np.floating]" | None = None, - shrink_factors: Sequence[int] = (6, 2, 1), - smoothing_sigmas: Sequence[int] = (6, 2, 1), - fill_value: float | None = None, - progress_plotter: Callable[..., "RegistrationProgress"] | None = None, - abort_event: Event | None = None, -) -> tuple[ - xr.DataArray, - "npt.NDArray[np.floating]" | xr.DataArray, - "RegistrationDiagnostics", -]: - """Run `register_volume` from the GUI. - - Parameters - ---------- - moving : xarray.DataArray - Moving volume. - fixed : xarray.DataArray - Fixed reference volume. - transform_type : {"translation", "rigid", "affine", "bspline"} - Registration model. - metric : {"correlation", "mattes_mi"} - Similarity metric. - learning_rate : float or {"auto"} - Optimizer learning rate. - number_of_iterations : int - Maximum number of optimizer iterations. - use_multi_resolution : bool - Whether to enable the registration pyramid. - resample_interpolation : {"linear", "bspline"} - Interpolator for the resampled output. - mesh_size : tuple of int, default: (10, 10, 10) - B-spline mesh size. - number_of_histogram_bins : int - Histogram bins for Mattes MI metric. - convergence_minimum_value : float - Convergence threshold. - convergence_window_size : int - Window size for convergence estimation. - center_initialization : {"center_geometry", "center_moments"} or None - Center-based transform initializer. - initial_transform : numpy.ndarray, optional - Pre-computed affine transform used as a warm start before optimization. - shrink_factors : sequence of int - Shrink factors per resolution level. - smoothing_sigmas : sequence of int - Smoothing sigmas per resolution level. - fill_value : float or None - Fill value for resampled output outside input domain. - progress_plotter : callable, optional - Optional progress-plotter factory forwarded to `register_volume`. - abort_event : threading.Event, optional - Cooperative cancellation flag forwarded to `register_volume`. - - Returns - ------- - registered : xarray.DataArray - Resampled registered volume. - transform : numpy.ndarray or xarray.DataArray - Estimated transform. - diagnostics : confusius.registration.RegistrationDiagnostics - Optimizer diagnostics. - """ - return register_volume( - moving, - fixed, - transform_type=transform_type, - metric=metric, - learning_rate=learning_rate, - number_of_iterations=number_of_iterations, - use_multi_resolution=use_multi_resolution, - resample=True, - resample_interpolation=resample_interpolation, - mesh_size=mesh_size, - number_of_histogram_bins=number_of_histogram_bins, - convergence_minimum_value=convergence_minimum_value, - convergence_window_size=convergence_window_size, - initialization=( - center_initialization if initial_transform is None else initial_transform - ), - shrink_factors=shrink_factors, - smoothing_sigmas=smoothing_sigmas, - fill_value=fill_value, - show_progress=progress_plotter is not None, - progress_plotter=progress_plotter, - abort_event=abort_event, - ) - - -def _run_register_volumewise( - data: xr.DataArray, - *, - reference_time: int, - n_jobs: int, - transform: Literal["translation", "rigid", "affine"], - metric: Literal["correlation", "mattes_mi"], - learning_rate: float | Literal["auto"] = 0.01, - number_of_iterations: int = 100, - use_multi_resolution: bool, - resample_interpolation: Literal["linear", "bspline"], - number_of_histogram_bins: int = 50, - convergence_minimum_value: float = 1e-6, - convergence_window_size: int = 10, - initialization: Literal["center_geometry", "center_moments"] - | None = "center_geometry", - shrink_factors: Sequence[int] = (6, 2, 1), - smoothing_sigmas: Sequence[int] = (6, 2, 1), - keep_diagnostics: bool = False, - abort_event: Event | None = None, - progress_reporter: NapariRegistrationProgressReporter | None = None, -) -> xr.DataArray: - """Run `register_volumewise` from the GUI. - - Parameters - ---------- - data : xarray.DataArray - Time-series data to motion-correct. - reference_time : int - Reference frame index. - n_jobs : int - Number of joblib workers to use. - transform : {"translation", "rigid", "affine"} - Registration model. - metric : {"correlation", "mattes_mi"} - Similarity metric. - learning_rate : float or {"auto"}, default: 0.01 - Optimizer learning rate. - number_of_iterations : int - Maximum number of optimizer iterations per frame. - use_multi_resolution : bool - Whether to enable the registration pyramid. - resample_interpolation : {"linear", "bspline"} - Interpolator for the resampled output. - number_of_histogram_bins : int - Histogram bins for Mattes MI metric. - convergence_minimum_value : float - Convergence threshold. - convergence_window_size : int - Window size for convergence estimation. - initialization : {"center_geometry", "center_moments"} or None - Transform initializer. - shrink_factors : tuple of int or None - Shrink factors per resolution level. - smoothing_sigmas : tuple of int or None - Smoothing sigmas per resolution level. - keep_diagnostics : bool - Store detailed optimization diagnostics. - abort_event : threading.Event, optional - Cooperative cancellation flag forwarded to `register_volumewise`. - progress_reporter : NapariRegistrationProgressReporter, optional - GUI-thread bridge-backed reporter forwarded to `register_volumewise`. - - Returns - ------- - xarray.DataArray - Registered time series. - """ - return register_volumewise( - data, - reference_time=reference_time, - n_jobs=n_jobs, - transform=transform, - metric=metric, - learning_rate=learning_rate, - number_of_iterations=number_of_iterations, - use_multi_resolution=use_multi_resolution, - resample_interpolation=resample_interpolation, - number_of_histogram_bins=number_of_histogram_bins, - convergence_minimum_value=convergence_minimum_value, - convergence_window_size=convergence_window_size, - initialization=initialization, - shrink_factors=shrink_factors, - smoothing_sigmas=smoothing_sigmas, - keep_diagnostics=keep_diagnostics, - show_progress=False, - abort_event=abort_event, - progress_reporter=progress_reporter, - ) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 2063fd1d..53b7f60a 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -11,7 +11,7 @@ from qtpy.QtWidgets import QApplication from confusius._napari._registration._panel_progress import ( - setup_volume_progress, + create_volume_progress_plotter, setup_volumewise_progress, teardown_volume_progress, update_progress_layer, @@ -221,7 +221,7 @@ def test_scale_preprocessing_resets_gamma_for_previews( moving.gamma = 0.4 fixed_layer.gamma = 0.6 - setup_volume_progress( + create_volume_progress_plotter( registration_panel, moving_layer=moving, fixed_layer=fixed_layer, @@ -235,7 +235,7 @@ def test_scale_preprocessing_resets_gamma_for_previews( assert viewer.layers["Registered (rigid)"].gamma == pytest.approx(1.0) teardown_volume_progress(registration_panel) - setup_volume_progress( + create_volume_progress_plotter( registration_panel, moving_layer=moving, fixed_layer=fixed_layer, @@ -248,7 +248,7 @@ def test_scale_preprocessing_resets_gamma_for_previews( assert viewer.layers["Moving"].gamma == pytest.approx(0.4) assert viewer.layers["Registered (rigid)"].gamma == pytest.approx(0.4) - def test_setup_volume_progress_preserves_camera_view( + def test_create_volume_progress_plotter_preserves_camera_view( self, viewer, registration_panel ): moving_data = xr.DataArray( @@ -274,7 +274,7 @@ def test_setup_volume_progress_preserves_camera_view( viewer.camera.zoom = 7.0 before = (tuple(viewer.camera.center), viewer.camera.zoom, viewer.dims.ndisplay) - setup_volume_progress( + create_volume_progress_plotter( registration_panel, moving_layer=moving, fixed_layer=fixed_layer, @@ -417,7 +417,7 @@ def _runner(*args, **kwargs): _fake_thread_worker, ) monkeypatch.setattr( - "confusius._napari._registration._panel.setup_volume_progress", + "confusius._napari._registration._panel.create_volume_progress_plotter", lambda *_args, **_kwargs: None, ) @@ -425,8 +425,7 @@ def _runner(*args, **kwargs): kwargs = cast("dict[str, Any]", captured["kwargs"]) args = cast("tuple[Any, ...]", captured["args"]) - np.testing.assert_array_equal(kwargs["initial_transform"], affine) - assert kwargs["center_initialization"] is None + np.testing.assert_array_equal(kwargs["initialization"], affine) np.testing.assert_allclose(args[0].values, np.sqrt(moving.values)) np.testing.assert_allclose(args[1].values, np.sqrt(fixed.values)) assert registration_panel._worker is not None @@ -506,7 +505,7 @@ def _runner(*args, **kwargs): _fake_thread_worker, ) monkeypatch.setattr( - "confusius._napari._registration._panel.setup_volume_progress", + "confusius._napari._registration._panel.create_volume_progress_plotter", lambda *_args, **_kwargs: None, ) @@ -522,8 +521,7 @@ def _runner(*args, **kwargs): [0.0, 0.0, 0.0, 1.0], ] ) - np.testing.assert_allclose(kwargs["initial_transform"], expected) - assert kwargs["center_initialization"] is None + np.testing.assert_allclose(kwargs["initialization"], expected) assert args[0].dims == ("z", "y", "x") assert registration_panel._worker is not None @@ -1115,7 +1113,7 @@ def test_volume_result_adds_bspline_transform_metadata( def test_volume_result_replaces_preview_layer( self, viewer, registration_panel, qtbot ): - """A preview layer created by `_setup_volume_progress` is removed + """A preview layer created by `create_volume_progress_plotter` is removed after `_on_registration_finished` so the final result is the only layer with that name.""" moving_data = xr.DataArray( @@ -1137,7 +1135,7 @@ def test_volume_result_replaces_preview_layer( ) fixed_layer = viewer.add_image(np.ones((4, 6), dtype=np.float32), name="fixed") - factory = setup_volume_progress( + factory = create_volume_progress_plotter( registration_panel, moving_layer=moving, fixed_layer=fixed_layer, @@ -1221,7 +1219,7 @@ def test_volume_result_replaces_preview_layer( np.asarray(registered.values), ) - def test_setup_volume_progress_applies_initial_transform_to_preview_layers( + def test_create_volume_progress_plotter_applies_initial_transform_to_preview_layers( self, viewer, registration_panel ): moving_data = xr.DataArray( @@ -1244,7 +1242,7 @@ def test_setup_volume_progress_applies_initial_transform_to_preview_layers( dtype=float, ) - setup_volume_progress( + create_volume_progress_plotter( registration_panel, moving_layer=moving, fixed_layer=fixed_layer, @@ -1289,7 +1287,7 @@ def test_progress_layer_data_updates_on_iteration( ) fixed_layer = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="fixed") - setup_volume_progress( + create_volume_progress_plotter( registration_panel, moving_layer=moving, fixed_layer=fixed_layer, @@ -1328,8 +1326,8 @@ def test_progress_layer_data_updates_on_iteration( assert moving.colormap.name != "cyan" assert moving.blending != "additive" - def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): - """`_setup_volume_progress` lazily creates and docks the metric plotter.""" + def test_create_volume_progress_plotter_creates_metric_plotter_dock(self, viewer, registration_panel): + """`create_volume_progress_plotter` lazily creates and docks the metric plotter.""" moving_data = xr.DataArray( np.zeros((4, 6), dtype=np.float32), dims=["y", "x"], @@ -1352,7 +1350,7 @@ def test_setup_creates_metric_plotter_dock(self, viewer, registration_panel): assert registration_panel._metric_plotter is None assert registration_panel._metric_dock is None - setup_volume_progress( + create_volume_progress_plotter( registration_panel, moving_layer=moving, fixed_layer=fixed_layer, From 07b1e90f4fe158f9ccb8f97d31d0a4b469e663bf Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 20:53:27 +0100 Subject: [PATCH 57/72] refactor(registration): tighten preview setup --- src/confusius/_napari/_registration/_panel.py | 37 ++++++++++--------- .../_napari/_registration/_panel_progress.py | 14 ++++--- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 6ae7797b..509329bb 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2026,22 +2026,25 @@ def _run_registration(self) -> None: else self._selected_center_initialization() ) - progress_plotter = create_volume_progress_plotter( - self, - moving_layer=cast("Image", moving_layer), - fixed_layer=cast("Image", fixed_layer), - moving=moving, - fixed=fixed, - layer_name=self._make_unique_layer_name( - self._volume_result_layer_name( - volume_payload["moving_layer_name"], - volume_payload["fixed_layer_name"], - transform_model=volume_payload["transform"], - ) - ), - initial_transform=initial_transform, - scale_mode=volume_payload["scale"], - ) + try: + progress_plotter = create_volume_progress_plotter( + self, + moving_layer=cast("Image", moving_layer), + fixed_layer=cast("Image", fixed_layer), + moving=moving, + fixed=fixed, + layer_name=self._make_unique_layer_name( + self._volume_result_layer_name( + volume_payload["moving_layer_name"], + volume_payload["fixed_layer_name"], + transform_model=volume_payload["transform"], + ) + ), + initial_transform=initial_transform, + scale_mode=volume_payload["scale"], + ) + except Exception: + return worker = thread_worker(register_volume)( moving, @@ -2061,7 +2064,7 @@ def _run_registration(self) -> None: shrink_factors=volume_payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=volume_payload["smoothing_sigmas"] or (6, 2, 1), fill_value=volume_payload["fill_value"], - show_progress=progress_plotter is not None, + show_progress=True, progress_plotter=progress_plotter, abort_event=self._abort_event, ) diff --git a/src/confusius/_napari/_registration/_panel_progress.py b/src/confusius/_napari/_registration/_panel_progress.py index ee9297ca..e836c75b 100644 --- a/src/confusius/_napari/_registration/_panel_progress.py +++ b/src/confusius/_napari/_registration/_panel_progress.py @@ -253,7 +253,7 @@ def create_volume_progress_plotter( layer_name: str, initial_transform: "npt.NDArray[np.floating] | None" = None, scale_mode: str, -) -> "Callable[..., RegistrationProgress] | None": +) -> "Callable[..., RegistrationProgress]": """Create between-scan preview layers and a progress-plotter factory. Parameters @@ -277,9 +277,13 @@ def create_volume_progress_plotter( Returns ------- - callable or None - Progress-plotter factory for `register_volume`, or `None` if the - preview layer could not be created. + callable + Progress-plotter factory for `register_volume`. + + Raises + ------ + Exception + If the preview layer could not be created. """ teardown_volume_progress(panel) @@ -377,7 +381,7 @@ def create_volume_progress_plotter( ) except Exception as exc: # noqa: BLE001 panel._set_error(f"Could not create progress layer: {exc}") - return None + raise bridge = NapariProgressBridge() bridge.iterated.connect(lambda arr: update_progress_layer(panel, arr)) From 4208964666fe830e5c5e095918d223bb5d9ab8b9 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Wed, 1 Jul 2026 23:27:59 +0100 Subject: [PATCH 58/72] refactor(registration): split transforms helper from panel module Move the transform payload and panel-specific transform helpers out of _panel.py and the previous _panel_transform_helpers.py into a single _panel_transforms.py module, with docstrings completed in the process. - Hoist UI callbacks (save, load, apply, refresh transform controls) into panel-level lambdas so the panel only owns wiring. - Update the napari registration panel tests to call the new module-level helpers. --- src/confusius/_napari/_registration/_panel.py | 570 +------ .../_registration/_panel_transform_helpers.py | 632 -------- .../_registration/_panel_transforms.py | 1311 +++++++++++++++++ .../test_napari/test_registration_panel.py | 22 +- 4 files changed, 1359 insertions(+), 1176 deletions(-) delete mode 100644 src/confusius/_napari/_registration/_panel_transform_helpers.py create mode 100644 src/confusius/_napari/_registration/_panel_transforms.py diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 509329bb..6b6512fd 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -2,7 +2,6 @@ from __future__ import annotations -from pathlib import Path from threading import Event from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, cast @@ -19,7 +18,6 @@ QComboBox, QDockWidget, QDoubleSpinBox, - QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, @@ -35,7 +33,7 @@ QWidget, ) -from confusius._dims import SPATIAL_DIMS, TIME_DIM +from confusius._dims import TIME_DIM from confusius._napari._registration._metric_plotter import ( RegistrationMetricPlotter, ) @@ -50,19 +48,18 @@ teardown_volume_progress, teardown_volumewise_progress, ) -from confusius._napari._registration._panel_transform_helpers import ( - AffineTransformPayload, +from confusius._napari._registration._panel_transforms import ( TransformPayload, - _get_affine_payload_from_layer, - _get_spatial_manual_affine_from_layer, - _make_manual_transform_payload, - get_affine_transform_from_payload, - get_bspline_transform_from_payload, - get_output_grid_from_payload, - load_transform_payload, + apply_selected_transform, + get_available_transform_payloads, + get_selected_center_initialization, + get_selected_initial_transform, + load_transform, make_affine_transform_payload, make_bspline_transform_payload, - save_transform_payload, + refresh_transform_controls, + save_selected_transform, + validate_initial_transform_selection, ) from confusius._napari._registration._panel_utils import ( ScientificDoubleSpinBox, @@ -79,7 +76,7 @@ NapariRegistrationProgressReporterBridge, ) from confusius.plotting.napari import plot_napari -from confusius.registration import register_volume, register_volumewise, resample_volume +from confusius.registration import register_volume, register_volumewise if TYPE_CHECKING: import napari @@ -235,6 +232,12 @@ def __init__(self, viewer: napari.Viewer) -> None: self._registration_parameters_by_operation: dict[ RegistrationOperation, ModeParameters ] = {} + self._refresh_transform_controls_callback = lambda: refresh_transform_controls( + self + ) + self._save_transform_callback = lambda: save_selected_transform(self) + self._load_transform_callback = lambda: load_transform(self) + self._apply_transform_callback = lambda: apply_selected_transform(self) self._setup_ui() self.viewer.layers.events.inserted.connect(self._refresh_layers) self.viewer.layers.events.removed.connect(self._refresh_layers) @@ -814,11 +817,11 @@ def _setup_ui(self) -> None: transform_buttons = QHBoxLayout() self._save_transform_btn = QPushButton("Save") - self._save_transform_btn.clicked.connect(self._save_transform) + self._save_transform_btn.clicked.connect(self._save_transform_callback) self._load_transform_btn = QPushButton("Load") - self._load_transform_btn.clicked.connect(self._load_transform) + self._load_transform_btn.clicked.connect(self._load_transform_callback) self._apply_transform_btn = QPushButton("Apply") - self._apply_transform_btn.clicked.connect(self._apply_transform) + self._apply_transform_btn.clicked.connect(self._apply_transform_callback) transform_buttons.addWidget(self._save_transform_btn) transform_buttons.addWidget(self._load_transform_btn) transform_buttons.addWidget(self._apply_transform_btn) @@ -879,7 +882,9 @@ def _sync_manual_transform_event_connections(self) -> None: """Keep manual-transform refresh hooks in sync with viewer layers.""" for layer in self._manual_transform_event_layers: try: - layer.events.affine.disconnect(self._refresh_transform_controls) + layer.events.affine.disconnect( + self._refresh_transform_controls_callback + ) except (TypeError, RuntimeError): pass self._manual_transform_event_layers = [] @@ -888,7 +893,7 @@ def _sync_manual_transform_event_connections(self) -> None: if not _is_registration_source_layer(layer): continue _get_source_dataarray(layer) - layer.events.affine.connect(self._refresh_transform_controls) + layer.events.affine.connect(self._refresh_transform_controls_callback) self._manual_transform_event_layers.append(layer) def _refresh_layers(self) -> None: @@ -926,7 +931,7 @@ def _refresh_layers(self) -> None: self._update_reference_time_bounds() self._sync_manual_transform_event_connections() - self._refresh_transform_controls() + refresh_transform_controls(self) self._validate_registration_selection() def _get_layer_by_name(self, name: str) -> Layer | None: @@ -1055,61 +1060,6 @@ def _current_transform_model(self) -> VolumeTransformType | VolumewiseTransformT return "affine" raise ValueError(f"Unknown transform model: {value!r}.") - def _transform_source_data(self, value: object) -> TransformSourceData | None: - """Return validated transform-source combo data. - - Parameters - ---------- - value : object - Raw combo-box payload to validate. - - Returns - ------- - tuple[str, str] or None - Validated `(kind, name)` pair, or `None` when the payload does not - match the expected transform-source schema. - """ - if not isinstance(value, tuple) or len(value) != 2: - return None - source_kind, source_name = value - if not isinstance(source_name, str): - return None - if source_kind == "loaded": - return ("loaded", source_name) - if source_kind == "layer": - return ("layer", source_name) - if source_kind == "manual": - return ("manual", source_name) - return None - - def _transform_payload_from_metadata( - self, payload: object - ) -> TransformPayload | None: - """Return a validated transform payload stored in layer metadata. - - Parameters - ---------- - payload : object - Raw metadata payload to validate. - - Returns - ------- - TransformPayload or None - Validated transform payload, or `None` when the metadata does not - contain a supported transform payload. - """ - if not isinstance(payload, dict): - return None - payload_mapping = cast("dict[str, object]", payload) - kind = payload_mapping.get("kind") - if kind == "affine": - get_affine_transform_from_payload(payload_mapping) - return cast("TransformPayload", payload_mapping) - if kind == "bspline": - get_bspline_transform_from_payload(payload_mapping) - return cast("TransformPayload", payload_mapping) - return None - def _set_image_layer_data(self, layer: Image, data: npt.NDArray[Any]) -> None: """Assign image data despite the current napari stub mismatch. @@ -1127,26 +1077,6 @@ def _set_image_layer_data(self, layer: Image, data: npt.NDArray[Any]) -> None: """ cast("Any", layer).data = data - def _transform_source_label( - self, payload: TransformPayload, *, suffix: str | None = None - ) -> str: - """Return a user-facing label for a transform payload. - - Parameters - ---------- - payload : TransformPayload - Transform payload to label. - suffix : str, optional - Unused legacy suffix parameter kept to avoid wider churn. - - Returns - ------- - str - Label shown in transform selectors. - """ - del suffix - return payload["name"] - def _make_unique_layer_name(self, base_name: str) -> str: """Return a viewer-unique layer name based on `base_name`. @@ -1184,7 +1114,7 @@ def _make_unique_transform_name(self, base_name: str) -> str: Unique transform payload name for the current viewer. """ existing_names = { - payload["name"] for payload in self._available_transform_payloads() + payload["name"] for payload in get_available_transform_payloads(self) } if base_name not in existing_names: return base_name @@ -1250,306 +1180,6 @@ def _volumewise_moving_preview_layer_name(self) -> str: """Return the napari layer name for the within-scan moving preview.""" return "Moving" - def _available_transform_payloads(self) -> list[TransformPayload]: - """Return all transform payloads currently available in the UI. - - Returns - ------- - list of TransformPayload - Loaded payload plus any validated payloads found on viewer layers. - """ - payloads: list[TransformPayload] = [] - if self._loaded_transform_payload is not None: - payloads.append(self._loaded_transform_payload) - for layer in self.viewer.layers: - payload = self._transform_payload_from_metadata( - layer.metadata.get("confusius_transform") - ) - if payload is not None: - payloads.append(payload) - return payloads - - def _refresh_transform_controls(self) -> None: - """Refresh transform-related layer selectors. - - Returns - ------- - None - Updates transform, initialization, and target selectors in place. - """ - source_data = self._transform_source_combo.currentData() - initialization_data = self._initialization_combo.currentData() - target_name = self._transform_target_combo.currentText() - - transform_options: list[tuple[str, tuple[str, str]]] = [] - if self._loaded_transform_payload is not None: - transform_options.append( - ( - self._transform_source_label( - self._loaded_transform_payload, - suffix="loaded", - ), - ("loaded", ""), - ) - ) - for layer in self.viewer.layers: - payload = self._transform_payload_from_metadata( - layer.metadata.get("confusius_transform") - ) - if payload is None: - continue - transform_options.append( - ( - self._transform_source_label(payload, suffix=layer.name), - ("layer", layer.name), - ) - ) - - manual_transform_options: list[tuple[str, tuple[str, str]]] = [] - manual_initialization_options: list[tuple[str, tuple[str, str]]] = [] - for layer in self.viewer.layers: - try: - data = _get_source_dataarray(layer) - spatial_dims = [str(dim) for dim in data.dims if dim in SPATIAL_DIMS] - if not spatial_dims: - continue - manual_affine = _get_spatial_manual_affine_from_layer( - layer, - spatial_dims=spatial_dims, - ) - except Exception: # noqa: BLE001 - continue - if np.allclose(manual_affine, np.eye(len(spatial_dims) + 1)): - continue - manual_option = (f"{layer.name} (manual)", ("manual", layer.name)) - manual_transform_options.append(manual_option) - manual_initialization_options.append(manual_option) - - self._transform_source_combo.blockSignals(True) - self._transform_source_combo.clear() - for label, data in transform_options: - self._transform_source_combo.addItem(label, data) - for label, data in manual_transform_options: - self._transform_source_combo.addItem(label, data) - self._transform_source_combo.blockSignals(False) - - self._initialization_combo.blockSignals(True) - self._initialization_combo.clear() - self._initialization_combo.addItem("center_geometry", "center_geometry") - self._initialization_combo.addItem("center_moments", "center_moments") - self._initialization_combo.addItem("none", None) - for label, data in transform_options: - source_kind, source_name = data - if source_kind == "loaded": - if self._loaded_transform_payload is None: - continue - if self._loaded_transform_payload["kind"] != "affine": - continue - elif source_kind == "layer": - layer = self._get_layer_by_name(source_name) - if layer is None or _get_affine_payload_from_layer(layer) is None: - continue - self._initialization_combo.addItem(label, data) - for label, data in manual_initialization_options: - self._initialization_combo.addItem(label, data) - self._initialization_combo.blockSignals(False) - - self._transform_target_combo.blockSignals(True) - self._transform_target_combo.clear() - self._transform_target_combo.addItems( - [layer.name for layer in self.viewer.layers] - ) - self._transform_target_combo.blockSignals(False) - - if source_data is not None: - for i in range(self._transform_source_combo.count()): - if self._transform_source_combo.itemData(i) == source_data: - self._transform_source_combo.setCurrentIndex(i) - break - - if initialization_data is not None: - for i in range(self._initialization_combo.count()): - if self._initialization_combo.itemData(i) == initialization_data: - self._initialization_combo.setCurrentIndex(i) - break - - target_index = self._transform_target_combo.findText(target_name) - if target_index >= 0: - self._transform_target_combo.setCurrentIndex(target_index) - - def _selected_transform_payload(self) -> TransformPayload | None: - """Return the currently selected transform payload. - - Returns - ------- - TransformPayload or None - Selected transform payload, or `None` when no valid selection is - available. - """ - source_data = self._transform_source_data( - self._transform_source_combo.currentData() - ) - if source_data is None: - return None - - source_kind, source_name = source_data - if source_kind == "loaded": - return self._loaded_transform_payload - if not source_name: - return None - layer = self._get_layer_by_name(source_name) - if layer is None: - return None - if source_kind == "layer": - return self._transform_payload_from_metadata( - layer.metadata.get("confusius_transform") - ) - if source_kind == "manual": - return _make_manual_transform_payload(layer) - return None - - def _selected_center_initialization( - self, - ) -> Literal["center_geometry", "center_moments"] | None: - """Return the selected built-in centering initialization, if any. - - Returns - ------- - {"center_geometry", "center_moments"} or None - Selected built-in initialization, or `None` when the selection is - an explicit transform or identity. - """ - value = self._initialization_combo.currentData() - if value in {"center_geometry", "center_moments"}: - return value - return None - - def _selected_initial_transform_payload(self) -> AffineTransformPayload | None: - """Return the payload selected for registration initialization, if any. - - Returns - ------- - AffineTransformPayload or None - Selected affine initialization payload, or `None` when the current - initialization does not point to an affine payload. - """ - source_data = self._transform_source_data( - self._initialization_combo.currentData() - ) - if source_data is None: - return None - - source_kind, source_name = source_data - if source_kind == "loaded": - if ( - self._loaded_transform_payload is not None - and self._loaded_transform_payload["kind"] == "affine" - ): - return self._loaded_transform_payload - return None - if source_kind != "layer" or not source_name: - return None - layer = self._get_layer_by_name(source_name) - if layer is None: - return None - return _get_affine_payload_from_layer(layer) - - def _selected_manual_initialization_layer(self) -> Layer | None: - """Return the layer selected for manual napari initialization, if any. - - Returns - ------- - napari.layers.Layer or None - Selected manual-initialization layer, or `None` when the current - initialization is not a manual layer transform. - """ - source_data = self._transform_source_data( - self._initialization_combo.currentData() - ) - if source_data is None: - return None - - source_kind, source_name = source_data - if source_kind != "manual" or not source_name: - return None - return self._get_layer_by_name(source_name) - - def _selected_initial_transform( - self, - moving: xr.DataArray, - *, - moving_layer: Layer | None = None, - fixed_layer: Layer | None = None, - ) -> tuple[npt.NDArray[np.float64] | None, str | None]: - """Return the selected initialization affine and its source label.""" - payload = self._selected_initial_transform_payload() - if payload is not None: - return get_affine_transform_from_payload(payload), payload["name"] - - layer = self._selected_manual_initialization_layer() - if layer is None: - return None, None - if moving_layer is None or fixed_layer is None: - raise ValueError("Select moving and fixed layers.") - if layer not in {moving_layer, fixed_layer}: - raise ValueError( - "Selected manual initialization must come from the current moving " - "or fixed layer." - ) - - spatial_dims = [str(dim) for dim in moving.dims if dim in SPATIAL_DIMS] - moving_affine = _get_spatial_manual_affine_from_layer( - moving_layer, - spatial_dims=spatial_dims, - ) - fixed_affine = _get_spatial_manual_affine_from_layer( - fixed_layer, - spatial_dims=spatial_dims, - ) - affine = np.linalg.inv(moving_affine) @ fixed_affine - return affine, f"{layer.name} (manual)" - - def _validate_initial_transform_selection( - self, - *, - operation: Literal["register_volume", "register_volumewise"], - moving: xr.DataArray, - fixed: xr.DataArray | None = None, - ) -> str | None: - """Return an inline validation message for transform initialization.""" - if operation != "register_volume": - return None - if ( - self._selected_initial_transform_payload() is None - and self._selected_manual_initialization_layer() is None - ): - return None - if fixed is None: - return "Select a fixed layer." - - moving_layer = self._selected_layer(self._moving_combo) - fixed_layer = self._selected_layer(self._fixed_combo) - - try: - affine, _ = self._selected_initial_transform( - moving, - moving_layer=moving_layer, - fixed_layer=fixed_layer, - ) - except Exception as exc: # noqa: BLE001 - return str(exc) - - if affine is None: - return None - - expected_shape = (moving.ndim + 1, moving.ndim + 1) - if affine.shape != expected_shape: - return ( - f"Selected initialization transform has shape {affine.shape}, " - f"but this registration expects {expected_shape}." - ) - return None - def _update_reference_time_bounds(self) -> None: """Clamp the volumewise reference-time widget to the moving layer.""" moving_layer = self._selected_layer(self._moving_combo) @@ -1639,7 +1269,8 @@ def _validate_registration_selection(self) -> bool: ) self._set_run_btn_enabled(False) return False - init_message = self._validate_initial_transform_selection( + init_message = validate_initial_transform_selection( + self, operation=operation, moving=moving, ) @@ -1676,7 +1307,8 @@ def _validate_registration_selection(self) -> bool: message = "Moving and fixed layers must be different." if message is None: - message = self._validate_initial_transform_selection( + message = validate_initial_transform_selection( + self, operation=operation, moving=_prepare_between_scan_data(moving), fixed=_prepare_between_scan_data(fixed), @@ -1694,7 +1326,7 @@ def _validate_registration_selection(self) -> bool: def _on_moving_layer_changed(self, _name: str) -> None: """Update dependent widgets when the moving layer changes.""" self._update_reference_time_bounds() - self._refresh_transform_controls() + refresh_transform_controls(self) self._validate_registration_selection() def _operation(self) -> Literal["register_volume", "register_volumewise"]: @@ -1812,105 +1444,6 @@ def _set_error(self, message: str) -> None: self._status.setText(message) self._status.show() - def _save_transform(self) -> None: - """Save the selected transform payload to disk.""" - payload = self._selected_transform_payload() - if payload is None: - self._set_error("Select a transform to save.") - return - - default_name = payload["name"].replace("/", "-") - suffix = ".json" if payload["kind"] == "affine" else ".zarr" - file_filter = ( - "JSON files (*.json)" - if payload["kind"] == "affine" - else "Zarr stores (*.zarr)" - ) - start = str(Path.home() / f"{default_name}{suffix}") - path_str, _ = QFileDialog.getSaveFileName( - self, - "Save transform", - start, - file_filter, - ) - if not path_str: - return - - save_transform_payload(path_str, payload) - show_info(f"Saved transform: {path_str}") - - def _load_transform(self) -> None: - """Load a transform payload from disk.""" - start = str(Path.home()) - path_str, _ = QFileDialog.getOpenFileName( - self, - "Load transform", - start, - "Transform files (*.json *.zarr)", - ) - if not path_str: - return - - try: - self._loaded_transform_payload = load_transform_payload(path_str) - except Exception as exc: # noqa: BLE001 - self._set_error(str(exc)) - show_error(str(exc)) - return - - self._refresh_transform_controls() - for i in range(self._transform_source_combo.count()): - if self._transform_source_combo.itemData(i) == ("loaded", ""): - self._transform_source_combo.setCurrentIndex(i) - break - show_info(f"Loaded transform: {self._loaded_transform_payload['name']}") - - def _apply_transform(self) -> None: - """Apply the selected affine transform to a layer.""" - payload = self._selected_transform_payload() - if payload is None: - self._set_error("Select a transform to apply.") - return - - moving_layer = self._selected_layer(self._transform_target_combo) - if moving_layer is None: - self._set_error("Select an input layer to transform.") - return - - try: - moving = _get_source_dataarray(moving_layer) - if payload["kind"] == "affine": - transform = get_affine_transform_from_payload(payload) - else: - transform = get_bspline_transform_from_payload(payload) - output_grid = get_output_grid_from_payload(payload) - except Exception as exc: # noqa: BLE001 - self._set_error(str(exc)) - return - - worker = thread_worker(resample_volume)( - moving, - transform, - shape=output_grid["shape"], - spacing=output_grid["spacing"], - origin=output_grid["origin"], - dims=output_grid["dims"], - interpolation=self._current_resample_interpolation(), - ) - apply_payload: ApplyTransformPayload = { - "moving_layer_name": moving_layer.name, - "target_layer_name": payload["target_layer_name"], - "transform_source": payload["name"], - } - self._worker = worker - self._begin_work() - worker.returned.connect( - lambda result: self._on_apply_transform_finished(apply_payload, result) - ) - worker.errored.connect(self._on_registration_failed) - worker.finished.connect(self._end_work) - worker.start() - def _run_registration(self) -> None: """Validate inputs and start the selected registration workflow.""" operation = self._operation() @@ -1977,7 +1510,8 @@ def _run_registration(self) -> None: initial_transform: npt.NDArray[np.floating] | None = None try: initial_transform, initial_transform_source = ( - self._selected_initial_transform( + get_selected_initial_transform( + self, moving, moving_layer=moving_layer, fixed_layer=fixed_layer, @@ -2023,7 +1557,7 @@ def _run_registration(self) -> None: initialization_arg = ( initial_transform if initial_transform is not None - else self._selected_center_initialization() + else get_selected_center_initialization(self) ) try: @@ -2142,7 +1676,7 @@ def _run_registration(self) -> None: "convergence_minimum_value" ], convergence_window_size=volumewise_payload["convergence_window_size"], - initialization=self._selected_center_initialization(), + initialization=get_selected_center_initialization(self), shrink_factors=volumewise_payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=volumewise_payload["smoothing_sigmas"] or (6, 2, 1), keep_diagnostics=volumewise_payload["keep_diagnostics"], @@ -2234,7 +1768,7 @@ def _finalize_registration_layer( layer.metadata.update(metadata) layer.metadata["xarray"] = registered self.viewer.layers.selection.active = layer - self._refresh_transform_controls() + refresh_transform_controls(self) if payload["operation"] == "register_volumewise": self._progress.setValue(self._progress.maximum()) @@ -2390,38 +1924,6 @@ def _on_volumewise_registration_finished( registration_status=registration_status, ) - def _on_apply_transform_finished( - self, payload: ApplyTransformPayload, result: xr.DataArray - ) -> None: - """Add a resampled layer produced from an existing affine transform. - - Parameters - ---------- - payload : dict[str, str] - UI snapshot captured before the worker started. - result : xarray.DataArray - Resampled output. - """ - registered = result.copy(deep=False) - registered.attrs = registered.attrs.copy() - registered.attrs["registration_operation"] = "apply_transform" - - layer_name = f"{payload['moving_layer_name']} → {payload['target_layer_name']}" - contrast_limits = tuple(calc_data_range(registered.data)) - - _, layer = plot_napari( - registered, - viewer=self.viewer, - name=layer_name, - show_colorbar=False, - contrast_limits=contrast_limits, - ) - layer.metadata["xarray"] = registered - layer.metadata["registration_operation"] = "apply_transform" - layer.metadata["registration_parameters"] = payload.copy() - self.viewer.layers.selection.active = layer - show_info(f"Added transformed layer: {layer.name}") - def _on_registration_failed(self, exc: BaseException) -> None: """Handle a failed worker execution. diff --git a/src/confusius/_napari/_registration/_panel_transform_helpers.py b/src/confusius/_napari/_registration/_panel_transform_helpers.py deleted file mode 100644 index 3aec9be5..00000000 --- a/src/confusius/_napari/_registration/_panel_transform_helpers.py +++ /dev/null @@ -1,632 +0,0 @@ -"""Transform payload and panel-specific transform helpers for the napari registration panel.""" - -from __future__ import annotations - -import json -from collections.abc import Sequence -from pathlib import Path -from typing import TYPE_CHECKING, Literal, SupportsFloat, SupportsIndex, TypedDict, cast - -import numpy as np -import numpy.typing as npt -import xarray as xr - -from confusius._dims import SPATIAL_DIMS -from confusius._napari._registration._panel_utils import ( - _get_source_dataarray, - _prepare_between_scan_data, -) -from confusius.registration.bspline import validate_bspline_dataarray - -if TYPE_CHECKING: - from collections.abc import Mapping - - from napari.layers import Layer - - from confusius.registration import RegistrationDiagnostics - - -class TransformDiagnosticsPayload(TypedDict): - """JSON-serializable registration diagnostics summary.""" - - metric: str - final_metric_value: float - n_iterations: int - stop_condition: str - status: str - - -class OutputGridPayload(TypedDict): - """JSON-serializable resampling grid description.""" - - dims: list[str] - shape: list[int] - spacing: list[float] - origin: list[float] - units: list[str | None] - - -class BSplineDataArrayPayload(TypedDict): - """JSON-serializable B-spline control-point DataArray.""" - - dims: list[str] - data: list[object] - coords: dict[str, list[float]] - attrs: dict[str, object] - - -class AffineTransformPayload(TypedDict): - """JSON-serializable affine transform payload used by the napari plugin.""" - - kind: Literal["affine"] - name: str - affine: list[list[float]] - source_layer_name: str - target_layer_name: str - operation: str - transform_model: str - metric: str - output_grid: OutputGridPayload - diagnostics: TransformDiagnosticsPayload - - -class BSplineTransformPayload(TypedDict): - """B-spline transform payload used by the napari plugin.""" - - kind: Literal["bspline"] - name: str - bspline: BSplineDataArrayPayload - source_layer_name: str - target_layer_name: str - operation: str - transform_model: str - metric: str - output_grid: OutputGridPayload - diagnostics: TransformDiagnosticsPayload - - -TransformPayload = AffineTransformPayload | BSplineTransformPayload -"""Union of affine and B-spline transform payloads.""" - - -def make_output_grid_payload(reference: "xr.DataArray") -> OutputGridPayload: - """Return the resampling grid defined by a reference DataArray. - - Parameters - ---------- - reference : xarray.DataArray - Spatial DataArray defining the output grid. - - Returns - ------- - OutputGridPayload - JSON-serializable output-grid description. - """ - dims = [str(dim) for dim in reference.dims] - return { - "dims": dims, - "shape": [int(reference.sizes[dim]) for dim in dims], - "spacing": [float(reference.fusi.spacing[dim]) for dim in dims], - "origin": [float(reference.fusi.origin[dim]) for dim in dims], - "units": [ - cast("str | None", reference.coords[dim].attrs.get("units")) - if dim in reference.coords - else None - for dim in dims - ], - } - - -def _make_diagnostics_payload( - diagnostics: "RegistrationDiagnostics", -) -> TransformDiagnosticsPayload: - """Return a JSON-serializable diagnostics summary.""" - return { - "metric": diagnostics.metric, - "final_metric_value": float(diagnostics.final_metric_value), - "n_iterations": int(diagnostics.n_iterations), - "stop_condition": diagnostics.stop_condition, - "status": diagnostics.status, - } - - -def make_affine_transform_payload( - affine: npt.NDArray[np.floating], - *, - reference: "xr.DataArray", - source_layer_name: str, - target_layer_name: str, - operation: str, - transform_model: str, - metric: str, - diagnostics: "RegistrationDiagnostics", - name: str | None = None, -) -> AffineTransformPayload: - """Build a JSON-serializable payload for a registered affine transform. - - Parameters - ---------- - affine : (N+1, N+1) numpy.ndarray - Affine transform in homogeneous coordinates. - reference : xarray.DataArray - Fixed/reference DataArray defining the output resampling grid. - source_layer_name : str - Name of the moving/source layer used when estimating the transform. - target_layer_name : str - Name of the fixed/target layer used when estimating the transform. - operation : str - Registration operation that produced the transform. - transform_model : str - Transform model used during registration. - metric : str - Similarity metric used during registration. - diagnostics : confusius.registration.RegistrationDiagnostics - Per-call registration diagnostics. - name : str, optional - Human-friendly transform name. If not provided, a default name is generated. - - Returns - ------- - AffineTransformPayload - JSON-serializable affine transform payload. - """ - affine = np.asarray(affine, dtype=float) - payload_name = ( - name or f"{source_layer_name} → {target_layer_name} ({transform_model})" - ) - return { - "kind": "affine", - "name": payload_name, - "affine": affine.tolist(), - "source_layer_name": source_layer_name, - "target_layer_name": target_layer_name, - "operation": operation, - "transform_model": transform_model, - "metric": metric, - "output_grid": make_output_grid_payload(reference), - "diagnostics": _make_diagnostics_payload(diagnostics), - } - - -def _serialize_bspline_dataarray(transform: "xr.DataArray") -> BSplineDataArrayPayload: - """Return a JSON-serializable B-spline DataArray payload.""" - validate_bspline_dataarray(transform) - return { - "dims": [str(dim) for dim in transform.dims], - "data": np.asarray(transform, dtype=float).tolist(), - "coords": { - str(dim): np.asarray(transform.coords[dim], dtype=float).tolist() - for dim in transform.dims - if dim in transform.coords - }, - "attrs": json.loads(json.dumps(transform.attrs)), - } - - -def _deserialize_bspline_dataarray(payload: BSplineDataArrayPayload) -> xr.DataArray: - """Reconstruct a B-spline DataArray from its JSON payload.""" - dims = [str(dim) for dim in payload["dims"]] - coords = { - str(dim): xr.DataArray(np.asarray(values, dtype=float), dims=[str(dim)]) - for dim, values in payload["coords"].items() - } - transform = xr.DataArray( - np.asarray(payload["data"], dtype=float), - dims=dims, - coords=coords, - attrs=dict(payload["attrs"]), - ) - validate_bspline_dataarray(transform) - return transform - - -def make_bspline_transform_payload( - transform: "xr.DataArray", - *, - reference: "xr.DataArray", - source_layer_name: str, - target_layer_name: str, - operation: str, - transform_model: str, - metric: str, - diagnostics: "RegistrationDiagnostics", - name: str | None = None, -) -> BSplineTransformPayload: - """Build a JSON-serializable payload for a registered B-spline transform. - - Parameters - ---------- - transform : xarray.DataArray - B-spline control-point grid. - reference : xarray.DataArray - Fixed/reference DataArray defining the output resampling grid. - source_layer_name : str - Name of the moving/source layer used when estimating the transform. - target_layer_name : str - Name of the fixed/target layer used when estimating the transform. - operation : str - Registration operation that produced the transform. - transform_model : str - Transform model used during registration. - metric : str - Similarity metric used during registration. - diagnostics : confusius.registration.RegistrationDiagnostics - Per-call registration diagnostics. - name : str, optional - Human-friendly transform name. If not provided, a default name is generated. - - Returns - ------- - BSplineTransformPayload - JSON-serializable B-spline transform payload. - """ - payload_name = ( - name or f"{source_layer_name} → {target_layer_name} ({transform_model})" - ) - return { - "kind": "bspline", - "name": payload_name, - "bspline": _serialize_bspline_dataarray(transform), - "source_layer_name": source_layer_name, - "target_layer_name": target_layer_name, - "operation": operation, - "transform_model": transform_model, - "metric": metric, - "output_grid": make_output_grid_payload(reference), - "diagnostics": _make_diagnostics_payload(diagnostics), - } - - -def get_affine_transform_from_payload( - payload: "Mapping[str, object]", -) -> npt.NDArray[np.float64]: - """Return the affine matrix stored in a payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - (N+1, N+1) numpy.ndarray - Affine matrix. - """ - if payload.get("kind") != "affine": - raise ValueError("Transform payload is not an affine transform.") - - affine = np.asarray(payload.get("affine"), dtype=float) - if affine.ndim != 2 or affine.shape[0] != affine.shape[1] or affine.shape[0] < 3: - raise ValueError( - "Affine payload must contain a square homogeneous matrix of shape " - "(N+1, N+1)." - ) - return affine - - -def get_bspline_transform_from_payload(payload: "Mapping[str, object]") -> xr.DataArray: - """Return the B-spline transform stored in a payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - xarray.DataArray - B-spline control-point grid. - """ - if payload.get("kind") != "bspline": - raise ValueError("Transform payload is not a B-spline transform.") - - bspline = payload.get("bspline") - if not isinstance(bspline, dict): - raise ValueError("B-spline payload must contain a serialized DataArray.") - return _deserialize_bspline_dataarray(cast("BSplineDataArrayPayload", bspline)) - - -def get_output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: - """Return the output grid stored in a transform payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - OutputGridPayload - Output-grid description stored in the payload. - """ - grid = payload.get("output_grid") - if not isinstance(grid, dict): - raise ValueError("Transform payload does not contain an output grid.") - - grid_dict = cast("dict[str, object]", grid) - dims = grid_dict.get("dims") - shape = grid_dict.get("shape") - spacing = grid_dict.get("spacing") - origin = grid_dict.get("origin") - units = grid_dict.get("units") - if not all(isinstance(v, list) for v in (dims, shape, spacing, origin, units)): - raise ValueError("Transform payload output grid is malformed.") - - dims_list = cast("list[object]", dims) - shape_list = cast("list[SupportsIndex]", shape) - spacing_list = cast("list[SupportsFloat]", spacing) - origin_list = cast("list[SupportsFloat]", origin) - units_list = cast("list[object]", units) - - return { - "dims": [str(v) for v in dims_list], - "shape": [int(v) for v in shape_list], - "spacing": [float(v) for v in spacing_list], - "origin": [float(v) for v in origin_list], - "units": [None if v is None else str(v) for v in units_list], - } - - -def _save_bspline_transform_payload( - path: str | Path, payload: BSplineTransformPayload -) -> None: - """Save a B-spline transform payload as Zarr. - - Parameters - ---------- - path : str or pathlib.Path - Output Zarr path. - payload : BSplineTransformPayload - Transform payload to save. - """ - path = Path(path) - if path.suffix != ".zarr": - raise ValueError("B-spline transform files must have .zarr extension.") - - transform = get_bspline_transform_from_payload(payload) - ds = transform.to_dataset(name="bspline_transform") - payload_metadata = { - key: value for key, value in payload.items() if key not in {"kind", "bspline"} - } - ds.attrs["confusius_transform_kind"] = "bspline" - ds.attrs["confusius_transform_payload_json"] = json.dumps(payload_metadata) - ds.to_zarr(path, mode="w") - - -def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload: - """Load a B-spline transform payload from Zarr. - - Parameters - ---------- - path : str or pathlib.Path - Input Zarr path. - - Returns - ------- - BSplineTransformPayload - Loaded B-spline transform payload. - """ - ds = xr.open_zarr(path) - try: - if ds.attrs.get("confusius_transform_kind") != "bspline": - raise ValueError( - "Zarr transform store does not contain a ConfUSIus B-spline transform." - ) - payload_metadata = json.loads( - cast("str", ds.attrs["confusius_transform_payload_json"]) - ) - if not isinstance(payload_metadata, dict): - raise ValueError("Stored transform payload metadata is malformed.") - transform = ds["bspline_transform"].load() - finally: - ds.close() - - validate_bspline_dataarray(transform) - payload: BSplineTransformPayload = { - "kind": "bspline", - "bspline": _serialize_bspline_dataarray(transform), - "name": str(payload_metadata["name"]), - "source_layer_name": str(payload_metadata["source_layer_name"]), - "target_layer_name": str(payload_metadata["target_layer_name"]), - "operation": str(payload_metadata["operation"]), - "transform_model": str(payload_metadata["transform_model"]), - "metric": str(payload_metadata["metric"]), - "output_grid": get_output_grid_from_payload(payload_metadata), - "diagnostics": cast( - "TransformDiagnosticsPayload", payload_metadata["diagnostics"] - ), - } - return payload - - -def save_transform_payload(path: str | Path, payload: TransformPayload) -> None: - """Save a transform payload to disk. - - Parameters - ---------- - path : str or pathlib.Path - Output path. - payload : TransformPayload - Transform payload to save. - - Notes - ----- - Affine payloads are saved as JSON. B-spline payloads are saved as Zarr. - """ - if payload["kind"] == "affine": - Path(path).write_text(json.dumps(payload, indent=2) + "\n") - return - _save_bspline_transform_payload(path, payload) - - -def load_transform_payload(path: str | Path) -> TransformPayload: - """Load an affine or B-spline transform payload from disk. - - Parameters - ---------- - path : str or pathlib.Path - Input path. - - Returns - ------- - TransformPayload - Loaded transform payload. - """ - path = Path(path) - if path.suffix == ".zarr": - return _load_bspline_transform_payload(path) - - payload = json.loads(path.read_text()) - if not isinstance(payload, dict): - raise ValueError("Transform file must contain a JSON object.") - - kind = payload.get("kind") - if kind != "affine": - raise ValueError( - "JSON transform files currently support affine payloads only. " - "Use .zarr for B-spline transforms." - ) - get_affine_transform_from_payload(payload) - get_output_grid_from_payload(payload) - return cast("TransformPayload", payload) - - -def _get_affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: - """Return the stored affine transform payload for a napari layer. - - Parameters - ---------- - layer : napari.layers.Layer - Layer whose metadata should be inspected. - - Returns - ------- - AffineTransformPayload or None - Stored payload when present and affine, otherwise `None`. - """ - payload = layer.metadata.get("confusius_transform") - if not isinstance(payload, dict) or payload.get("kind") != "affine": - return None - get_affine_transform_from_payload(payload) - return cast("AffineTransformPayload", payload) - - -def _get_spatial_manual_affine_from_layer( - layer: "Layer", *, spatial_dims: Sequence[str] -) -> npt.NDArray[np.float64]: - """Return the spatial sub-affine from a napari layer's manual transform. - - Parameters - ---------- - layer : napari.layers.Layer - Layer whose manual napari affine should be extracted. - spatial_dims : sequence of str - Spatial dimension names, in the exact order expected by registration. - - Returns - ------- - (N+1, N+1) numpy.ndarray - Spatial homogeneous affine in world coordinates. - - Raises - ------ - ValueError - If the layer does not contain the requested spatial dimensions. - ValueError - If the layer affine has an unexpected shape. - ValueError - If the manual affine mixes selected spatial axes with ignored axes. - """ - data = _get_source_dataarray(layer) - layer_dims = [str(dim) for dim in data.dims] - missing_dims = [dim for dim in spatial_dims if dim not in layer_dims] - if missing_dims: - raise ValueError( - "Selected manual napari transform does not contain spatial dims " - f"{missing_dims}." - ) - - affine = np.asarray(layer.affine.affine_matrix, dtype=float) - expected_shape = (len(layer_dims) + 1, len(layer_dims) + 1) - if affine.shape != expected_shape: - raise ValueError( - f"Selected manual napari transform has shape {affine.shape}, " - f"but layer '{layer.name}' expects {expected_shape}." - ) - - spatial_indices = [layer_dims.index(dim) for dim in spatial_dims] - ignored_indices = [i for i in range(len(layer_dims)) if i not in spatial_indices] - linear = affine[:-1, :-1] - - if ignored_indices: - spatial_to_ignored = linear[np.ix_(spatial_indices, ignored_indices)] - ignored_to_spatial = linear[np.ix_(ignored_indices, spatial_indices)] - if not np.allclose(spatial_to_ignored, 0.0) or not np.allclose( - ignored_to_spatial, 0.0 - ): - raise ValueError( - "Selected manual napari transform mixes spatial axes with ignored " - "non-spatial axes, so it cannot be used as a registration " - "initialization." - ) - - spatial_affine = np.eye(len(spatial_dims) + 1, dtype=float) - spatial_affine[:-1, :-1] = linear[np.ix_(spatial_indices, spatial_indices)] - spatial_affine[:-1, -1] = affine[np.ix_(spatial_indices, [-1])].ravel() - return spatial_affine - - -def _make_manual_transform_payload(layer: "Layer") -> AffineTransformPayload: - """Build an affine payload from a layer's manual napari transform. - - Parameters - ---------- - layer : napari.layers.Layer - Layer whose current manual napari transform should be serialized. - - Returns - ------- - AffineTransformPayload - JSON-serializable affine payload representing the visible manual layer - transform on the layer's own spatial output grid. - """ - data = _get_source_dataarray(layer) - spatial_data = _prepare_between_scan_data(data) - spatial_dims = [str(dim) for dim in spatial_data.dims if dim in SPATIAL_DIMS] - manual_affine = _get_spatial_manual_affine_from_layer( - layer, spatial_dims=spatial_dims - ) - pull_affine = np.linalg.inv(manual_affine) - return { - "kind": "affine", - "name": f"{layer.name} (manual)", - "affine": pull_affine.tolist(), - "source_layer_name": layer.name, - "target_layer_name": layer.name, - "operation": "manual_napari_transform", - "transform_model": "affine", - "metric": "manual", - "output_grid": { - "dims": [str(dim) for dim in spatial_data.dims], - "shape": [int(spatial_data.sizes[dim]) for dim in spatial_data.dims], - "spacing": [ - float(spatial_data.fusi.spacing[dim]) for dim in spatial_data.dims - ], - "origin": [ - float(spatial_data.fusi.origin[dim]) for dim in spatial_data.dims - ], - "units": [ - cast("str | None", spatial_data.coords[dim].attrs.get("units")) - if dim in spatial_data.coords - else None - for dim in spatial_data.dims - ], - }, - "diagnostics": { - "metric": "manual", - "final_metric_value": 0.0, - "n_iterations": 0, - "stop_condition": "Saved from manual napari layer transform.", - "status": "completed", - }, - } diff --git a/src/confusius/_napari/_registration/_panel_transforms.py b/src/confusius/_napari/_registration/_panel_transforms.py new file mode 100644 index 00000000..17bc16f0 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel_transforms.py @@ -0,0 +1,1311 @@ +"""Transform payload and panel-specific transform helpers for the napari registration panel.""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Literal, + SupportsFloat, + SupportsIndex, + TypedDict, + cast, +) + +import numpy as np +import numpy.typing as npt +import xarray as xr +from napari.qt.threading import thread_worker +from napari.utils.notifications import show_error, show_info +from qtpy.QtWidgets import QFileDialog + +from confusius._dims import SPATIAL_DIMS +from confusius._napari._registration._panel_utils import ( + _get_source_dataarray, + _prepare_between_scan_data, +) +from confusius.registration import resample_volume +from confusius.registration.bspline import validate_bspline_dataarray + +if TYPE_CHECKING: + from collections.abc import Mapping + + from napari.layers import Layer + + from confusius._napari._registration._panel import ( + ApplyTransformPayload, + RegistrationPanel, + TransformSourceData, + ) + from confusius.registration import RegistrationDiagnostics + + +class TransformDiagnosticsPayload(TypedDict): + """JSON-serializable registration diagnostics summary.""" + + metric: str + final_metric_value: float + n_iterations: int + stop_condition: str + status: str + + +class OutputGridPayload(TypedDict): + """JSON-serializable resampling grid description.""" + + dims: list[str] + shape: list[int] + spacing: list[float] + origin: list[float] + units: list[str | None] + + +class BSplineDataArrayPayload(TypedDict): + """JSON-serializable B-spline control-point DataArray.""" + + dims: list[str] + data: list[object] + coords: dict[str, list[float]] + attrs: dict[str, object] + + +class AffineTransformPayload(TypedDict): + """JSON-serializable affine transform payload used by the napari plugin.""" + + kind: Literal["affine"] + name: str + affine: list[list[float]] + source_layer_name: str + target_layer_name: str + operation: str + transform_model: str + metric: str + output_grid: OutputGridPayload + diagnostics: TransformDiagnosticsPayload + + +class BSplineTransformPayload(TypedDict): + """B-spline transform payload used by the napari plugin.""" + + kind: Literal["bspline"] + name: str + bspline: BSplineDataArrayPayload + source_layer_name: str + target_layer_name: str + operation: str + transform_model: str + metric: str + output_grid: OutputGridPayload + diagnostics: TransformDiagnosticsPayload + + +TransformPayload = AffineTransformPayload | BSplineTransformPayload +"""Union of affine and B-spline transform payloads.""" + + +def make_output_grid_payload(reference: "xr.DataArray") -> OutputGridPayload: + """Return the resampling grid defined by a reference DataArray. + + Parameters + ---------- + reference : xarray.DataArray + Spatial DataArray defining the output grid. + + Returns + ------- + OutputGridPayload + JSON-serializable output-grid description. + """ + dims = [str(dim) for dim in reference.dims] + return { + "dims": dims, + "shape": [int(reference.sizes[dim]) for dim in dims], + "spacing": [float(reference.fusi.spacing[dim]) for dim in dims], + "origin": [float(reference.fusi.origin[dim]) for dim in dims], + "units": [ + cast("str | None", reference.coords[dim].attrs.get("units")) + if dim in reference.coords + else None + for dim in dims + ], + } + + +def _make_diagnostics_payload( + diagnostics: "RegistrationDiagnostics", +) -> TransformDiagnosticsPayload: + """Return a JSON-serializable diagnostics summary. + + Parameters + ---------- + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics to serialize. + + Returns + ------- + TransformDiagnosticsPayload + JSON-serializable diagnostics summary. + """ + return { + "metric": diagnostics.metric, + "final_metric_value": float(diagnostics.final_metric_value), + "n_iterations": int(diagnostics.n_iterations), + "stop_condition": diagnostics.stop_condition, + "status": diagnostics.status, + } + + +def make_affine_transform_payload( + affine: npt.NDArray[np.floating], + *, + reference: "xr.DataArray", + source_layer_name: str, + target_layer_name: str, + operation: str, + transform_model: str, + metric: str, + diagnostics: "RegistrationDiagnostics", + name: str | None = None, +) -> AffineTransformPayload: + """Build a JSON-serializable payload for a registered affine transform. + + Parameters + ---------- + affine : (N+1, N+1) numpy.ndarray + Affine transform in homogeneous coordinates. + reference : xarray.DataArray + Fixed/reference DataArray defining the output resampling grid. + source_layer_name : str + Name of the moving/source layer used when estimating the transform. + target_layer_name : str + Name of the fixed/target layer used when estimating the transform. + operation : str + Registration operation that produced the transform. + transform_model : str + Transform model used during registration. + metric : str + Similarity metric used during registration. + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics. + name : str, optional + Human-friendly transform name. If not provided, a default name is generated. + + Returns + ------- + AffineTransformPayload + JSON-serializable affine transform payload. + """ + affine = np.asarray(affine, dtype=float) + payload_name = ( + name or f"{source_layer_name} → {target_layer_name} ({transform_model})" + ) + return { + "kind": "affine", + "name": payload_name, + "affine": affine.tolist(), + "source_layer_name": source_layer_name, + "target_layer_name": target_layer_name, + "operation": operation, + "transform_model": transform_model, + "metric": metric, + "output_grid": make_output_grid_payload(reference), + "diagnostics": _make_diagnostics_payload(diagnostics), + } + + +def _serialize_bspline_dataarray(transform: "xr.DataArray") -> BSplineDataArrayPayload: + """Return a JSON-serializable B-spline DataArray payload. + + Parameters + ---------- + transform : xarray.DataArray + B-spline control-point grid to serialize. + + Returns + ------- + BSplineDataArrayPayload + JSON-serializable B-spline DataArray payload. + """ + validate_bspline_dataarray(transform) + return { + "dims": [str(dim) for dim in transform.dims], + "data": np.asarray(transform, dtype=float).tolist(), + "coords": { + str(dim): np.asarray(transform.coords[dim], dtype=float).tolist() + for dim in transform.dims + if dim in transform.coords + }, + "attrs": json.loads(json.dumps(transform.attrs)), + } + + +def _deserialize_bspline_dataarray(payload: BSplineDataArrayPayload) -> xr.DataArray: + """Reconstruct a B-spline DataArray from its JSON payload. + + Parameters + ---------- + payload : BSplineDataArrayPayload + JSON payload describing a B-spline control-point grid. + + Returns + ------- + xarray.DataArray + Reconstructed B-spline control-point grid. + """ + dims = [str(dim) for dim in payload["dims"]] + coords = { + str(dim): xr.DataArray(np.asarray(values, dtype=float), dims=[str(dim)]) + for dim, values in payload["coords"].items() + } + transform = xr.DataArray( + np.asarray(payload["data"], dtype=float), + dims=dims, + coords=coords, + attrs=dict(payload["attrs"]), + ) + validate_bspline_dataarray(transform) + return transform + + +def make_bspline_transform_payload( + transform: "xr.DataArray", + *, + reference: "xr.DataArray", + source_layer_name: str, + target_layer_name: str, + operation: str, + transform_model: str, + metric: str, + diagnostics: "RegistrationDiagnostics", + name: str | None = None, +) -> BSplineTransformPayload: + """Build a JSON-serializable payload for a registered B-spline transform. + + Parameters + ---------- + transform : xarray.DataArray + B-spline control-point grid. + reference : xarray.DataArray + Fixed/reference DataArray defining the output resampling grid. + source_layer_name : str + Name of the moving/source layer used when estimating the transform. + target_layer_name : str + Name of the fixed/target layer used when estimating the transform. + operation : str + Registration operation that produced the transform. + transform_model : str + Transform model used during registration. + metric : str + Similarity metric used during registration. + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics. + name : str, optional + Human-friendly transform name. If not provided, a default name is generated. + + Returns + ------- + BSplineTransformPayload + JSON-serializable B-spline transform payload. + """ + payload_name = ( + name or f"{source_layer_name} → {target_layer_name} ({transform_model})" + ) + return { + "kind": "bspline", + "name": payload_name, + "bspline": _serialize_bspline_dataarray(transform), + "source_layer_name": source_layer_name, + "target_layer_name": target_layer_name, + "operation": operation, + "transform_model": transform_model, + "metric": metric, + "output_grid": make_output_grid_payload(reference), + "diagnostics": _make_diagnostics_payload(diagnostics), + } + + +def get_affine_transform_from_payload( + payload: "Mapping[str, object]", +) -> npt.NDArray[np.float64]: + """Return the affine matrix stored in a payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + (N+1, N+1) numpy.ndarray + Affine matrix. + """ + if payload.get("kind") != "affine": + raise ValueError("Transform payload is not an affine transform.") + + affine = np.asarray(payload.get("affine"), dtype=float) + if affine.ndim != 2 or affine.shape[0] != affine.shape[1] or affine.shape[0] < 3: + raise ValueError( + "Affine payload must contain a square homogeneous matrix of shape " + "(N+1, N+1)." + ) + return affine + + +def get_bspline_transform_from_payload(payload: "Mapping[str, object]") -> xr.DataArray: + """Return the B-spline transform stored in a payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + xarray.DataArray + B-spline control-point grid. + """ + if payload.get("kind") != "bspline": + raise ValueError("Transform payload is not a B-spline transform.") + + bspline = payload.get("bspline") + if not isinstance(bspline, dict): + raise ValueError("B-spline payload must contain a serialized DataArray.") + return _deserialize_bspline_dataarray(cast("BSplineDataArrayPayload", bspline)) + + +def get_output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: + """Return the output grid stored in a transform payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + OutputGridPayload + Output-grid description stored in the payload. + """ + grid = payload.get("output_grid") + if not isinstance(grid, dict): + raise ValueError("Transform payload does not contain an output grid.") + + grid_dict = cast("dict[str, object]", grid) + dims = grid_dict.get("dims") + shape = grid_dict.get("shape") + spacing = grid_dict.get("spacing") + origin = grid_dict.get("origin") + units = grid_dict.get("units") + if not all(isinstance(v, list) for v in (dims, shape, spacing, origin, units)): + raise ValueError("Transform payload output grid is malformed.") + + dims_list = cast("list[object]", dims) + shape_list = cast("list[SupportsIndex]", shape) + spacing_list = cast("list[SupportsFloat]", spacing) + origin_list = cast("list[SupportsFloat]", origin) + units_list = cast("list[object]", units) + + return { + "dims": [str(v) for v in dims_list], + "shape": [int(v) for v in shape_list], + "spacing": [float(v) for v in spacing_list], + "origin": [float(v) for v in origin_list], + "units": [None if v is None else str(v) for v in units_list], + } + + +def _save_bspline_transform_payload( + path: str | Path, payload: BSplineTransformPayload +) -> None: + """Save a B-spline transform payload as Zarr. + + Parameters + ---------- + path : str or pathlib.Path + Output Zarr path. + payload : BSplineTransformPayload + Transform payload to save. + + Raises + ------ + ValueError + If `path` does not have a `.zarr` extension. + """ + path = Path(path) + if path.suffix != ".zarr": + raise ValueError("B-spline transform files must have .zarr extension.") + + transform = get_bspline_transform_from_payload(payload) + ds = transform.to_dataset(name="bspline_transform") + payload_metadata = { + key: value for key, value in payload.items() if key not in {"kind", "bspline"} + } + ds.attrs["confusius_transform_kind"] = "bspline" + ds.attrs["confusius_transform_payload_json"] = json.dumps(payload_metadata) + ds.to_zarr(path, mode="w") + + +def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload: + """Load a B-spline transform payload from Zarr. + + Parameters + ---------- + path : str or pathlib.Path + Input Zarr path. + + Returns + ------- + BSplineTransformPayload + Loaded B-spline transform payload. + """ + ds = xr.open_zarr(path) + try: + if ds.attrs.get("confusius_transform_kind") != "bspline": + raise ValueError( + "Zarr transform store does not contain a ConfUSIus B-spline transform." + ) + payload_metadata = json.loads( + cast("str", ds.attrs["confusius_transform_payload_json"]) + ) + if not isinstance(payload_metadata, dict): + raise ValueError("Stored transform payload metadata is malformed.") + transform = ds["bspline_transform"].load() + finally: + ds.close() + + validate_bspline_dataarray(transform) + payload: BSplineTransformPayload = { + "kind": "bspline", + "bspline": _serialize_bspline_dataarray(transform), + "name": str(payload_metadata["name"]), + "source_layer_name": str(payload_metadata["source_layer_name"]), + "target_layer_name": str(payload_metadata["target_layer_name"]), + "operation": str(payload_metadata["operation"]), + "transform_model": str(payload_metadata["transform_model"]), + "metric": str(payload_metadata["metric"]), + "output_grid": get_output_grid_from_payload(payload_metadata), + "diagnostics": cast( + "TransformDiagnosticsPayload", payload_metadata["diagnostics"] + ), + } + return payload + + +def save_transform_payload(path: str | Path, payload: TransformPayload) -> None: + """Save a transform payload to disk. + + Parameters + ---------- + path : str or pathlib.Path + Output path. + payload : TransformPayload + Transform payload to save. + + Notes + ----- + Affine payloads are saved as JSON. B-spline payloads are saved as Zarr. + """ + if payload["kind"] == "affine": + Path(path).write_text(json.dumps(payload, indent=2) + "\n") + return + _save_bspline_transform_payload(path, payload) + + +def load_transform_payload(path: str | Path) -> TransformPayload: + """Load an affine or B-spline transform payload from disk. + + Parameters + ---------- + path : str or pathlib.Path + Input path. + + Returns + ------- + TransformPayload + Loaded transform payload. + """ + path = Path(path) + if path.suffix == ".zarr": + return _load_bspline_transform_payload(path) + + payload = json.loads(path.read_text()) + if not isinstance(payload, dict): + raise ValueError("Transform file must contain a JSON object.") + + kind = payload.get("kind") + if kind != "affine": + raise ValueError( + "JSON transform files currently support affine payloads only. " + "Use .zarr for B-spline transforms." + ) + get_affine_transform_from_payload(payload) + get_output_grid_from_payload(payload) + return cast("TransformPayload", payload) + + +def _get_affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: + """Return the stored affine transform payload for a napari layer. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose metadata should be inspected. + + Returns + ------- + AffineTransformPayload or None + Stored payload when present and affine, otherwise `None`. + """ + payload = layer.metadata.get("confusius_transform") + if not isinstance(payload, dict) or payload.get("kind") != "affine": + return None + get_affine_transform_from_payload(payload) + return cast("AffineTransformPayload", payload) + + +def _get_spatial_manual_affine_from_layer( + layer: "Layer", *, spatial_dims: Sequence[str] +) -> npt.NDArray[np.float64]: + """Return the spatial sub-affine from a napari layer's manual transform. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose manual napari affine should be extracted. + spatial_dims : sequence of str + Spatial dimension names, in the exact order expected by registration. + + Returns + ------- + (N+1, N+1) numpy.ndarray + Spatial homogeneous affine in world coordinates. + + Raises + ------ + ValueError + If the layer does not contain the requested spatial dimensions. + ValueError + If the layer affine has an unexpected shape. + ValueError + If the manual affine mixes selected spatial axes with ignored axes. + """ + data = _get_source_dataarray(layer) + layer_dims = [str(dim) for dim in data.dims] + missing_dims = [dim for dim in spatial_dims if dim not in layer_dims] + if missing_dims: + raise ValueError( + "Selected manual napari transform does not contain spatial dims " + f"{missing_dims}." + ) + + affine = np.asarray(layer.affine.affine_matrix, dtype=float) + expected_shape = (len(layer_dims) + 1, len(layer_dims) + 1) + if affine.shape != expected_shape: + raise ValueError( + f"Selected manual napari transform has shape {affine.shape}, " + f"but layer '{layer.name}' expects {expected_shape}." + ) + + spatial_indices = [layer_dims.index(dim) for dim in spatial_dims] + ignored_indices = [i for i in range(len(layer_dims)) if i not in spatial_indices] + linear = affine[:-1, :-1] + + if ignored_indices: + spatial_to_ignored = linear[np.ix_(spatial_indices, ignored_indices)] + ignored_to_spatial = linear[np.ix_(ignored_indices, spatial_indices)] + if not np.allclose(spatial_to_ignored, 0.0) or not np.allclose( + ignored_to_spatial, 0.0 + ): + raise ValueError( + "Selected manual napari transform mixes spatial axes with ignored " + "non-spatial axes, so it cannot be used as a registration " + "initialization." + ) + + spatial_affine = np.eye(len(spatial_dims) + 1, dtype=float) + spatial_affine[:-1, :-1] = linear[np.ix_(spatial_indices, spatial_indices)] + spatial_affine[:-1, -1] = affine[np.ix_(spatial_indices, [-1])].ravel() + return spatial_affine + + +def _make_manual_transform_payload(layer: "Layer") -> AffineTransformPayload: + """Build an affine payload from a layer's manual napari transform. + + Parameters + ---------- + layer : napari.layers.Layer + Layer whose current manual napari transform should be serialized. + + Returns + ------- + AffineTransformPayload + JSON-serializable affine payload representing the visible manual layer transform + on the layer's own spatial output grid. + """ + data = _get_source_dataarray(layer) + spatial_data = _prepare_between_scan_data(data) + spatial_dims = [str(dim) for dim in spatial_data.dims if dim in SPATIAL_DIMS] + manual_affine = _get_spatial_manual_affine_from_layer( + layer, spatial_dims=spatial_dims + ) + pull_affine = np.linalg.inv(manual_affine) + return { + "kind": "affine", + "name": f"{layer.name} (manual)", + "affine": pull_affine.tolist(), + "source_layer_name": layer.name, + "target_layer_name": layer.name, + "operation": "manual_napari_transform", + "transform_model": "affine", + "metric": "manual", + "output_grid": { + "dims": [str(dim) for dim in spatial_data.dims], + "shape": [int(spatial_data.sizes[dim]) for dim in spatial_data.dims], + "spacing": [ + float(spatial_data.fusi.spacing[dim]) for dim in spatial_data.dims + ], + "origin": [ + float(spatial_data.fusi.origin[dim]) for dim in spatial_data.dims + ], + "units": [ + cast("str | None", spatial_data.coords[dim].attrs.get("units")) + if dim in spatial_data.coords + else None + for dim in spatial_data.dims + ], + }, + "diagnostics": { + "metric": "manual", + "final_metric_value": 0.0, + "n_iterations": 0, + "stop_condition": "Saved from manual napari layer transform.", + "status": "completed", + }, + } + + +def get_transform_source_data(value: object) -> "TransformSourceData | None": + """Return validated transform-source combo data. + + Parameters + ---------- + value : object + Raw combo-box payload to validate. + + Returns + ------- + tuple[str, str] or None + Validated `(kind, name)` pair, or `None` when the payload does not match the + expected transform-source schema. + """ + if not isinstance(value, tuple) or len(value) != 2: + return None + source_kind, source_name = value + if not isinstance(source_name, str): + return None + if source_kind == "loaded": + return ("loaded", source_name) + if source_kind == "layer": + return ("layer", source_name) + if source_kind == "manual": + return ("manual", source_name) + return None + + +def get_transform_payload_from_metadata(payload: object) -> TransformPayload | None: + """Return a validated transform payload stored in layer metadata. + + Parameters + ---------- + payload : object + Raw metadata payload to validate. + + Returns + ------- + TransformPayload or None + Validated transform payload, or `None` when the metadata does not contain a + supported transform payload. + """ + if not isinstance(payload, dict): + return None + payload_mapping = cast("dict[str, object]", payload) + kind = payload_mapping.get("kind") + if kind == "affine": + get_affine_transform_from_payload(payload_mapping) + return cast("TransformPayload", payload_mapping) + if kind == "bspline": + get_bspline_transform_from_payload(payload_mapping) + return cast("TransformPayload", payload_mapping) + return None + + +def get_transform_source_label( + payload: TransformPayload, *, suffix: str | None = None +) -> str: + """Return a user-facing label for a transform payload. + + Parameters + ---------- + payload : TransformPayload + Transform payload to label. + suffix : str, optional + Unused legacy suffix parameter kept to avoid wider churn. + + Returns + ------- + str + Label shown in transform selectors. + """ + del suffix + return payload["name"] + + +def get_available_transform_payloads( + panel: "RegistrationPanel", +) -> list[TransformPayload]: + """Return all transform payloads currently available in the UI. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose loaded payload and viewer layers are queried. + + Returns + ------- + list of TransformPayload + Loaded payload plus any validated payloads found on viewer layers. + """ + payloads: list[TransformPayload] = [] + if panel._loaded_transform_payload is not None: + payloads.append(panel._loaded_transform_payload) + for layer in panel.viewer.layers: + payload = get_transform_payload_from_metadata( + layer.metadata.get("confusius_transform") + ) + if payload is not None: + payloads.append(payload) + return payloads + + +def refresh_transform_controls(panel: "RegistrationPanel") -> None: + """Refresh transform-related layer selectors. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose transform selectors are updated. + + Returns + ------- + None + Updates transform, initialization, and target selectors in place. + """ + source_data = panel._transform_source_combo.currentData() + initialization_data = panel._initialization_combo.currentData() + target_name = panel._transform_target_combo.currentText() + + transform_options: list[tuple[str, tuple[str, str]]] = [] + if panel._loaded_transform_payload is not None: + transform_options.append( + ( + get_transform_source_label( + panel._loaded_transform_payload, + suffix="loaded", + ), + ("loaded", ""), + ) + ) + for layer in panel.viewer.layers: + payload = get_transform_payload_from_metadata( + layer.metadata.get("confusius_transform") + ) + if payload is None: + continue + transform_options.append( + ( + get_transform_source_label(payload, suffix=layer.name), + ("layer", layer.name), + ) + ) + + manual_transform_options: list[tuple[str, tuple[str, str]]] = [] + manual_initialization_options: list[tuple[str, tuple[str, str]]] = [] + for layer in panel.viewer.layers: + try: + data = _get_source_dataarray(layer) + spatial_dims = [str(dim) for dim in data.dims if dim in SPATIAL_DIMS] + if not spatial_dims: + continue + manual_affine = _get_spatial_manual_affine_from_layer( + layer, + spatial_dims=spatial_dims, + ) + except Exception: # noqa: BLE001 + continue + if np.allclose(manual_affine, np.eye(len(spatial_dims) + 1)): + continue + manual_option = (f"{layer.name} (manual)", ("manual", layer.name)) + manual_transform_options.append(manual_option) + manual_initialization_options.append(manual_option) + + panel._transform_source_combo.blockSignals(True) + panel._transform_source_combo.clear() + for label, data in transform_options: + panel._transform_source_combo.addItem(label, data) + for label, data in manual_transform_options: + panel._transform_source_combo.addItem(label, data) + panel._transform_source_combo.blockSignals(False) + + panel._initialization_combo.blockSignals(True) + panel._initialization_combo.clear() + panel._initialization_combo.addItem("center_geometry", "center_geometry") + panel._initialization_combo.addItem("center_moments", "center_moments") + panel._initialization_combo.addItem("none", None) + for label, data in transform_options: + source_kind, source_name = data + if source_kind == "loaded": + if panel._loaded_transform_payload is None: + continue + if panel._loaded_transform_payload["kind"] != "affine": + continue + elif source_kind == "layer": + layer = panel._get_layer_by_name(source_name) + if layer is None or _get_affine_payload_from_layer(layer) is None: + continue + panel._initialization_combo.addItem(label, data) + for label, data in manual_initialization_options: + panel._initialization_combo.addItem(label, data) + panel._initialization_combo.blockSignals(False) + + panel._transform_target_combo.blockSignals(True) + panel._transform_target_combo.clear() + panel._transform_target_combo.addItems( + [layer.name for layer in panel.viewer.layers] + ) + panel._transform_target_combo.blockSignals(False) + + if source_data is not None: + for i in range(panel._transform_source_combo.count()): + if panel._transform_source_combo.itemData(i) == source_data: + panel._transform_source_combo.setCurrentIndex(i) + break + + if initialization_data is not None: + for i in range(panel._initialization_combo.count()): + if panel._initialization_combo.itemData(i) == initialization_data: + panel._initialization_combo.setCurrentIndex(i) + break + + target_index = panel._transform_target_combo.findText(target_name) + if target_index >= 0: + panel._transform_target_combo.setCurrentIndex(target_index) + + +def get_selected_transform_payload( + panel: "RegistrationPanel", +) -> TransformPayload | None: + """Return the currently selected transform payload. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose transform selection is read. + + Returns + ------- + TransformPayload or None + Selected transform payload, or `None` when no valid selection is available. + """ + source_data = get_transform_source_data(panel._transform_source_combo.currentData()) + if source_data is None: + return None + + source_kind, source_name = source_data + if source_kind == "loaded": + return panel._loaded_transform_payload + if not source_name: + return None + layer = panel._get_layer_by_name(source_name) + if layer is None: + return None + if source_kind == "layer": + return get_transform_payload_from_metadata( + layer.metadata.get("confusius_transform") + ) + if source_kind == "manual": + return _make_manual_transform_payload(layer) + return None + + +def get_selected_center_initialization( + panel: "RegistrationPanel", +) -> Literal["center_geometry", "center_moments"] | None: + """Return the selected built-in centering initialization, if any. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose initialization selector is read. + + Returns + ------- + {"center_geometry", "center_moments"} or None + Selected built-in centering initialization, or `None` when a different kind of + initialization is currently selected. + """ + value = panel._initialization_combo.currentData() + if value in {"center_geometry", "center_moments"}: + return value + return None + + +def get_selected_initial_transform_payload( + panel: "RegistrationPanel", +) -> AffineTransformPayload | None: + """Return the payload selected for registration initialization, if any. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose initialization selector is read. + + Returns + ------- + AffineTransformPayload or None + Selected affine transform payload, or `None` when no affine payload is currently + selected for initialization. + """ + source_data = get_transform_source_data(panel._initialization_combo.currentData()) + if source_data is None: + return None + + source_kind, source_name = source_data + if source_kind == "loaded": + if ( + panel._loaded_transform_payload is not None + and panel._loaded_transform_payload["kind"] == "affine" + ): + return panel._loaded_transform_payload + return None + if source_kind != "layer" or not source_name: + return None + layer = panel._get_layer_by_name(source_name) + if layer is None: + return None + return _get_affine_payload_from_layer(layer) + + +def get_selected_manual_initialization_layer( + panel: "RegistrationPanel", +) -> "Layer | None": + """Return the layer selected for manual napari initialization, if any. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose initialization selector is read. + + Returns + ------- + napari.layers.Layer or None + Layer selected as the manual napari initialization source, or `None` when no + manual initialization is currently selected. + """ + source_data = get_transform_source_data(panel._initialization_combo.currentData()) + if source_data is None: + return None + + source_kind, source_name = source_data + if source_kind != "manual" or not source_name: + return None + return panel._get_layer_by_name(source_name) + + +def get_selected_initial_transform( + panel: "RegistrationPanel", + moving: xr.DataArray, + *, + moving_layer: "Layer | None" = None, + fixed_layer: "Layer | None" = None, +) -> tuple[npt.NDArray[np.float64] | None, str | None]: + """Return the selected initialization affine and its source label. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose initialization selector is read. + moving : xarray.DataArray + Moving DataArray defining the spatial dimensions of the registration. + moving_layer : napari.layers.Layer, optional + Layer used as the moving input; required when a manual initialization is + selected. + fixed_layer : napari.layers.Layer, optional + Layer used as the fixed input; required when a manual initialization is + selected. + + Returns + ------- + affine : (N+1, N+1) numpy.ndarray or None + Selected initialization affine in homogeneous coordinates, or `None` when no + initialization is selected. + label : str or None + Human-readable label for the selected initialization source, or `None` when no + initialization is selected. + + Raises + ------ + ValueError + If a manual initialization is selected but the moving and fixed layers are not + provided, or the selected manual layer is not the current moving or fixed layer. + """ + payload = get_selected_initial_transform_payload(panel) + if payload is not None: + return get_affine_transform_from_payload(payload), payload["name"] + + layer = get_selected_manual_initialization_layer(panel) + if layer is None: + return None, None + if moving_layer is None or fixed_layer is None: + raise ValueError("Select moving and fixed layers.") + if layer not in {moving_layer, fixed_layer}: + raise ValueError( + "Selected manual initialization must come from the current moving " + "or fixed layer." + ) + + spatial_dims = [str(dim) for dim in moving.dims if dim in SPATIAL_DIMS] + moving_affine = _get_spatial_manual_affine_from_layer( + moving_layer, + spatial_dims=spatial_dims, + ) + fixed_affine = _get_spatial_manual_affine_from_layer( + fixed_layer, + spatial_dims=spatial_dims, + ) + affine = np.linalg.inv(moving_affine) @ fixed_affine + return affine, f"{layer.name} (manual)" + + +def validate_initial_transform_selection( + panel: "RegistrationPanel", + *, + operation: Literal["register_volume", "register_volumewise"], + moving: xr.DataArray, + fixed: xr.DataArray | None = None, +) -> str | None: + """Return an inline validation message for transform initialization. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose selection is validated. + operation : {"register_volume", "register_volumewise"} + Registration operation the panel is currently configured to run. + moving : xarray.DataArray + Moving DataArray used to check initialization transform shapes. + fixed : xarray.DataArray, optional + Fixed DataArray; required when an initialization is selected. + + Returns + ------- + str or None + Human-readable validation message, or `None` when the current selection is valid + (or no initialization is selected). + """ + if operation != "register_volume": + return None + if ( + get_selected_initial_transform_payload(panel) is None + and get_selected_manual_initialization_layer(panel) is None + ): + return None + if fixed is None: + return "Select a fixed layer." + + moving_layer = panel._selected_layer(panel._moving_combo) + fixed_layer = panel._selected_layer(panel._fixed_combo) + + try: + affine, _ = get_selected_initial_transform( + panel, + moving, + moving_layer=moving_layer, + fixed_layer=fixed_layer, + ) + except Exception as exc: # noqa: BLE001 + return str(exc) + + if affine is None: + return None + + expected_shape = (moving.ndim + 1, moving.ndim + 1) + if affine.shape != expected_shape: + return ( + f"Selected initialization transform has shape {affine.shape}, " + f"but this registration expects {expected_shape}." + ) + return None + + +def save_selected_transform(panel: "RegistrationPanel") -> None: + """Save the selected transform payload to disk. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose selected transform should be saved. + + Returns + ------- + None + Prompts the user for a destination path and writes the payload. The chosen file + format is JSON for affine payloads and Zarr for B-spline payloads. Updates the + panel status on success or failure. + """ + payload = get_selected_transform_payload(panel) + if payload is None: + panel._set_error("Select a transform to save.") + return + + default_name = payload["name"].replace("/", "-") + suffix = ".json" if payload["kind"] == "affine" else ".zarr" + file_filter = ( + "JSON files (*.json)" if payload["kind"] == "affine" else "Zarr stores (*.zarr)" + ) + start = str(Path.home() / f"{default_name}{suffix}") + path_str, _ = QFileDialog.getSaveFileName( + panel, "Save transform", start, file_filter + ) + if not path_str: + return + + save_transform_payload(path_str, payload) + show_info(f"Saved transform: {path_str}") + + +def load_transform(panel: "RegistrationPanel") -> None: + """Load a transform payload from disk. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel that should receive the loaded transform. + + Returns + ------- + None + Prompts the user for a `.json` or `.zarr` path, stores the resulting payload on + the panel, and refreshes the transform selectors. Errors raised while parsing + the file are surfaced through the panel error label and a napari notification. + """ + start = str(Path.home()) + path_str, _ = QFileDialog.getOpenFileName( + panel, + "Load transform", + start, + "Transform files (*.json *.zarr)", + ) + if not path_str: + return + + try: + panel._loaded_transform_payload = load_transform_payload(path_str) + except Exception as exc: # noqa: BLE001 + panel._set_error(str(exc)) + show_error(str(exc)) + return + + refresh_transform_controls(panel) + for i in range(panel._transform_source_combo.count()): + if panel._transform_source_combo.itemData(i) == ("loaded", ""): + panel._transform_source_combo.setCurrentIndex(i) + break + show_info(f"Loaded transform: {panel._loaded_transform_payload['name']}") + + +def apply_selected_transform(panel: "RegistrationPanel") -> None: + """Apply the selected transform to the chosen input layer. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose selected transform and target layer should be used. + + Returns + ------- + None + Reads the selected payload and the chosen input layer, dispatches a background + [`resample_volume`][confusius.registration.resample_volume] worker, and connects + it to the panel worker lifecycle. The resampled DataArray is added to the viewer + once the worker finishes; failures are forwarded to the panel's error handler. + """ + payload = get_selected_transform_payload(panel) + if payload is None: + panel._set_error("Select a transform to apply.") + return + + moving_layer = panel._selected_layer(panel._transform_target_combo) + if moving_layer is None: + panel._set_error("Select an input layer to transform.") + return + + try: + moving = _get_source_dataarray(moving_layer) + if payload["kind"] == "affine": + transform = get_affine_transform_from_payload(payload) + else: + transform = get_bspline_transform_from_payload(payload) + output_grid = get_output_grid_from_payload(payload) + except Exception as exc: # noqa: BLE001 + panel._set_error(str(exc)) + return + + worker = thread_worker(resample_volume)( + moving, + transform, + shape=output_grid["shape"], + spacing=output_grid["spacing"], + origin=output_grid["origin"], + dims=output_grid["dims"], + interpolation=panel._current_resample_interpolation(), + ) + apply_payload: ApplyTransformPayload = { + "moving_layer_name": moving_layer.name, + "target_layer_name": payload["target_layer_name"], + "transform_source": payload["name"], + } + panel._worker = worker + panel._begin_work() + worker.returned.connect( + lambda result: on_apply_transform_finished(panel, apply_payload, result) + ) + worker.errored.connect(panel._on_registration_failed) + worker.finished.connect(panel._end_work) + worker.start() + + +def on_apply_transform_finished( + panel: "RegistrationPanel", payload: "ApplyTransformPayload", result: xr.DataArray +) -> None: + """Add a resampled layer produced from an existing transform. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel that initiated the resampling worker. + payload : ApplyTransformPayload + UI snapshot captured when the worker was started; carries the moving and target + layer names together with the source transform label. + result : xarray.DataArray + Resampled DataArray returned by the worker. + """ + name = panel._make_unique_layer_name( + f"{payload['moving_layer_name']} → {payload['target_layer_name']}" + ) + layer = cast("Any", panel.viewer.add_image(result.values, name=name)) + layer.metadata["xarray"] = result + layer.metadata["transform_source"] = payload["transform_source"] + panel._status.hide() diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 53b7f60a..9ae84187 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -16,14 +16,16 @@ teardown_volume_progress, update_progress_layer, ) -from confusius._napari._registration._panel_transform_helpers import ( +from confusius._napari._registration._panel_transforms import ( get_affine_transform_from_payload, get_bspline_transform_from_payload, get_output_grid_from_payload, load_transform_payload, make_affine_transform_payload, make_bspline_transform_payload, + refresh_transform_controls, save_transform_payload, + apply_selected_transform, ) from confusius.registration import RegistrationDiagnostics, resample_like @@ -376,7 +378,7 @@ def test_between_scan_run_uses_selected_initial_transform( metadata={"confusius_transform": transform_payload}, ) registration_panel._refresh_layers() - registration_panel._refresh_transform_controls() + refresh_transform_controls(registration_panel) registration_panel._moving_combo.setCurrentText("moving") registration_panel._fixed_combo.setCurrentText("fixed") registration_panel._scale_combo.setCurrentText("square root") @@ -465,7 +467,7 @@ def test_between_scan_run_uses_selected_manual_napari_transform( moving_layer.affine = manual_affine registration_panel._refresh_layers() - registration_panel._refresh_transform_controls() + refresh_transform_controls(registration_panel) registration_panel._moving_combo.setCurrentText("moving") registration_panel._fixed_combo.setCurrentText("fixed") for i in range(registration_panel._initialization_combo.count()): @@ -631,7 +633,7 @@ def test_initial_transform_dropdown_lists_available_transforms( name="Registered", metadata={"confusius_transform": payload}, ) - registration_panel._refresh_transform_controls() + refresh_transform_controls(registration_panel) assert registration_panel._initialization_combo.itemText(0) == "center_geometry" assert registration_panel._initialization_combo.count() >= 4 @@ -660,7 +662,7 @@ def test_initial_transform_dropdown_lists_manual_napari_transforms( manual_affine[0, 3] = 1.0 layer.affine = manual_affine - registration_panel._refresh_transform_controls() + refresh_transform_controls(registration_panel) assert any( registration_panel._initialization_combo.itemData(i) == ("manual", "moving") @@ -686,7 +688,7 @@ def test_transform_source_dropdown_lists_manual_napari_transforms( manual_affine[0, 3] = 1.0 layer.affine = manual_affine - registration_panel._refresh_transform_controls() + refresh_transform_controls(registration_panel) assert any( registration_panel._transform_source_combo.itemData(i) @@ -819,7 +821,7 @@ def test_bspline_transform_is_not_offered_for_initialization( metadata={"xarray": moving, "confusius_transform": payload}, ) - registration_panel._refresh_transform_controls() + refresh_transform_controls(registration_panel) transform_items = [ registration_panel._transform_source_combo.itemText(i) @@ -864,7 +866,7 @@ def test_apply_transform_uses_bspline_payload( name="Registered (bspline)", metadata={"xarray": moving, "confusius_transform": payload}, ) - registration_panel._refresh_transform_controls() + refresh_transform_controls(registration_panel) registration_panel._transform_source_combo.setCurrentText( "moving → fixed (bspline)" ) @@ -895,11 +897,11 @@ def _runner(*args, **kwargs): return _runner monkeypatch.setattr( - "confusius._napari._registration._panel.thread_worker", + "confusius._napari._registration._panel_transforms.thread_worker", _fake_thread_worker, ) - registration_panel._apply_transform() + apply_selected_transform(registration_panel) func = cast("Any", captured["func"]) args = cast("tuple[Any, ...]", captured["args"]) From ebb18643850044995c007ed924087fd8bd75befd Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 2 Jul 2026 00:54:49 +0100 Subject: [PATCH 59/72] docs(registration): tighten transform docstrings --- .../_registration/_panel_transforms.py | 51 ++++++------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel_transforms.py b/src/confusius/_napari/_registration/_panel_transforms.py index 17bc16f0..80411bc4 100644 --- a/src/confusius/_napari/_registration/_panel_transforms.py +++ b/src/confusius/_napari/_registration/_panel_transforms.py @@ -495,7 +495,7 @@ def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload def save_transform_payload(path: str | Path, payload: TransformPayload) -> None: - """Save a transform payload to disk. + """Save a transform payload to disk as JSON for affine payloads or Zarr for B-spline payloads. Parameters ---------- @@ -791,17 +791,12 @@ def get_available_transform_payloads( def refresh_transform_controls(panel: "RegistrationPanel") -> None: - """Refresh transform-related layer selectors. + """Refresh the transform, initialization, and target selectors from the current viewer state. Parameters ---------- panel : RegistrationPanel Registration panel whose transform selectors are updated. - - Returns - ------- - None - Updates transform, initialization, and target selectors in place. """ source_data = panel._transform_source_combo.currentData() initialization_data = panel._initialization_combo.currentData() @@ -1151,19 +1146,12 @@ def validate_initial_transform_selection( def save_selected_transform(panel: "RegistrationPanel") -> None: - """Save the selected transform payload to disk. + """Prompt for a destination path and save the currently selected transform payload. Parameters ---------- panel : RegistrationPanel Registration panel whose selected transform should be saved. - - Returns - ------- - None - Prompts the user for a destination path and writes the payload. The chosen file - format is JSON for affine payloads and Zarr for B-spline payloads. Updates the - panel status on success or failure. """ payload = get_selected_transform_payload(panel) if payload is None: @@ -1187,19 +1175,12 @@ def save_selected_transform(panel: "RegistrationPanel") -> None: def load_transform(panel: "RegistrationPanel") -> None: - """Load a transform payload from disk. + """Prompt for a transform file, load it into the panel state, and refresh the selectors. Parameters ---------- panel : RegistrationPanel Registration panel that should receive the loaded transform. - - Returns - ------- - None - Prompts the user for a `.json` or `.zarr` path, stores the resulting payload on - the panel, and refreshes the transform selectors. Errors raised while parsing - the file are surfaced through the panel error label and a napari notification. """ start = str(Path.home()) path_str, _ = QFileDialog.getOpenFileName( @@ -1227,20 +1208,12 @@ def load_transform(panel: "RegistrationPanel") -> None: def apply_selected_transform(panel: "RegistrationPanel") -> None: - """Apply the selected transform to the chosen input layer. + """Start a background resampling worker for the selected transform and target layer. Parameters ---------- panel : RegistrationPanel Registration panel whose selected transform and target layer should be used. - - Returns - ------- - None - Reads the selected payload and the chosen input layer, dispatches a background - [`resample_volume`][confusius.registration.resample_volume] worker, and connects - it to the panel worker lifecycle. The resampled DataArray is added to the viewer - once the worker finishes; failures are forwarded to the panel's error handler. """ payload = get_selected_transform_payload(panel) if payload is None: @@ -1290,7 +1263,7 @@ def apply_selected_transform(panel: "RegistrationPanel") -> None: def on_apply_transform_finished( panel: "RegistrationPanel", payload: "ApplyTransformPayload", result: xr.DataArray ) -> None: - """Add a resampled layer produced from an existing transform. + """Add the finished transformed layer to the viewer and attach apply-transform metadata. Parameters ---------- @@ -1302,10 +1275,18 @@ def on_apply_transform_finished( result : xarray.DataArray Resampled DataArray returned by the worker. """ + registered = result.copy(deep=False) + registered.attrs = registered.attrs.copy() + registered.attrs["registration_operation"] = "apply_transform" + name = panel._make_unique_layer_name( f"{payload['moving_layer_name']} → {payload['target_layer_name']}" ) - layer = cast("Any", panel.viewer.add_image(result.values, name=name)) - layer.metadata["xarray"] = result + layer = cast("Any", panel.viewer.add_image(registered.values, name=name)) + layer.metadata["xarray"] = registered layer.metadata["transform_source"] = payload["transform_source"] + layer.metadata["registration_operation"] = "apply_transform" + layer.metadata["registration_parameters"] = payload.copy() + panel.viewer.layers.selection.active = layer panel._status.hide() + show_info(f"Added transformed layer: {layer.name}") From d89854121bb2d3a10a4272f3a8c14793e70a6295 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 2 Jul 2026 01:11:31 +0100 Subject: [PATCH 60/72] refactor(registration): split panel results --- src/confusius/_napari/_registration/_panel.py | 269 +-------------- .../_napari/_registration/_panel_results.py | 320 ++++++++++++++++++ .../_registration/_panel_transforms.py | 4 +- .../_registration/_panel_worker_state.py | 31 ++ .../test_napari/test_registration_panel.py | 15 +- 5 files changed, 372 insertions(+), 267 deletions(-) create mode 100644 src/confusius/_napari/_registration/_panel_results.py create mode 100644 src/confusius/_napari/_registration/_panel_worker_state.py diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 6b6512fd..255d2c65 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -6,10 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, cast import numpy as np -import xarray as xr -from napari.layers.utils.layer_utils import calc_data_range from napari.qt.threading import thread_worker -from napari.utils.notifications import show_error, show_info from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QApplication, @@ -45,9 +42,12 @@ from confusius._napari._registration._panel_progress import ( create_volume_progress_plotter, setup_volumewise_progress, - teardown_volume_progress, - teardown_volumewise_progress, ) +from confusius._napari._registration._panel_results import ( + on_volume_registration_finished, + on_volumewise_registration_finished, +) +from confusius._napari._registration._panel_worker_state import on_registration_failed from confusius._napari._registration._panel_transforms import ( TransformPayload, apply_selected_transform, @@ -55,8 +55,6 @@ get_selected_center_initialization, get_selected_initial_transform, load_transform, - make_affine_transform_payload, - make_bspline_transform_payload, refresh_transform_controls, save_selected_transform, validate_initial_transform_selection, @@ -64,8 +62,6 @@ from confusius._napari._registration._panel_utils import ( ScientificDoubleSpinBox, _apply_registration_scale, - _gamma_needs_reset, - _get_image_display_kwargs_from_layer, _get_source_dataarray, _is_registration_source_layer, _parse_comma_separated_ints, @@ -75,7 +71,6 @@ NapariProgressBridge, NapariRegistrationProgressReporterBridge, ) -from confusius.plotting.napari import plot_napari from confusius.registration import register_volume, register_volumewise if TYPE_CHECKING: @@ -83,8 +78,6 @@ import numpy.typing as npt from napari.layers import Image, Layer - from confusius.registration import RegistrationDiagnostics - ScaleMode = Literal["off", "dB", "sqrt"] """Allowed registration intensity-scaling modes used by the panel.""" @@ -1605,8 +1598,8 @@ def _run_registration(self) -> None: self._worker = worker self._begin_work() worker.returned.connect( - lambda result: self._on_volume_registration_finished( - volume_payload, result + lambda result: on_volume_registration_finished( + self, volume_payload, result ) ) else: @@ -1687,252 +1680,10 @@ def _run_registration(self) -> None: self._worker = worker self._begin_work() worker.returned.connect( - lambda result: self._on_volumewise_registration_finished( - volumewise_payload, result + lambda result: on_volumewise_registration_finished( + self, volumewise_payload, result ) ) - worker.errored.connect(self._on_registration_failed) + worker.errored.connect(lambda exc: on_registration_failed(self, exc)) worker.finished.connect(self._end_work) worker.start() - - def _coerce_volume_registration_payload( - self, payload: dict[str, Any] | VolumeRegistrationRunPayload - ) -> VolumeRegistrationRunPayload: - """Return a typed between-scan registration payload.""" - if payload.get("operation") != "register_volume": - raise ValueError("Expected a register_volume payload.") - return cast("VolumeRegistrationRunPayload", payload) - - def _coerce_volumewise_registration_payload( - self, payload: dict[str, Any] | VolumewiseRegistrationRunPayload - ) -> VolumewiseRegistrationRunPayload: - """Return a typed within-scan registration payload.""" - if payload.get("operation") != "register_volumewise": - raise ValueError("Expected a register_volumewise payload.") - return cast("VolumewiseRegistrationRunPayload", payload) - - def _finalize_registration_layer( - self, - *, - payload: VolumeRegistrationRunPayload | VolumewiseRegistrationRunPayload, - registered: xr.DataArray, - layer_name: str, - metadata: dict[str, Any], - registration_status: Literal["completed", "aborted"], - ) -> None: - """Attach registration metadata and add or update the result layer.""" - metadata["registration_operation"] = payload["operation"] - metadata["registration_parameters"] = payload.copy() - - source_layer = self._get_layer_by_name(payload["moving_layer_name"]) - display_kwargs = ( - _get_image_display_kwargs_from_layer(source_layer) - if source_layer is not None - else {} - ) - if _gamma_needs_reset(payload.get("scale", "off")): - display_kwargs["gamma"] = 1.0 - if payload["operation"] == "register_volume": - display_kwargs["colormap"] = "cyan" - display_kwargs["blending"] = "additive" - contrast_limits = tuple(calc_data_range(registered.data)) - - if ( - payload["operation"] == "register_volume" - and self._progress_layer is not None - ): - layer = self._progress_layer - self._set_image_layer_data(layer, np.asarray(registered.data)) - if hasattr(layer, "contrast_limits"): - layer.contrast_limits = contrast_limits - self._progress_bridge = None - self._progress_layer = None - elif ( - payload["operation"] == "register_volumewise" - and self._volumewise_progress_layer is not None - ): - layer = self._volumewise_progress_layer - self._set_image_layer_data(layer, np.asarray(registered.data)) - if hasattr(layer, "contrast_limits"): - layer.contrast_limits = contrast_limits - teardown_volumewise_progress(self, remove_layer=False) - else: - _, layer = plot_napari( - registered, - viewer=self.viewer, - name=layer_name, - show_colorbar=False, - contrast_limits=contrast_limits, - **display_kwargs, - ) - layer.metadata.update(metadata) - layer.metadata["xarray"] = registered - self.viewer.layers.selection.active = layer - refresh_transform_controls(self) - - if payload["operation"] == "register_volumewise": - self._progress.setValue(self._progress.maximum()) - - if registration_status == "aborted": - layer.name = f"{layer.name} (aborted)" - self._set_error("Registration aborted; added partial result.") - show_info(f"Registration aborted; added partial layer: {layer.name}") - else: - show_info(f"Added registered layer: {layer.name}") - - def _on_registration_finished( - self, - payload: dict[str, Any], - result: object, - ) -> None: - """Dispatch a finished registration callback to the typed handler. - - Parameters - ---------- - payload : dict[str, Any] - Untyped compatibility payload captured when the worker started. - result : object - Worker result to forward to the operation-specific handler. - - Raises - ------ - ValueError - If `payload["operation"]` is not recognized. - """ - if payload.get("operation") == "register_volume": - self._on_volume_registration_finished( - self._coerce_volume_registration_payload(payload), - cast( - "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics]", - result, - ), - ) - return - if payload.get("operation") == "register_volumewise": - self._on_volumewise_registration_finished( - self._coerce_volumewise_registration_payload(payload), - cast("xr.DataArray", result), - ) - return - raise ValueError( - f"Unknown registration operation: {payload.get('operation')!r}." - ) - - def _on_volume_registration_finished( - self, - payload: VolumeRegistrationRunPayload, - result: tuple[ - xr.DataArray, - npt.NDArray[np.floating] | xr.DataArray, - RegistrationDiagnostics, - ], - ) -> None: - """Add a between-scan registration result back to the viewer. - - Parameters - ---------- - payload : VolumeRegistrationRunPayload - Typed UI snapshot captured before the worker started. - result : tuple - Registered volume, estimated transform, and diagnostics. - """ - registered, transform, diagnostics = result - registered = registered.copy(deep=False) - registered.attrs = registered.attrs.copy() - registered.attrs["registration_transform"] = transform - registered.attrs["registration_diagnostics"] = diagnostics - registered.attrs["registration_operation"] = payload["operation"] - registered.attrs["registration_status"] = diagnostics.status - metadata: dict[str, Any] = { - "registration_transform": transform, - "registration_diagnostics": diagnostics, - "registration_status": diagnostics.status, - } - transform_name = self._make_unique_transform_name( - f"{payload['moving_layer_name']} → {payload['fixed_layer_name']} ({payload['transform']})" - ) - if isinstance(transform, np.ndarray): - metadata["confusius_transform"] = make_affine_transform_payload( - np.asarray(transform, dtype=float), - reference=registered, - source_layer_name=payload["moving_layer_name"], - target_layer_name=payload["fixed_layer_name"], - operation=payload["operation"], - transform_model=payload["transform"], - metric=payload["metric"], - diagnostics=diagnostics, - name=transform_name, - ) - else: - metadata["confusius_transform"] = make_bspline_transform_payload( - transform, - reference=registered, - source_layer_name=payload["moving_layer_name"], - target_layer_name=payload["fixed_layer_name"], - operation=payload["operation"], - transform_model=payload["transform"], - metric=payload["metric"], - diagnostics=diagnostics, - name=transform_name, - ) - self._finalize_registration_layer( - payload=payload, - registered=registered, - layer_name=self._volume_result_layer_name( - payload["moving_layer_name"], - payload["fixed_layer_name"], - transform_model=payload["transform"], - ), - metadata=metadata, - registration_status=diagnostics.status, - ) - - def _on_volumewise_registration_finished( - self, - payload: VolumewiseRegistrationRunPayload, - result: xr.DataArray, - ) -> None: - """Add a within-scan registration result back to the viewer. - - Parameters - ---------- - payload : VolumewiseRegistrationRunPayload - Typed UI snapshot captured before the worker started. - result : xarray.DataArray - Motion-corrected time series returned by the worker. - """ - registered = result.copy(deep=False) - registered.attrs = registered.attrs.copy() - registered.attrs["registration_operation"] = payload["operation"] - motion_params = registered.attrs.get("motion_params") - registration_status = "completed" - if motion_params is not None: - try: - statuses = motion_params["status"] - except Exception: # noqa: BLE001 - statuses = None - if statuses is not None and bool((statuses == "aborted").any()): - registration_status = "aborted" - self._finalize_registration_layer( - payload=payload, - registered=registered, - layer_name=self._volumewise_result_layer_name(payload["moving_layer_name"]), - metadata={ - "motion_params": motion_params, - "reference_time": payload["reference_time"], - }, - registration_status=registration_status, - ) - - def _on_registration_failed(self, exc: BaseException) -> None: - """Handle a failed worker execution. - - Parameters - ---------- - exc : BaseException - Exception raised by the worker. - """ - teardown_volume_progress(self) - teardown_volumewise_progress(self, remove_layer=True) - self._set_error(str(exc)) - show_error(str(exc)) diff --git a/src/confusius/_napari/_registration/_panel_results.py b/src/confusius/_napari/_registration/_panel_results.py new file mode 100644 index 00000000..4c8f1040 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel_results.py @@ -0,0 +1,320 @@ +"""Result-handling helpers for the napari registration panel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, cast + +import numpy as np +import xarray as xr +from napari.layers.utils.layer_utils import calc_data_range +from napari.utils.notifications import show_info + +from confusius._napari._registration._panel_progress import teardown_volumewise_progress +from confusius._napari._registration._panel_transforms import ( + make_affine_transform_payload, + make_bspline_transform_payload, + refresh_transform_controls, +) +from confusius._napari._registration._panel_utils import ( + _gamma_needs_reset, + _get_image_display_kwargs_from_layer, +) +from confusius.plotting.napari import plot_napari + +if TYPE_CHECKING: + import numpy.typing as npt + + from confusius._napari._registration._panel import ( + RegistrationPanel, + VolumeRegistrationRunPayload, + VolumewiseRegistrationRunPayload, + ) + from confusius.registration import RegistrationDiagnostics + + +def coerce_volume_registration_payload( + payload: dict[str, Any] | "VolumeRegistrationRunPayload", +) -> "VolumeRegistrationRunPayload": + """Return a typed between-scan registration payload. + + Parameters + ---------- + payload : dict[str, Any] or VolumeRegistrationRunPayload + Untyped or typed payload captured when the worker started. + + Returns + ------- + VolumeRegistrationRunPayload + Typed payload for a between-scan registration run. + + Raises + ------ + ValueError + If `payload["operation"]` is not `"register_volume"`. + """ + if payload.get("operation") != "register_volume": + raise ValueError("Expected a register_volume payload.") + return cast("VolumeRegistrationRunPayload", payload) + + +def coerce_volumewise_registration_payload( + payload: dict[str, Any] | "VolumewiseRegistrationRunPayload", +) -> "VolumewiseRegistrationRunPayload": + """Return a typed within-scan registration payload. + + Parameters + ---------- + payload : dict[str, Any] or VolumewiseRegistrationRunPayload + Untyped or typed payload captured when the worker started. + + Returns + ------- + VolumewiseRegistrationRunPayload + Typed payload for a within-scan registration run. + + Raises + ------ + ValueError + If `payload["operation"]` is not `"register_volumewise"`. + """ + if payload.get("operation") != "register_volumewise": + raise ValueError("Expected a register_volumewise payload.") + return cast("VolumewiseRegistrationRunPayload", payload) + + +def finalize_registration_layer( + panel: "RegistrationPanel", + *, + payload: "VolumeRegistrationRunPayload | VolumewiseRegistrationRunPayload", + registered: xr.DataArray, + layer_name: str, + metadata: dict[str, Any], + registration_status: Literal["completed", "aborted"], +) -> None: + """Attach registration metadata and add or update the result layer. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose viewer should receive the final layer. + payload : VolumeRegistrationRunPayload or VolumewiseRegistrationRunPayload + Typed UI snapshot captured before the worker started. + registered : xarray.DataArray + Registered output returned by the worker. + layer_name : str + Name to use when creating a new result layer. + metadata : dict[str, Any] + Extra layer metadata to attach. + registration_status : {"completed", "aborted"} + Final run status used for user feedback and layer naming. + """ + metadata["registration_operation"] = payload["operation"] + metadata["registration_parameters"] = payload.copy() + + source_layer = panel._get_layer_by_name(payload["moving_layer_name"]) + display_kwargs = ( + _get_image_display_kwargs_from_layer(source_layer) + if source_layer is not None + else {} + ) + if _gamma_needs_reset(payload.get("scale", "off")): + display_kwargs["gamma"] = 1.0 + if payload["operation"] == "register_volume": + display_kwargs["colormap"] = "cyan" + display_kwargs["blending"] = "additive" + contrast_limits = tuple(calc_data_range(registered.data)) + + if payload["operation"] == "register_volume" and panel._progress_layer is not None: + layer = panel._progress_layer + panel._set_image_layer_data(layer, np.asarray(registered.data)) + if hasattr(layer, "contrast_limits"): + layer.contrast_limits = contrast_limits + panel._progress_bridge = None + panel._progress_layer = None + elif ( + payload["operation"] == "register_volumewise" + and panel._volumewise_progress_layer is not None + ): + layer = panel._volumewise_progress_layer + panel._set_image_layer_data(layer, np.asarray(registered.data)) + if hasattr(layer, "contrast_limits"): + layer.contrast_limits = contrast_limits + teardown_volumewise_progress(panel, remove_layer=False) + else: + _, layer = plot_napari( + registered, + viewer=panel.viewer, + name=layer_name, + show_colorbar=False, + contrast_limits=contrast_limits, + **display_kwargs, + ) + layer.metadata.update(metadata) + layer.metadata["xarray"] = registered + panel.viewer.layers.selection.active = layer + refresh_transform_controls(panel) + + if payload["operation"] == "register_volumewise": + panel._progress.setValue(panel._progress.maximum()) + + if registration_status == "aborted": + layer.name = f"{layer.name} (aborted)" + panel._set_error("Registration aborted; added partial result.") + show_info(f"Registration aborted; added partial layer: {layer.name}") + else: + show_info(f"Added registered layer: {layer.name}") + + +def on_registration_finished( + panel: "RegistrationPanel", + payload: dict[str, Any], + result: object, +) -> None: + """Dispatch a finished registration callback to the typed handler. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel that started the worker. + payload : dict[str, Any] + Untyped compatibility payload captured when the worker started. + result : object + Worker result to forward to the operation-specific handler. + + Raises + ------ + ValueError + If `payload["operation"]` is not recognized. + """ + if payload.get("operation") == "register_volume": + on_volume_registration_finished( + panel, + coerce_volume_registration_payload(payload), + cast( + "tuple[xr.DataArray, npt.NDArray[np.floating] | xr.DataArray, RegistrationDiagnostics]", + result, + ), + ) + return + if payload.get("operation") == "register_volumewise": + on_volumewise_registration_finished( + panel, + coerce_volumewise_registration_payload(payload), + cast("xr.DataArray", result), + ) + return + raise ValueError(f"Unknown registration operation: {payload.get('operation')!r}.") + + +def on_volume_registration_finished( + panel: "RegistrationPanel", + payload: "VolumeRegistrationRunPayload", + result: tuple[ + xr.DataArray, + "npt.NDArray[np.floating] | xr.DataArray", + "RegistrationDiagnostics", + ], +) -> None: + """Add a between-scan registration result back to the viewer. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel that started the worker. + payload : VolumeRegistrationRunPayload + Typed UI snapshot captured before the worker started. + result : tuple + Registered volume, estimated transform, and diagnostics. + """ + registered, transform, diagnostics = result + registered = registered.copy(deep=False) + registered.attrs = registered.attrs.copy() + registered.attrs["registration_transform"] = transform + registered.attrs["registration_diagnostics"] = diagnostics + registered.attrs["registration_operation"] = payload["operation"] + registered.attrs["registration_status"] = diagnostics.status + metadata: dict[str, Any] = { + "registration_transform": transform, + "registration_diagnostics": diagnostics, + "registration_status": diagnostics.status, + } + transform_name = panel._make_unique_transform_name( + f"{payload['moving_layer_name']} → {payload['fixed_layer_name']} ({payload['transform']})" + ) + if isinstance(transform, np.ndarray): + metadata["confusius_transform"] = make_affine_transform_payload( + np.asarray(transform, dtype=float), + reference=registered, + source_layer_name=payload["moving_layer_name"], + target_layer_name=payload["fixed_layer_name"], + operation=payload["operation"], + transform_model=payload["transform"], + metric=payload["metric"], + diagnostics=diagnostics, + name=transform_name, + ) + else: + metadata["confusius_transform"] = make_bspline_transform_payload( + transform, + reference=registered, + source_layer_name=payload["moving_layer_name"], + target_layer_name=payload["fixed_layer_name"], + operation=payload["operation"], + transform_model=payload["transform"], + metric=payload["metric"], + diagnostics=diagnostics, + name=transform_name, + ) + finalize_registration_layer( + panel, + payload=payload, + registered=registered, + layer_name=panel._volume_result_layer_name( + payload["moving_layer_name"], + payload["fixed_layer_name"], + transform_model=payload["transform"], + ), + metadata=metadata, + registration_status=diagnostics.status, + ) + + +def on_volumewise_registration_finished( + panel: "RegistrationPanel", + payload: "VolumewiseRegistrationRunPayload", + result: xr.DataArray, +) -> None: + """Add a within-scan registration result back to the viewer. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel that started the worker. + payload : VolumewiseRegistrationRunPayload + Typed UI snapshot captured before the worker started. + result : xarray.DataArray + Motion-corrected time series returned by the worker. + """ + registered = result.copy(deep=False) + registered.attrs = registered.attrs.copy() + registered.attrs["registration_operation"] = payload["operation"] + motion_params = registered.attrs.get("motion_params") + registration_status = "completed" + if motion_params is not None: + try: + statuses = motion_params["status"] + except Exception: # noqa: BLE001 + statuses = None + if statuses is not None and bool((statuses == "aborted").any()): + registration_status = "aborted" + finalize_registration_layer( + panel, + payload=payload, + registered=registered, + layer_name=panel._volumewise_result_layer_name(payload["moving_layer_name"]), + metadata={ + "motion_params": motion_params, + "reference_time": payload["reference_time"], + }, + registration_status=registration_status, + ) diff --git a/src/confusius/_napari/_registration/_panel_transforms.py b/src/confusius/_napari/_registration/_panel_transforms.py index 80411bc4..05004046 100644 --- a/src/confusius/_napari/_registration/_panel_transforms.py +++ b/src/confusius/_napari/_registration/_panel_transforms.py @@ -27,6 +27,7 @@ _get_source_dataarray, _prepare_between_scan_data, ) +from confusius._napari._registration._panel_worker_state import on_registration_failed from confusius.registration import resample_volume from confusius.registration.bspline import validate_bspline_dataarray @@ -1252,10 +1253,11 @@ def apply_selected_transform(panel: "RegistrationPanel") -> None: } panel._worker = worker panel._begin_work() + worker.returned.connect( lambda result: on_apply_transform_finished(panel, apply_payload, result) ) - worker.errored.connect(panel._on_registration_failed) + worker.errored.connect(lambda exc: on_registration_failed(panel, exc)) worker.finished.connect(panel._end_work) worker.start() diff --git a/src/confusius/_napari/_registration/_panel_worker_state.py b/src/confusius/_napari/_registration/_panel_worker_state.py new file mode 100644 index 00000000..a87d6f30 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel_worker_state.py @@ -0,0 +1,31 @@ +"""Worker-state cleanup helpers for the napari registration panel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from napari.utils.notifications import show_error + +from confusius._napari._registration._panel_progress import ( + teardown_volume_progress, + teardown_volumewise_progress, +) + +if TYPE_CHECKING: + from confusius._napari._registration._panel import RegistrationPanel + + +def on_registration_failed(panel: "RegistrationPanel", exc: BaseException) -> None: + """Handle a failed worker execution. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose in-flight state should be cleaned up. + exc : BaseException + Exception raised by the worker. + """ + teardown_volume_progress(panel) + teardown_volumewise_progress(panel, remove_layer=True) + panel._set_error(str(exc)) + show_error(str(exc)) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 9ae84187..865ca328 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -16,6 +16,7 @@ teardown_volume_progress, update_progress_layer, ) +from confusius._napari._registration._panel_results import on_registration_finished from confusius._napari._registration._panel_transforms import ( get_affine_transform_from_payload, get_bspline_transform_from_payload, @@ -1054,7 +1055,7 @@ def test_volume_result_adds_new_layer_with_transform_metadata( "resample_interpolation": "linear", } - registration_panel._on_registration_finished( + on_registration_finished(registration_panel, payload, (registered, transform, diagnostics), ) @@ -1099,7 +1100,7 @@ def test_volume_result_adds_bspline_transform_metadata( "resample_interpolation": "linear", } - registration_panel._on_registration_finished( + on_registration_finished(registration_panel, payload, (registered, transform, diagnostics), ) @@ -1194,7 +1195,7 @@ def test_volume_result_replaces_preview_layer( "use_multi_resolution": False, "resample_interpolation": "linear", } - registration_panel._on_registration_finished( + on_registration_finished(registration_panel, payload, (registered, transform, diagnostics), ) @@ -1405,7 +1406,7 @@ def test_volumewise_result_adds_registered_layer(self, viewer, registration_pane "reference_time": 1, } - registration_panel._on_registration_finished(payload, registered) + on_registration_finished(registration_panel, payload, registered) layer = viewer.layers["Motion corrected"] assert layer.metadata["reference_time"] == 1 @@ -1457,7 +1458,7 @@ def test_volumewise_finished_keeps_preview_layers(self, viewer, registration_pan "reference_time": 1, } - registration_panel._on_registration_finished(payload, registered) + on_registration_finished(registration_panel, payload, registered) assert {"Moving", "Motion corrected"}.issubset( {layer.name for layer in viewer.layers} @@ -1488,11 +1489,11 @@ def test_unique_transform_and_result_names(self, viewer, registration_panel): transform = np.eye(3) diagnostics = _FakeDiagnostics() - registration_panel._on_registration_finished( + on_registration_finished(registration_panel, payload, (fixed.copy(), transform, diagnostics), ) - registration_panel._on_registration_finished( + on_registration_finished(registration_panel, payload, (fixed.copy(), transform, diagnostics), ) From 5be564fc0155d76870d48241a01601f14410a37d Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 2 Jul 2026 02:18:19 +0100 Subject: [PATCH 61/72] refactor(registration): split transform payloads --- src/confusius/_napari/_registration/_panel.py | 2 +- .../_napari/_registration/_panel_results.py | 4 +- .../_registration/_panel_transforms.py | 545 +----------------- .../_registration/_transform_payloads.py | 521 +++++++++++++++++ .../test_napari/test_registration_panel.py | 6 +- 5 files changed, 540 insertions(+), 538 deletions(-) create mode 100644 src/confusius/_napari/_registration/_transform_payloads.py diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 255d2c65..bd9146ef 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -49,7 +49,6 @@ ) from confusius._napari._registration._panel_worker_state import on_registration_failed from confusius._napari._registration._panel_transforms import ( - TransformPayload, apply_selected_transform, get_available_transform_payloads, get_selected_center_initialization, @@ -59,6 +58,7 @@ save_selected_transform, validate_initial_transform_selection, ) +from confusius._napari._registration._transform_payloads import TransformPayload from confusius._napari._registration._panel_utils import ( ScientificDoubleSpinBox, _apply_registration_scale, diff --git a/src/confusius/_napari/_registration/_panel_results.py b/src/confusius/_napari/_registration/_panel_results.py index 4c8f1040..79ef9c2d 100644 --- a/src/confusius/_napari/_registration/_panel_results.py +++ b/src/confusius/_napari/_registration/_panel_results.py @@ -10,10 +10,10 @@ from napari.utils.notifications import show_info from confusius._napari._registration._panel_progress import teardown_volumewise_progress -from confusius._napari._registration._panel_transforms import ( +from confusius._napari._registration._panel_transforms import refresh_transform_controls +from confusius._napari._registration._transform_payloads import ( make_affine_transform_payload, make_bspline_transform_payload, - refresh_transform_controls, ) from confusius._napari._registration._panel_utils import ( _gamma_needs_reset, diff --git a/src/confusius/_napari/_registration/_panel_transforms.py b/src/confusius/_napari/_registration/_panel_transforms.py index 05004046..3fa96891 100644 --- a/src/confusius/_napari/_registration/_panel_transforms.py +++ b/src/confusius/_napari/_registration/_panel_transforms.py @@ -2,18 +2,9 @@ from __future__ import annotations -import json from collections.abc import Sequence from pathlib import Path -from typing import ( - TYPE_CHECKING, - Any, - Literal, - SupportsFloat, - SupportsIndex, - TypedDict, - cast, -) +from typing import TYPE_CHECKING, Any, Literal, cast import numpy as np import numpy.typing as npt @@ -28,12 +19,19 @@ _prepare_between_scan_data, ) from confusius._napari._registration._panel_worker_state import on_registration_failed +from confusius._napari._registration._transform_payloads import ( + AffineTransformPayload, + TransformPayload, + get_affine_transform_from_payload, + get_bspline_transform_from_payload, + get_output_grid_from_payload, + load_transform_payload, + make_output_grid_payload, + save_transform_payload, +) from confusius.registration import resample_volume -from confusius.registration.bspline import validate_bspline_dataarray if TYPE_CHECKING: - from collections.abc import Mapping - from napari.layers import Layer from confusius._napari._registration._panel import ( @@ -41,510 +39,6 @@ RegistrationPanel, TransformSourceData, ) - from confusius.registration import RegistrationDiagnostics - - -class TransformDiagnosticsPayload(TypedDict): - """JSON-serializable registration diagnostics summary.""" - - metric: str - final_metric_value: float - n_iterations: int - stop_condition: str - status: str - - -class OutputGridPayload(TypedDict): - """JSON-serializable resampling grid description.""" - - dims: list[str] - shape: list[int] - spacing: list[float] - origin: list[float] - units: list[str | None] - - -class BSplineDataArrayPayload(TypedDict): - """JSON-serializable B-spline control-point DataArray.""" - - dims: list[str] - data: list[object] - coords: dict[str, list[float]] - attrs: dict[str, object] - - -class AffineTransformPayload(TypedDict): - """JSON-serializable affine transform payload used by the napari plugin.""" - - kind: Literal["affine"] - name: str - affine: list[list[float]] - source_layer_name: str - target_layer_name: str - operation: str - transform_model: str - metric: str - output_grid: OutputGridPayload - diagnostics: TransformDiagnosticsPayload - - -class BSplineTransformPayload(TypedDict): - """B-spline transform payload used by the napari plugin.""" - - kind: Literal["bspline"] - name: str - bspline: BSplineDataArrayPayload - source_layer_name: str - target_layer_name: str - operation: str - transform_model: str - metric: str - output_grid: OutputGridPayload - diagnostics: TransformDiagnosticsPayload - - -TransformPayload = AffineTransformPayload | BSplineTransformPayload -"""Union of affine and B-spline transform payloads.""" - - -def make_output_grid_payload(reference: "xr.DataArray") -> OutputGridPayload: - """Return the resampling grid defined by a reference DataArray. - - Parameters - ---------- - reference : xarray.DataArray - Spatial DataArray defining the output grid. - - Returns - ------- - OutputGridPayload - JSON-serializable output-grid description. - """ - dims = [str(dim) for dim in reference.dims] - return { - "dims": dims, - "shape": [int(reference.sizes[dim]) for dim in dims], - "spacing": [float(reference.fusi.spacing[dim]) for dim in dims], - "origin": [float(reference.fusi.origin[dim]) for dim in dims], - "units": [ - cast("str | None", reference.coords[dim].attrs.get("units")) - if dim in reference.coords - else None - for dim in dims - ], - } - - -def _make_diagnostics_payload( - diagnostics: "RegistrationDiagnostics", -) -> TransformDiagnosticsPayload: - """Return a JSON-serializable diagnostics summary. - - Parameters - ---------- - diagnostics : confusius.registration.RegistrationDiagnostics - Per-call registration diagnostics to serialize. - - Returns - ------- - TransformDiagnosticsPayload - JSON-serializable diagnostics summary. - """ - return { - "metric": diagnostics.metric, - "final_metric_value": float(diagnostics.final_metric_value), - "n_iterations": int(diagnostics.n_iterations), - "stop_condition": diagnostics.stop_condition, - "status": diagnostics.status, - } - - -def make_affine_transform_payload( - affine: npt.NDArray[np.floating], - *, - reference: "xr.DataArray", - source_layer_name: str, - target_layer_name: str, - operation: str, - transform_model: str, - metric: str, - diagnostics: "RegistrationDiagnostics", - name: str | None = None, -) -> AffineTransformPayload: - """Build a JSON-serializable payload for a registered affine transform. - - Parameters - ---------- - affine : (N+1, N+1) numpy.ndarray - Affine transform in homogeneous coordinates. - reference : xarray.DataArray - Fixed/reference DataArray defining the output resampling grid. - source_layer_name : str - Name of the moving/source layer used when estimating the transform. - target_layer_name : str - Name of the fixed/target layer used when estimating the transform. - operation : str - Registration operation that produced the transform. - transform_model : str - Transform model used during registration. - metric : str - Similarity metric used during registration. - diagnostics : confusius.registration.RegistrationDiagnostics - Per-call registration diagnostics. - name : str, optional - Human-friendly transform name. If not provided, a default name is generated. - - Returns - ------- - AffineTransformPayload - JSON-serializable affine transform payload. - """ - affine = np.asarray(affine, dtype=float) - payload_name = ( - name or f"{source_layer_name} → {target_layer_name} ({transform_model})" - ) - return { - "kind": "affine", - "name": payload_name, - "affine": affine.tolist(), - "source_layer_name": source_layer_name, - "target_layer_name": target_layer_name, - "operation": operation, - "transform_model": transform_model, - "metric": metric, - "output_grid": make_output_grid_payload(reference), - "diagnostics": _make_diagnostics_payload(diagnostics), - } - - -def _serialize_bspline_dataarray(transform: "xr.DataArray") -> BSplineDataArrayPayload: - """Return a JSON-serializable B-spline DataArray payload. - - Parameters - ---------- - transform : xarray.DataArray - B-spline control-point grid to serialize. - - Returns - ------- - BSplineDataArrayPayload - JSON-serializable B-spline DataArray payload. - """ - validate_bspline_dataarray(transform) - return { - "dims": [str(dim) for dim in transform.dims], - "data": np.asarray(transform, dtype=float).tolist(), - "coords": { - str(dim): np.asarray(transform.coords[dim], dtype=float).tolist() - for dim in transform.dims - if dim in transform.coords - }, - "attrs": json.loads(json.dumps(transform.attrs)), - } - - -def _deserialize_bspline_dataarray(payload: BSplineDataArrayPayload) -> xr.DataArray: - """Reconstruct a B-spline DataArray from its JSON payload. - - Parameters - ---------- - payload : BSplineDataArrayPayload - JSON payload describing a B-spline control-point grid. - - Returns - ------- - xarray.DataArray - Reconstructed B-spline control-point grid. - """ - dims = [str(dim) for dim in payload["dims"]] - coords = { - str(dim): xr.DataArray(np.asarray(values, dtype=float), dims=[str(dim)]) - for dim, values in payload["coords"].items() - } - transform = xr.DataArray( - np.asarray(payload["data"], dtype=float), - dims=dims, - coords=coords, - attrs=dict(payload["attrs"]), - ) - validate_bspline_dataarray(transform) - return transform - - -def make_bspline_transform_payload( - transform: "xr.DataArray", - *, - reference: "xr.DataArray", - source_layer_name: str, - target_layer_name: str, - operation: str, - transform_model: str, - metric: str, - diagnostics: "RegistrationDiagnostics", - name: str | None = None, -) -> BSplineTransformPayload: - """Build a JSON-serializable payload for a registered B-spline transform. - - Parameters - ---------- - transform : xarray.DataArray - B-spline control-point grid. - reference : xarray.DataArray - Fixed/reference DataArray defining the output resampling grid. - source_layer_name : str - Name of the moving/source layer used when estimating the transform. - target_layer_name : str - Name of the fixed/target layer used when estimating the transform. - operation : str - Registration operation that produced the transform. - transform_model : str - Transform model used during registration. - metric : str - Similarity metric used during registration. - diagnostics : confusius.registration.RegistrationDiagnostics - Per-call registration diagnostics. - name : str, optional - Human-friendly transform name. If not provided, a default name is generated. - - Returns - ------- - BSplineTransformPayload - JSON-serializable B-spline transform payload. - """ - payload_name = ( - name or f"{source_layer_name} → {target_layer_name} ({transform_model})" - ) - return { - "kind": "bspline", - "name": payload_name, - "bspline": _serialize_bspline_dataarray(transform), - "source_layer_name": source_layer_name, - "target_layer_name": target_layer_name, - "operation": operation, - "transform_model": transform_model, - "metric": metric, - "output_grid": make_output_grid_payload(reference), - "diagnostics": _make_diagnostics_payload(diagnostics), - } - - -def get_affine_transform_from_payload( - payload: "Mapping[str, object]", -) -> npt.NDArray[np.float64]: - """Return the affine matrix stored in a payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - (N+1, N+1) numpy.ndarray - Affine matrix. - """ - if payload.get("kind") != "affine": - raise ValueError("Transform payload is not an affine transform.") - - affine = np.asarray(payload.get("affine"), dtype=float) - if affine.ndim != 2 or affine.shape[0] != affine.shape[1] or affine.shape[0] < 3: - raise ValueError( - "Affine payload must contain a square homogeneous matrix of shape " - "(N+1, N+1)." - ) - return affine - - -def get_bspline_transform_from_payload(payload: "Mapping[str, object]") -> xr.DataArray: - """Return the B-spline transform stored in a payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - xarray.DataArray - B-spline control-point grid. - """ - if payload.get("kind") != "bspline": - raise ValueError("Transform payload is not a B-spline transform.") - - bspline = payload.get("bspline") - if not isinstance(bspline, dict): - raise ValueError("B-spline payload must contain a serialized DataArray.") - return _deserialize_bspline_dataarray(cast("BSplineDataArrayPayload", bspline)) - - -def get_output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: - """Return the output grid stored in a transform payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - OutputGridPayload - Output-grid description stored in the payload. - """ - grid = payload.get("output_grid") - if not isinstance(grid, dict): - raise ValueError("Transform payload does not contain an output grid.") - - grid_dict = cast("dict[str, object]", grid) - dims = grid_dict.get("dims") - shape = grid_dict.get("shape") - spacing = grid_dict.get("spacing") - origin = grid_dict.get("origin") - units = grid_dict.get("units") - if not all(isinstance(v, list) for v in (dims, shape, spacing, origin, units)): - raise ValueError("Transform payload output grid is malformed.") - - dims_list = cast("list[object]", dims) - shape_list = cast("list[SupportsIndex]", shape) - spacing_list = cast("list[SupportsFloat]", spacing) - origin_list = cast("list[SupportsFloat]", origin) - units_list = cast("list[object]", units) - - return { - "dims": [str(v) for v in dims_list], - "shape": [int(v) for v in shape_list], - "spacing": [float(v) for v in spacing_list], - "origin": [float(v) for v in origin_list], - "units": [None if v is None else str(v) for v in units_list], - } - - -def _save_bspline_transform_payload( - path: str | Path, payload: BSplineTransformPayload -) -> None: - """Save a B-spline transform payload as Zarr. - - Parameters - ---------- - path : str or pathlib.Path - Output Zarr path. - payload : BSplineTransformPayload - Transform payload to save. - - Raises - ------ - ValueError - If `path` does not have a `.zarr` extension. - """ - path = Path(path) - if path.suffix != ".zarr": - raise ValueError("B-spline transform files must have .zarr extension.") - - transform = get_bspline_transform_from_payload(payload) - ds = transform.to_dataset(name="bspline_transform") - payload_metadata = { - key: value for key, value in payload.items() if key not in {"kind", "bspline"} - } - ds.attrs["confusius_transform_kind"] = "bspline" - ds.attrs["confusius_transform_payload_json"] = json.dumps(payload_metadata) - ds.to_zarr(path, mode="w") - - -def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload: - """Load a B-spline transform payload from Zarr. - - Parameters - ---------- - path : str or pathlib.Path - Input Zarr path. - - Returns - ------- - BSplineTransformPayload - Loaded B-spline transform payload. - """ - ds = xr.open_zarr(path) - try: - if ds.attrs.get("confusius_transform_kind") != "bspline": - raise ValueError( - "Zarr transform store does not contain a ConfUSIus B-spline transform." - ) - payload_metadata = json.loads( - cast("str", ds.attrs["confusius_transform_payload_json"]) - ) - if not isinstance(payload_metadata, dict): - raise ValueError("Stored transform payload metadata is malformed.") - transform = ds["bspline_transform"].load() - finally: - ds.close() - - validate_bspline_dataarray(transform) - payload: BSplineTransformPayload = { - "kind": "bspline", - "bspline": _serialize_bspline_dataarray(transform), - "name": str(payload_metadata["name"]), - "source_layer_name": str(payload_metadata["source_layer_name"]), - "target_layer_name": str(payload_metadata["target_layer_name"]), - "operation": str(payload_metadata["operation"]), - "transform_model": str(payload_metadata["transform_model"]), - "metric": str(payload_metadata["metric"]), - "output_grid": get_output_grid_from_payload(payload_metadata), - "diagnostics": cast( - "TransformDiagnosticsPayload", payload_metadata["diagnostics"] - ), - } - return payload - - -def save_transform_payload(path: str | Path, payload: TransformPayload) -> None: - """Save a transform payload to disk as JSON for affine payloads or Zarr for B-spline payloads. - - Parameters - ---------- - path : str or pathlib.Path - Output path. - payload : TransformPayload - Transform payload to save. - - Notes - ----- - Affine payloads are saved as JSON. B-spline payloads are saved as Zarr. - """ - if payload["kind"] == "affine": - Path(path).write_text(json.dumps(payload, indent=2) + "\n") - return - _save_bspline_transform_payload(path, payload) - - -def load_transform_payload(path: str | Path) -> TransformPayload: - """Load an affine or B-spline transform payload from disk. - - Parameters - ---------- - path : str or pathlib.Path - Input path. - - Returns - ------- - TransformPayload - Loaded transform payload. - """ - path = Path(path) - if path.suffix == ".zarr": - return _load_bspline_transform_payload(path) - - payload = json.loads(path.read_text()) - if not isinstance(payload, dict): - raise ValueError("Transform file must contain a JSON object.") - - kind = payload.get("kind") - if kind != "affine": - raise ValueError( - "JSON transform files currently support affine payloads only. " - "Use .zarr for B-spline transforms." - ) - get_affine_transform_from_payload(payload) - get_output_grid_from_payload(payload) - return cast("TransformPayload", payload) def _get_affine_payload_from_layer(layer: "Layer") -> AffineTransformPayload | None: @@ -662,22 +156,7 @@ def _make_manual_transform_payload(layer: "Layer") -> AffineTransformPayload: "operation": "manual_napari_transform", "transform_model": "affine", "metric": "manual", - "output_grid": { - "dims": [str(dim) for dim in spatial_data.dims], - "shape": [int(spatial_data.sizes[dim]) for dim in spatial_data.dims], - "spacing": [ - float(spatial_data.fusi.spacing[dim]) for dim in spatial_data.dims - ], - "origin": [ - float(spatial_data.fusi.origin[dim]) for dim in spatial_data.dims - ], - "units": [ - cast("str | None", spatial_data.coords[dim].attrs.get("units")) - if dim in spatial_data.coords - else None - for dim in spatial_data.dims - ], - }, + "output_grid": make_output_grid_payload(spatial_data), "diagnostics": { "metric": "manual", "final_metric_value": 0.0, diff --git a/src/confusius/_napari/_registration/_transform_payloads.py b/src/confusius/_napari/_registration/_transform_payloads.py new file mode 100644 index 00000000..59b8cad8 --- /dev/null +++ b/src/confusius/_napari/_registration/_transform_payloads.py @@ -0,0 +1,521 @@ +"""Transform payload types and serialization helpers for napari registration.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Literal, SupportsFloat, SupportsIndex, TypedDict, cast + +import numpy as np +import numpy.typing as npt +import xarray as xr + +from confusius.registration.bspline import validate_bspline_dataarray + +if TYPE_CHECKING: + from collections.abc import Mapping + + from confusius.registration import RegistrationDiagnostics + + +class TransformDiagnosticsPayload(TypedDict): + """JSON-serializable registration diagnostics summary.""" + + metric: str + final_metric_value: float + n_iterations: int + stop_condition: str + status: str + + +class OutputGridPayload(TypedDict): + """JSON-serializable resampling grid description.""" + + dims: list[str] + shape: list[int] + spacing: list[float] + origin: list[float] + units: list[str | None] + + +class BSplineDataArrayPayload(TypedDict): + """JSON-serializable B-spline control-point DataArray.""" + + dims: list[str] + data: list[object] + coords: dict[str, list[float]] + attrs: dict[str, object] + + +class AffineTransformPayload(TypedDict): + """JSON-serializable affine transform payload used by the napari plugin.""" + + kind: Literal["affine"] + name: str + affine: list[list[float]] + source_layer_name: str + target_layer_name: str + operation: str + transform_model: str + metric: str + output_grid: OutputGridPayload + diagnostics: TransformDiagnosticsPayload + + +class BSplineTransformPayload(TypedDict): + """B-spline transform payload used by the napari plugin.""" + + kind: Literal["bspline"] + name: str + bspline: BSplineDataArrayPayload + source_layer_name: str + target_layer_name: str + operation: str + transform_model: str + metric: str + output_grid: OutputGridPayload + diagnostics: TransformDiagnosticsPayload + + +TransformPayload = AffineTransformPayload | BSplineTransformPayload +"""Union of affine and B-spline transform payloads.""" + + +def make_output_grid_payload(reference: "xr.DataArray") -> OutputGridPayload: + """Return the resampling grid defined by a reference DataArray. + + Parameters + ---------- + reference : xarray.DataArray + Spatial DataArray defining the output grid. + + Returns + ------- + OutputGridPayload + JSON-serializable output-grid description. + """ + dims = [str(dim) for dim in reference.dims] + return { + "dims": dims, + "shape": [int(reference.sizes[dim]) for dim in dims], + "spacing": [float(reference.fusi.spacing[dim]) for dim in dims], + "origin": [float(reference.fusi.origin[dim]) for dim in dims], + "units": [ + cast("str | None", reference.coords[dim].attrs.get("units")) + if dim in reference.coords + else None + for dim in dims + ], + } + + +def _make_diagnostics_payload( + diagnostics: "RegistrationDiagnostics", +) -> TransformDiagnosticsPayload: + """Return a JSON-serializable diagnostics summary. + + Parameters + ---------- + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics to serialize. + + Returns + ------- + TransformDiagnosticsPayload + JSON-serializable diagnostics summary. + """ + return { + "metric": diagnostics.metric, + "final_metric_value": float(diagnostics.final_metric_value), + "n_iterations": int(diagnostics.n_iterations), + "stop_condition": diagnostics.stop_condition, + "status": diagnostics.status, + } + + +def make_affine_transform_payload( + affine: npt.NDArray[np.floating], + *, + reference: "xr.DataArray", + source_layer_name: str, + target_layer_name: str, + operation: str, + transform_model: str, + metric: str, + diagnostics: "RegistrationDiagnostics", + name: str | None = None, +) -> AffineTransformPayload: + """Build a JSON-serializable payload for a registered affine transform. + + Parameters + ---------- + affine : (N+1, N+1) numpy.ndarray + Affine transform in homogeneous coordinates. + reference : xarray.DataArray + Fixed/reference DataArray defining the output resampling grid. + source_layer_name : str + Name of the moving/source layer used when estimating the transform. + target_layer_name : str + Name of the fixed/target layer used when estimating the transform. + operation : str + Registration operation that produced the transform. + transform_model : str + Transform model used during registration. + metric : str + Similarity metric used during registration. + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics. + name : str, optional + Human-friendly transform name. If not provided, a default name is generated. + + Returns + ------- + AffineTransformPayload + JSON-serializable affine transform payload. + """ + affine = np.asarray(affine, dtype=float) + payload_name = ( + name or f"{source_layer_name} → {target_layer_name} ({transform_model})" + ) + return { + "kind": "affine", + "name": payload_name, + "affine": affine.tolist(), + "source_layer_name": source_layer_name, + "target_layer_name": target_layer_name, + "operation": operation, + "transform_model": transform_model, + "metric": metric, + "output_grid": make_output_grid_payload(reference), + "diagnostics": _make_diagnostics_payload(diagnostics), + } + + +def _serialize_bspline_dataarray(transform: "xr.DataArray") -> BSplineDataArrayPayload: + """Return a JSON-serializable B-spline DataArray payload. + + Parameters + ---------- + transform : xarray.DataArray + B-spline control-point grid to serialize. + + Returns + ------- + BSplineDataArrayPayload + JSON-serializable B-spline DataArray payload. + """ + validate_bspline_dataarray(transform) + return { + "dims": [str(dim) for dim in transform.dims], + "data": np.asarray(transform, dtype=float).tolist(), + "coords": { + str(dim): np.asarray(transform.coords[dim], dtype=float).tolist() + for dim in transform.dims + if dim in transform.coords + }, + "attrs": json.loads(json.dumps(transform.attrs)), + } + + +def _deserialize_bspline_dataarray(payload: BSplineDataArrayPayload) -> xr.DataArray: + """Reconstruct a B-spline DataArray from its JSON payload. + + Parameters + ---------- + payload : BSplineDataArrayPayload + JSON payload describing a B-spline control-point grid. + + Returns + ------- + xarray.DataArray + Reconstructed B-spline control-point grid. + """ + dims = [str(dim) for dim in payload["dims"]] + coords = { + str(dim): xr.DataArray(np.asarray(values, dtype=float), dims=[str(dim)]) + for dim, values in payload["coords"].items() + } + transform = xr.DataArray( + np.asarray(payload["data"], dtype=float), + dims=dims, + coords=coords, + attrs=dict(payload["attrs"]), + ) + validate_bspline_dataarray(transform) + return transform + + +def make_bspline_transform_payload( + transform: "xr.DataArray", + *, + reference: "xr.DataArray", + source_layer_name: str, + target_layer_name: str, + operation: str, + transform_model: str, + metric: str, + diagnostics: "RegistrationDiagnostics", + name: str | None = None, +) -> BSplineTransformPayload: + """Build a JSON-serializable payload for a registered B-spline transform. + + Parameters + ---------- + transform : xarray.DataArray + B-spline control-point grid. + reference : xarray.DataArray + Fixed/reference DataArray defining the output resampling grid. + source_layer_name : str + Name of the moving/source layer used when estimating the transform. + target_layer_name : str + Name of the fixed/target layer used when estimating the transform. + operation : str + Registration operation that produced the transform. + transform_model : str + Transform model used during registration. + metric : str + Similarity metric used during registration. + diagnostics : confusius.registration.RegistrationDiagnostics + Per-call registration diagnostics. + name : str, optional + Human-friendly transform name. If not provided, a default name is generated. + + Returns + ------- + BSplineTransformPayload + JSON-serializable B-spline transform payload. + """ + payload_name = ( + name or f"{source_layer_name} → {target_layer_name} ({transform_model})" + ) + return { + "kind": "bspline", + "name": payload_name, + "bspline": _serialize_bspline_dataarray(transform), + "source_layer_name": source_layer_name, + "target_layer_name": target_layer_name, + "operation": operation, + "transform_model": transform_model, + "metric": metric, + "output_grid": make_output_grid_payload(reference), + "diagnostics": _make_diagnostics_payload(diagnostics), + } + + +def get_affine_transform_from_payload( + payload: "Mapping[str, object]", +) -> npt.NDArray[np.float64]: + """Return the affine matrix stored in a payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + (N+1, N+1) numpy.ndarray + Affine matrix. + """ + if payload.get("kind") != "affine": + raise ValueError("Transform payload is not an affine transform.") + + affine = np.asarray(payload.get("affine"), dtype=float) + if affine.ndim != 2 or affine.shape[0] != affine.shape[1] or affine.shape[0] < 3: + raise ValueError( + "Affine payload must contain a square homogeneous matrix of shape " + "(N+1, N+1)." + ) + return affine + + +def get_bspline_transform_from_payload(payload: "Mapping[str, object]") -> xr.DataArray: + """Return the B-spline transform stored in a payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + xarray.DataArray + B-spline control-point grid. + """ + if payload.get("kind") != "bspline": + raise ValueError("Transform payload is not a B-spline transform.") + + bspline = payload.get("bspline") + if not isinstance(bspline, dict): + raise ValueError("B-spline payload must contain a serialized DataArray.") + return _deserialize_bspline_dataarray(cast("BSplineDataArrayPayload", bspline)) + + +def get_output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: + """Return the output grid stored in a transform payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + OutputGridPayload + Output-grid description stored in the payload. + """ + grid = payload.get("output_grid") + if not isinstance(grid, dict): + raise ValueError("Transform payload does not contain an output grid.") + + grid_dict = cast("dict[str, object]", grid) + dims = grid_dict.get("dims") + shape = grid_dict.get("shape") + spacing = grid_dict.get("spacing") + origin = grid_dict.get("origin") + units = grid_dict.get("units") + if not all(isinstance(v, list) for v in (dims, shape, spacing, origin, units)): + raise ValueError("Transform payload output grid is malformed.") + + dims_list = cast("list[object]", dims) + shape_list = cast("list[SupportsIndex]", shape) + spacing_list = cast("list[SupportsFloat]", spacing) + origin_list = cast("list[SupportsFloat]", origin) + units_list = cast("list[object]", units) + + return { + "dims": [str(v) for v in dims_list], + "shape": [int(v) for v in shape_list], + "spacing": [float(v) for v in spacing_list], + "origin": [float(v) for v in origin_list], + "units": [None if v is None else str(v) for v in units_list], + } + + +def _save_bspline_transform_payload( + path: str | Path, payload: BSplineTransformPayload +) -> None: + """Save a B-spline transform payload as Zarr. + + Parameters + ---------- + path : str or pathlib.Path + Output Zarr path. + payload : BSplineTransformPayload + Transform payload to save. + + Raises + ------ + ValueError + If `path` does not have a `.zarr` extension. + """ + path = Path(path) + if path.suffix != ".zarr": + raise ValueError("B-spline transform files must have .zarr extension.") + + transform = get_bspline_transform_from_payload(payload) + ds = transform.to_dataset(name="bspline_transform") + payload_metadata = { + key: value for key, value in payload.items() if key not in {"kind", "bspline"} + } + ds.attrs["confusius_transform_kind"] = "bspline" + ds.attrs["confusius_transform_payload_json"] = json.dumps(payload_metadata) + ds.to_zarr(path, mode="w") + + +def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload: + """Load a B-spline transform payload from Zarr. + + Parameters + ---------- + path : str or pathlib.Path + Input Zarr path. + + Returns + ------- + BSplineTransformPayload + Loaded B-spline transform payload. + """ + ds = xr.open_zarr(path) + try: + if ds.attrs.get("confusius_transform_kind") != "bspline": + raise ValueError( + "Zarr transform store does not contain a ConfUSIus B-spline transform." + ) + payload_metadata = json.loads( + cast("str", ds.attrs["confusius_transform_payload_json"]) + ) + if not isinstance(payload_metadata, dict): + raise ValueError("Stored transform payload metadata is malformed.") + transform = ds["bspline_transform"].load() + finally: + ds.close() + + validate_bspline_dataarray(transform) + payload: BSplineTransformPayload = { + "kind": "bspline", + "bspline": _serialize_bspline_dataarray(transform), + "name": str(payload_metadata["name"]), + "source_layer_name": str(payload_metadata["source_layer_name"]), + "target_layer_name": str(payload_metadata["target_layer_name"]), + "operation": str(payload_metadata["operation"]), + "transform_model": str(payload_metadata["transform_model"]), + "metric": str(payload_metadata["metric"]), + "output_grid": get_output_grid_from_payload(payload_metadata), + "diagnostics": cast( + "TransformDiagnosticsPayload", payload_metadata["diagnostics"] + ), + } + return payload + + +def save_transform_payload(path: str | Path, payload: TransformPayload) -> None: + """Save a transform payload to disk as JSON for affine payloads or Zarr for B-spline payloads. + + Parameters + ---------- + path : str or pathlib.Path + Output path. + payload : TransformPayload + Transform payload to save. + + Notes + ----- + Affine payloads are saved as JSON. B-spline payloads are saved as Zarr. + """ + if payload["kind"] == "affine": + Path(path).write_text(json.dumps(payload, indent=2) + "\n") + return + _save_bspline_transform_payload(path, payload) + + +def load_transform_payload(path: str | Path) -> TransformPayload: + """Load an affine or B-spline transform payload from disk. + + Parameters + ---------- + path : str or pathlib.Path + Input path. + + Returns + ------- + TransformPayload + Loaded transform payload. + """ + path = Path(path) + if path.suffix == ".zarr": + return _load_bspline_transform_payload(path) + + payload = json.loads(path.read_text()) + if not isinstance(payload, dict): + raise ValueError("Transform file must contain a JSON object.") + + kind = payload.get("kind") + if kind != "affine": + raise ValueError( + "JSON transform files currently support affine payloads only. " + "Use .zarr for B-spline transforms." + ) + get_affine_transform_from_payload(payload) + get_output_grid_from_payload(payload) + return cast("TransformPayload", payload) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 865ca328..475cb7e0 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -18,15 +18,17 @@ ) from confusius._napari._registration._panel_results import on_registration_finished from confusius._napari._registration._panel_transforms import ( + apply_selected_transform, + refresh_transform_controls, +) +from confusius._napari._registration._transform_payloads import ( get_affine_transform_from_payload, get_bspline_transform_from_payload, get_output_grid_from_payload, load_transform_payload, make_affine_transform_payload, make_bspline_transform_payload, - refresh_transform_controls, save_transform_payload, - apply_selected_transform, ) from confusius.registration import RegistrationDiagnostics, resample_like From ecd97d3b7f5d3b8d7dba9b2424aaae6ab129eb60 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 2 Jul 2026 11:21:54 +0100 Subject: [PATCH 62/72] refactor(registration): split panel selection --- src/confusius/_napari/_registration/_panel.py | 338 ++------------ .../_napari/_registration/_panel_selection.py | 432 ++++++++++++++++++ 2 files changed, 458 insertions(+), 312 deletions(-) create mode 100644 src/confusius/_napari/_registration/_panel_selection.py diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index bd9146ef..3d6b616d 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -39,6 +39,20 @@ get_registration_parameters, set_registration_parameters, ) +from confusius._napari._registration._panel_selection import ( + current_metric, + current_resample_interpolation, + current_scale_mode, + current_transform_model, + get_layer_by_name, + on_moving_layer_changed, + refresh_layers, + selected_layer, + set_layer_validation_style, + set_run_btn_enabled, + update_reference_time_bounds, + validate_registration_selection, +) from confusius._napari._registration._panel_progress import ( create_volume_progress_plotter, setup_volumewise_progress, @@ -56,7 +70,6 @@ load_transform, refresh_transform_controls, save_selected_transform, - validate_initial_transform_selection, ) from confusius._napari._registration._transform_payloads import TransformPayload from confusius._napari._registration._panel_utils import ( @@ -889,169 +902,13 @@ def _sync_manual_transform_event_connections(self) -> None: layer.events.affine.connect(self._refresh_transform_controls_callback) self._manual_transform_event_layers.append(layer) - def _refresh_layers(self) -> None: - """Repopulate the layer selectors from the viewer.""" - moving_name = self._moving_combo.currentText() - fixed_name = self._fixed_combo.currentText() - - layer_names = [ - layer.name - for layer in self.viewer.layers - if _is_registration_source_layer(layer) - ] - - self._moving_combo.blockSignals(True) - self._fixed_combo.blockSignals(True) - self._moving_combo.clear() - self._fixed_combo.clear() - self._moving_combo.addItems(layer_names) - self._fixed_combo.addItems(layer_names) - self._moving_combo.blockSignals(False) - self._fixed_combo.blockSignals(False) - - moving_index = self._moving_combo.findText(moving_name) - if moving_index >= 0: - self._moving_combo.setCurrentIndex(moving_index) - - fixed_index = self._fixed_combo.findText(fixed_name) - if fixed_index >= 0: - self._fixed_combo.setCurrentIndex(fixed_index) - elif ( - self._fixed_combo.count() > 1 - and self._fixed_combo.currentText() == self._moving_combo.currentText() - ): - self._fixed_combo.setCurrentIndex(1) - - self._update_reference_time_bounds() - self._sync_manual_transform_event_connections() - refresh_transform_controls(self) - self._validate_registration_selection() - - def _get_layer_by_name(self, name: str) -> Layer | None: - """Return a viewer layer by name, if present. - - Parameters - ---------- - name : str - Layer name to look up in the viewer. - - Returns - ------- - napari.layers.Layer or None - Matching layer when present, otherwise `None`. - """ - try: - return cast("Layer", self.viewer.layers[name]) - except KeyError: - return None - - def _selected_layer(self, combo: QComboBox) -> Layer | None: - """Return the currently selected viewer layer for a combo box. - - Parameters - ---------- - combo : QComboBox - Combo box containing layer names. - - Returns - ------- - napari.layers.Layer or None - Selected layer, or `None` when no valid selection exists. - """ - name = combo.currentText() - if not name: - return None - return self._get_layer_by_name(name) - - def _current_scale_mode(self) -> ScaleMode: - """Return the validated registration scale mode from the combo box. - - Returns - ------- - {"off", "dB", "sqrt"} - Selected registration scale mode. - - Raises - ------ - ValueError - If the combo box contains an unexpected value. - """ - value = self._scale_combo.currentData() - if value in {"off", "dB", "sqrt"}: - return value - raise ValueError(f"Unknown registration scale mode: {value!r}.") - - def _current_metric(self) -> MetricName: - """Return the validated registration metric from the combo box. - - Returns - ------- - {"correlation", "mattes_mi"} - Selected registration metric. - - Raises - ------ - ValueError - If the combo box contains an unexpected value. - """ - value = self._metric_combo.currentText() - if value == "correlation": - return "correlation" - if value == "mattes_mi": - return "mattes_mi" - raise ValueError(f"Unknown registration metric: {value!r}.") - - def _current_resample_interpolation(self) -> ResampleInterpolation: - """Return the validated resampling interpolation from the combo box. - - Returns - ------- - {"linear", "bspline"} - Selected resampling interpolation. - - Raises - ------ - ValueError - If the combo box contains an unexpected value. - """ - value = self._interpolation_combo.currentText() - if value == "linear": - return "linear" - if value == "bspline": - return "bspline" - raise ValueError(f"Unknown resampling interpolation: {value!r}.") - - def _current_transform_model(self) -> VolumeTransformType | VolumewiseTransformType: - """Return the validated transform model for the active mode. - - Returns - ------- - {"translation", "rigid", "affine", "bspline"} - Selected transform model, constrained by the active workflow. - - Raises - ------ - ValueError - If the combo box contains an unexpected value. - """ - value = self._transform_combo.currentText() - if self._operation() == "register_volume": - if value == "translation": - return "translation" - if value == "rigid": - return "rigid" - if value == "affine": - return "affine" - if value == "bspline": - return "bspline" - else: - if value == "translation": - return "translation" - if value == "rigid": - return "rigid" - if value == "affine": - return "affine" - raise ValueError(f"Unknown transform model: {value!r}.") + _refresh_layers = refresh_layers + _get_layer_by_name = get_layer_by_name + _selected_layer = selected_layer + _current_scale_mode = current_scale_mode + _current_metric = current_metric + _current_resample_interpolation = current_resample_interpolation + _current_transform_model = current_transform_model def _set_image_layer_data(self, layer: Image, data: npt.NDArray[Any]) -> None: """Assign image data despite the current napari stub mismatch. @@ -1173,154 +1030,11 @@ def _volumewise_moving_preview_layer_name(self) -> str: """Return the napari layer name for the within-scan moving preview.""" return "Moving" - def _update_reference_time_bounds(self) -> None: - """Clamp the volumewise reference-time widget to the moving layer.""" - moving_layer = self._selected_layer(self._moving_combo) - if moving_layer is None: - self._reference_time_spin.setMaximum(0) - self._reference_time_spin.setValue(0) - return - - data = _get_source_dataarray(moving_layer) - if TIME_DIM not in data.dims: - self._reference_time_spin.setMaximum(0) - self._reference_time_spin.setValue(0) - return - - self._reference_time_spin.setMaximum(max(0, data.sizes[TIME_DIM] - 1)) - - def _set_layer_validation_style( - self, - *, - moving_invalid: bool = False, - fixed_invalid: bool = False, - message: str | None = None, - ) -> None: - """Update inline validation state for the layer selectors.""" - error_style = "border: 1px solid #e05555;" - normal_style = "" - self._moving_combo.setStyleSheet( - error_style if moving_invalid else normal_style - ) - self._fixed_combo.setStyleSheet(error_style if fixed_invalid else normal_style) - self._moving_label.setStyleSheet("color: #e05555;" if moving_invalid else "") - self._fixed_label.setStyleSheet("color: #e05555;" if fixed_invalid else "") - self._reference_time_label.setStyleSheet("") - if message: - self._layer_validation.setText(message) - self._layer_validation.show() - else: - self._layer_validation.hide() - self._layer_validation.clear() - - def _set_run_btn_enabled(self, enabled: bool) -> None: - """Enable or disable the Run button without changing its busy text. - - The button is also disabled in `_begin_work` while a registration is - running; this helper only handles the idle-state gating driven by - layer-selection validation. - """ - # Don't override the busy state. - if self._run_btn.text() == "Registering…": - return - self._run_btn.setEnabled(enabled) - - def _validate_registration_selection(self) -> bool: - """Validate the current registration-layer selection and show inline feedback. - - Returns - ------- - bool - `True` when the selection is valid and a registration can be - started, `False` otherwise. As a side effect, the Run button is - enabled/disabled to match the validation result. - """ - moving_layer = self._selected_layer(self._moving_combo) - fixed_layer = self._selected_layer(self._fixed_combo) - operation = self._operation() - - if moving_layer is None: - self._set_layer_validation_style() - self._set_run_btn_enabled(False) - return False - - try: - moving = _get_source_dataarray(moving_layer) - except Exception: - self._set_layer_validation_style( - moving_invalid=True, - message="Could not read the selected moving layer.", - ) - self._set_run_btn_enabled(False) - return False - - if operation == "register_volumewise": - if TIME_DIM not in moving.dims: - self._set_layer_validation_style( - moving_invalid=True, - message="Within-scan registration requires a layer with a time dimension.", - ) - self._set_run_btn_enabled(False) - return False - init_message = validate_initial_transform_selection( - self, - operation=operation, - moving=moving, - ) - self._set_layer_validation_style(message=init_message) - self._set_run_btn_enabled(init_message is None) - return init_message is None - - moving_invalid = False - fixed_invalid = False - message: str | None = None - - if fixed_layer is None: - self._set_layer_validation_style( - moving_invalid=moving_invalid, - fixed_invalid=True, - message="Between-scans registration requires different moving and fixed layers.", - ) - self._set_run_btn_enabled(False) - return False - - try: - fixed = _get_source_dataarray(fixed_layer) - except Exception: - self._set_layer_validation_style( - fixed_invalid=True, - message="Could not read the selected fixed layer.", - ) - self._set_run_btn_enabled(False) - return False - - if fixed_layer is moving_layer: - moving_invalid = True - fixed_invalid = True - message = "Moving and fixed layers must be different." - - if message is None: - message = validate_initial_transform_selection( - self, - operation=operation, - moving=_prepare_between_scan_data(moving), - fixed=_prepare_between_scan_data(fixed), - ) - - valid = not (moving_invalid or fixed_invalid or message is not None) - self._set_layer_validation_style( - moving_invalid=moving_invalid, - fixed_invalid=fixed_invalid, - message=message, - ) - self._set_run_btn_enabled(valid) - return valid - - def _on_moving_layer_changed(self, _name: str) -> None: - """Update dependent widgets when the moving layer changes.""" - self._update_reference_time_bounds() - refresh_transform_controls(self) - self._validate_registration_selection() + _update_reference_time_bounds = update_reference_time_bounds + _set_layer_validation_style = set_layer_validation_style + _set_run_btn_enabled = set_run_btn_enabled + _validate_registration_selection = validate_registration_selection + _on_moving_layer_changed = on_moving_layer_changed def _operation(self) -> Literal["register_volume", "register_volumewise"]: """Return the currently selected registration workflow.""" diff --git a/src/confusius/_napari/_registration/_panel_selection.py b/src/confusius/_napari/_registration/_panel_selection.py new file mode 100644 index 00000000..ccbd8bd4 --- /dev/null +++ b/src/confusius/_napari/_registration/_panel_selection.py @@ -0,0 +1,432 @@ +"""Layer-selection and validation helpers for the napari registration panel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from qtpy.QtWidgets import QComboBox + +from confusius._dims import TIME_DIM +from confusius._napari._registration._panel_transforms import ( + refresh_transform_controls, + validate_initial_transform_selection, +) +from confusius._napari._registration._panel_utils import ( + _get_source_dataarray, + _is_registration_source_layer, + _prepare_between_scan_data, +) + +if TYPE_CHECKING: + from napari.layers import Layer + + from confusius._napari._registration._panel import ( + MetricName, + RegistrationPanel, + ResampleInterpolation, + ScaleMode, + VolumeTransformType, + VolumewiseTransformType, + ) + + +def refresh_layers(panel: "RegistrationPanel") -> None: + """Repopulate the layer selectors from the viewer. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose layer selectors should be refreshed. + """ + moving_name = panel._moving_combo.currentText() + fixed_name = panel._fixed_combo.currentText() + + layer_names = [ + layer.name + for layer in panel.viewer.layers + if _is_registration_source_layer(layer) + ] + + panel._moving_combo.blockSignals(True) + panel._fixed_combo.blockSignals(True) + panel._moving_combo.clear() + panel._fixed_combo.clear() + panel._moving_combo.addItems(layer_names) + panel._fixed_combo.addItems(layer_names) + panel._moving_combo.blockSignals(False) + panel._fixed_combo.blockSignals(False) + + moving_index = panel._moving_combo.findText(moving_name) + if moving_index >= 0: + panel._moving_combo.setCurrentIndex(moving_index) + + fixed_index = panel._fixed_combo.findText(fixed_name) + if fixed_index >= 0: + panel._fixed_combo.setCurrentIndex(fixed_index) + elif ( + panel._fixed_combo.count() > 1 + and panel._fixed_combo.currentText() == panel._moving_combo.currentText() + ): + panel._fixed_combo.setCurrentIndex(1) + + update_reference_time_bounds(panel) + panel._sync_manual_transform_event_connections() + refresh_transform_controls(panel) + validate_registration_selection(panel) + + +def get_layer_by_name(panel: "RegistrationPanel", name: str) -> "Layer | None": + """Return a viewer layer by name, if present. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose viewer should be searched. + name : str + Layer name to look up in the viewer. + + Returns + ------- + napari.layers.Layer or None + Matching layer when present, otherwise `None`. + """ + try: + return cast("Layer", panel.viewer.layers[name]) + except KeyError: + return None + + +def selected_layer(panel: "RegistrationPanel", combo: QComboBox) -> "Layer | None": + """Return the currently selected viewer layer for a combo box. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose viewer should be searched. + combo : QComboBox + Combo box containing layer names. + + Returns + ------- + napari.layers.Layer or None + Selected layer, or `None` when no valid selection exists. + """ + name = combo.currentText() + if not name: + return None + return get_layer_by_name(panel, name) + + +def current_scale_mode(panel: "RegistrationPanel") -> "ScaleMode": + """Return the validated registration scale mode from the combo box. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose scale selector should be read. + + Returns + ------- + {"off", "dB", "sqrt"} + Selected registration scale mode. + + Raises + ------ + ValueError + If the combo box contains an unexpected value. + """ + value = panel._scale_combo.currentData() + if value in {"off", "dB", "sqrt"}: + return value + raise ValueError(f"Unknown registration scale mode: {value!r}.") + + +def current_metric(panel: "RegistrationPanel") -> "MetricName": + """Return the validated registration metric from the combo box. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose metric selector should be read. + + Returns + ------- + {"correlation", "mattes_mi"} + Selected registration metric. + + Raises + ------ + ValueError + If the combo box contains an unexpected value. + """ + value = panel._metric_combo.currentText() + if value == "correlation": + return "correlation" + if value == "mattes_mi": + return "mattes_mi" + raise ValueError(f"Unknown registration metric: {value!r}.") + + +def current_resample_interpolation( + panel: "RegistrationPanel", +) -> "ResampleInterpolation": + """Return the validated resampling interpolation from the combo box. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose interpolation selector should be read. + + Returns + ------- + {"linear", "bspline"} + Selected resampling interpolation. + + Raises + ------ + ValueError + If the combo box contains an unexpected value. + """ + value = panel._interpolation_combo.currentText() + if value == "linear": + return "linear" + if value == "bspline": + return "bspline" + raise ValueError(f"Unknown resampling interpolation: {value!r}.") + + +def current_transform_model( + panel: "RegistrationPanel", +) -> "VolumeTransformType | VolumewiseTransformType": + """Return the validated transform model for the active mode. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose transform selector should be read. + + Returns + ------- + {"translation", "rigid", "affine", "bspline"} + Selected transform model, constrained by the active workflow. + + Raises + ------ + ValueError + If the combo box contains an unexpected value. + """ + value = panel._transform_combo.currentText() + if panel._operation() == "register_volume": + if value == "translation": + return "translation" + if value == "rigid": + return "rigid" + if value == "affine": + return "affine" + if value == "bspline": + return "bspline" + else: + if value == "translation": + return "translation" + if value == "rigid": + return "rigid" + if value == "affine": + return "affine" + raise ValueError(f"Unknown transform model: {value!r}.") + + +def update_reference_time_bounds(panel: "RegistrationPanel") -> None: + """Clamp the volumewise reference-volume widget to the moving layer. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose reference-volume bounds should be updated. + """ + moving_layer = selected_layer(panel, panel._moving_combo) + if moving_layer is None: + panel._reference_time_spin.setMaximum(0) + panel._reference_time_spin.setValue(0) + return + + data = _get_source_dataarray(moving_layer) + if TIME_DIM not in data.dims: + panel._reference_time_spin.setMaximum(0) + panel._reference_time_spin.setValue(0) + return + + panel._reference_time_spin.setMaximum(max(0, data.sizes[TIME_DIM] - 1)) + + +def set_layer_validation_style( + panel: "RegistrationPanel", + *, + moving_invalid: bool = False, + fixed_invalid: bool = False, + message: str | None = None, +) -> None: + """Update inline validation state for the layer selectors. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose validation widgets should be updated. + moving_invalid : bool, default: False + Whether to mark the moving-layer selector as invalid. + fixed_invalid : bool, default: False + Whether to mark the fixed-layer selector as invalid. + message : str, optional + Validation message to show below the layer selectors. + """ + error_style = "border: 1px solid #e05555;" + normal_style = "" + panel._moving_combo.setStyleSheet(error_style if moving_invalid else normal_style) + panel._fixed_combo.setStyleSheet(error_style if fixed_invalid else normal_style) + panel._moving_label.setStyleSheet("color: #e05555;" if moving_invalid else "") + panel._fixed_label.setStyleSheet("color: #e05555;" if fixed_invalid else "") + panel._reference_time_label.setStyleSheet("") + if message: + panel._layer_validation.setText(message) + panel._layer_validation.show() + else: + panel._layer_validation.hide() + panel._layer_validation.clear() + + +def set_run_btn_enabled(panel: "RegistrationPanel", enabled: bool) -> None: + """Enable or disable the Run button without changing its busy text. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose Run button should be updated. + enabled : bool + Whether to enable the idle-state Run button. + + Notes + ----- + The button is also disabled in `_begin_work` while a registration is + running; this helper only handles the idle-state gating driven by + layer-selection validation. + """ + if panel._run_btn.text() == "Registering…": + return + panel._run_btn.setEnabled(enabled) + + +def validate_registration_selection(panel: "RegistrationPanel") -> bool: + """Validate the current registration-layer selection and show inline feedback. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose selection state should be validated. + + Returns + ------- + bool + `True` when the selection is valid and a registration can be started, + `False` otherwise. As a side effect, the Run button is enabled or disabled + to match the validation result. + """ + moving_layer = selected_layer(panel, panel._moving_combo) + fixed_layer = selected_layer(panel, panel._fixed_combo) + operation = panel._operation() + + if moving_layer is None: + set_layer_validation_style(panel) + set_run_btn_enabled(panel, False) + return False + + try: + moving = _get_source_dataarray(moving_layer) + except Exception: + set_layer_validation_style( + panel, + moving_invalid=True, + message="Could not read the selected moving layer.", + ) + set_run_btn_enabled(panel, False) + return False + + if operation == "register_volumewise": + if TIME_DIM not in moving.dims: + set_layer_validation_style( + panel, + moving_invalid=True, + message="Within-scan registration requires a layer with a time dimension.", + ) + set_run_btn_enabled(panel, False) + return False + init_message = validate_initial_transform_selection( + panel, + operation=operation, + moving=moving, + ) + set_layer_validation_style(panel, message=init_message) + set_run_btn_enabled(panel, init_message is None) + return init_message is None + + moving_invalid = False + fixed_invalid = False + message: str | None = None + + if fixed_layer is None: + set_layer_validation_style( + panel, + moving_invalid=moving_invalid, + fixed_invalid=True, + message="Between-scans registration requires different moving and fixed layers.", + ) + set_run_btn_enabled(panel, False) + return False + + try: + fixed = _get_source_dataarray(fixed_layer) + except Exception: + set_layer_validation_style( + panel, + fixed_invalid=True, + message="Could not read the selected fixed layer.", + ) + set_run_btn_enabled(panel, False) + return False + + if fixed_layer is moving_layer: + moving_invalid = True + fixed_invalid = True + message = "Moving and fixed layers must be different." + + if message is None: + message = validate_initial_transform_selection( + panel, + operation=operation, + moving=_prepare_between_scan_data(moving), + fixed=_prepare_between_scan_data(fixed), + ) + + valid = not (moving_invalid or fixed_invalid or message is not None) + set_layer_validation_style( + panel, + moving_invalid=moving_invalid, + fixed_invalid=fixed_invalid, + message=message, + ) + set_run_btn_enabled(panel, valid) + return valid + + +def on_moving_layer_changed(panel: "RegistrationPanel", _name: str) -> None: + """Update dependent widgets when the moving layer changes. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose dependent widgets should be refreshed. + _name : str + Unused emitted layer name from the combo-box signal. + """ + del _name + update_reference_time_bounds(panel) + refresh_transform_controls(panel) + validate_registration_selection(panel) From 7218aede5b6b2e5dec7e1fa77546a280c30fbe7d Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 2 Jul 2026 12:53:48 +0100 Subject: [PATCH 63/72] feat(napari): expose registration masks and tuning --- src/confusius/_napari/_registration/_panel.py | 401 +++++++++++++++++- .../_registration/_panel_parameters.py | 11 + .../_napari/_registration/_panel_selection.py | 25 ++ src/confusius/_napari/_tour.py | 11 +- src/confusius/_napari/_widget.py | 2 +- .../test_napari/test_registration_panel.py | 226 ++++++++++ 6 files changed, 666 insertions(+), 10 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 3d6b616d..7803f43b 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -16,6 +16,7 @@ QDockWidget, QDoubleSpinBox, QFormLayout, + QGridLayout, QGroupBox, QHBoxLayout, QLabel, @@ -30,7 +31,7 @@ QWidget, ) -from confusius._dims import TIME_DIM +from confusius._dims import SPATIAL_DIMS_WITH_POSE, TIME_DIM from confusius._napari._registration._metric_plotter import ( RegistrationMetricPlotter, ) @@ -148,6 +149,9 @@ class ModeParameters(TypedDict): fill_value: float reference_time: int n_jobs: int + sitk_threads: int + optimizer_weights_enabled: bool + optimizer_weights_values: list[float] keep_diagnostics: bool advanced_open: bool @@ -168,6 +172,7 @@ class RegistrationRunPayloadBase(TypedDict): initialization: InitializationSelection shrink_factors: tuple[int, ...] | None smoothing_sigmas: tuple[int, ...] | None + optimizer_weights: list[float] | None keep_diagnostics: bool fill_value: float | None @@ -179,6 +184,9 @@ class VolumeRegistrationRunPayload(RegistrationRunPayloadBase): transform: VolumeTransformType mesh_size: tuple[int, int, int] fixed_layer_name: str + fixed_mask_layer_name: str | None + moving_mask_layer_name: str | None + sitk_threads: int initial_transform_source: NotRequired[str] @@ -200,6 +208,40 @@ class ApplyTransformPayload(TypedDict): transform_source: str +def _get_optimizer_weight_labels( + transform: VolumeTransformType | VolumewiseTransformType, ndim: int +) -> list[str]: + """Return user-facing optimizer-weight labels for one transform model.""" + if transform == "bspline": + return [] + if transform == "translation": + return ["tx", "ty"] if ndim == 2 else ["tx", "ty", "tz"] + if transform == "rigid": + return ( + ["angle", "tx", "ty"] + if ndim == 2 + else ["angleX", "angleY", "angleZ", "tx", "ty", "tz"] + ) + if transform == "affine": + if ndim == 2: + return ["a00", "a01", "a10", "a11", "tx", "ty"] + return [ + "a00", + "a01", + "a02", + "a10", + "a11", + "a12", + "a20", + "a21", + "a22", + "tx", + "ty", + "tz", + ] + raise ValueError(f"Unknown transform model: {transform!r}.") + + class RegistrationPanel(QWidget): """Right-side panel for running registration from napari. @@ -215,6 +257,7 @@ def __init__(self, viewer: napari.Viewer) -> None: self._worker = None self._abort_event: Event | None = None self._loaded_transform_payload: TransformPayload | None = None + self._optimizer_weight_spins: list[QDoubleSpinBox] = [] # Per-run progress state. Set on the GUI thread before the worker starts. self._progress_bridge: NapariProgressBridge | None = None self._progress_layer: Image | None = None @@ -268,6 +311,16 @@ def _make_form_label(self, text: str, *, tooltip: str | None = None) -> QLabel: label.setToolTip(tooltip) return label + def _left_aligned_widget(self, widget: QWidget) -> QWidget: + """Wrap a compact widget so it stays left-aligned inside a form row.""" + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(widget) + layout.addStretch(1) + return container + def _make_advanced_row( self, layout: QFormLayout, @@ -388,6 +441,39 @@ def _setup_ui(self) -> None: ) operation_layout.addRow(self._moving_label, self._moving_combo) + self._moving_mask_combo = QComboBox() + self._moving_mask_combo.setMinimumContentsLength(18) + self._moving_mask_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._moving_mask_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._moving_mask_combo.setToolTip( + "Optional Labels layer used as the moving-image metric mask. Nonzero labels are treated as True." + ) + self._new_moving_mask_btn = QPushButton("+") + self._new_moving_mask_btn.setStyleSheet("font-weight: bold; font-size: 14px;") + self._new_moving_mask_btn.setToolTip( + "Create a new 3D Labels layer (no time axis) aligned to the current image." + ) + self._new_moving_mask_btn.clicked.connect( + lambda: self._create_labels_layer(name="Moving mask") + ) + moving_mask_row = QHBoxLayout() + moving_mask_row.setContentsMargins(0, 0, 0, 0) + moving_mask_row.setSpacing(6) + moving_mask_row.addWidget(self._moving_mask_combo, stretch=1) + moving_mask_row.addWidget(self._new_moving_mask_btn) + moving_mask_container = QWidget() + moving_mask_container.setLayout(moving_mask_row) + self._moving_mask_row = moving_mask_container + self._moving_mask_label = self._make_form_label( + "Moving mask", + tooltip="Optional Labels layer used as the moving-image metric mask. Nonzero labels are treated as True.", + ) + operation_layout.addRow(self._moving_mask_label, moving_mask_container) + self._fixed_label = QLabel("Fixed layer") self._fixed_combo = QComboBox() self._fixed_combo.setMinimumContentsLength(18) @@ -402,6 +488,39 @@ def _setup_ui(self) -> None: ) operation_layout.addRow(self._fixed_label, self._fixed_combo) + self._fixed_mask_combo = QComboBox() + self._fixed_mask_combo.setMinimumContentsLength(18) + self._fixed_mask_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self._fixed_mask_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._fixed_mask_combo.setToolTip( + "Optional Labels layer used as the fixed-image metric mask. Nonzero labels are treated as True." + ) + self._new_fixed_mask_btn = QPushButton("+") + self._new_fixed_mask_btn.setStyleSheet("font-weight: bold; font-size: 14px;") + self._new_fixed_mask_btn.setToolTip( + "Create a new 3D Labels layer (no time axis) aligned to the current image." + ) + self._new_fixed_mask_btn.clicked.connect( + lambda: self._create_labels_layer(name="Fixed mask") + ) + fixed_mask_row = QHBoxLayout() + fixed_mask_row.setContentsMargins(0, 0, 0, 0) + fixed_mask_row.setSpacing(6) + fixed_mask_row.addWidget(self._fixed_mask_combo, stretch=1) + fixed_mask_row.addWidget(self._new_fixed_mask_btn) + fixed_mask_container = QWidget() + fixed_mask_container.setLayout(fixed_mask_row) + self._fixed_mask_row = fixed_mask_container + self._fixed_mask_label = self._make_form_label( + "Fixed mask", + tooltip="Optional Labels layer used as the fixed-image metric mask. Nonzero labels are treated as True.", + ) + operation_layout.addRow(self._fixed_mask_label, fixed_mask_container) + self._reference_time_label = QLabel("Reference volume") self._reference_time_spin = QSpinBox() self._reference_time_spin.setMinimum(0) @@ -413,6 +532,7 @@ def _setup_ui(self) -> None: self._n_jobs_spin = QSpinBox() self._n_jobs_spin.setRange(-128, 128) + self._n_jobs_spin.setMaximumWidth(56) self._n_jobs_spin.setSpecialValueText("auto") self._n_jobs_spin.setToolTip( "Number of workers for time-series registration. -1 uses all CPUs." @@ -621,25 +741,34 @@ def _setup_ui(self) -> None: self._convergence_min_edit = ScientificDoubleSpinBox() self._convergence_min_edit.setRange(1e-10, 1.0) self._convergence_min_edit.setSingleStep(1e-6) + self._convergence_min_edit.setSizePolicy( + QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed + ) + self._convergence_min_edit.setMinimumWidth(260) + self._convergence_min_edit.setMaximumWidth(260) + convergence_min_line_edit = self._convergence_min_edit.lineEdit() + if convergence_min_line_edit is not None: + convergence_min_line_edit.setMinimumWidth(220) self._convergence_min_edit.setToolTip( "Convergence threshold. Accepts decimal (0.000001) or scientific notation (1e-6)." ) self._convergence_min_row = self._make_advanced_row( advanced_layout, "Convergence min", - self._convergence_min_edit, + self._left_aligned_widget(self._convergence_min_edit), tooltip="Convergence threshold below which the optimizer stops early.", ) self._convergence_window_spin = QSpinBox() - self._convergence_window_spin.setRange(1, 100) + self._convergence_window_spin.setRange(1, 1000) + self._convergence_window_spin.setMaximumWidth(40) self._convergence_window_spin.setToolTip( "Number of recent metric values for convergence estimation." ) self._convergence_window_row = self._make_advanced_row( advanced_layout, "Convergence window", - self._convergence_window_spin, + self._left_aligned_widget(self._convergence_window_spin), tooltip="Number of recent metric values used to estimate convergence.", ) @@ -751,6 +880,54 @@ def _setup_ui(self) -> None: tooltip="Number of parallel workers used for within-scan registration. -1 uses all CPUs.", ) + self._sitk_threads_spin = QSpinBox() + self._sitk_threads_spin.setRange(-128, 128) + self._sitk_threads_spin.setMaximumWidth(56) + self._sitk_threads_spin.setSpecialValueText("auto") + self._sitk_threads_spin.setToolTip( + "Number of SimpleITK threads used for between-scan registration. -1 uses all CPUs." + ) + self._sitk_threads_row = self._make_advanced_row( + advanced_layout, + "ITK Threads", + self._left_aligned_widget(self._sitk_threads_spin), + tooltip="Number of SimpleITK threads used for between-scan registration. -1 uses all CPUs.", + ) + + self._optimizer_weights_check = QCheckBox("Custom") + self._optimizer_weights_check.setToolTip( + "Apply per-parameter optimizer weights. 0 freezes a parameter; 1 leaves it unchanged. Not available for B-spline transforms." + ) + self._optimizer_weights_widget = QWidget() + optimizer_layout = QVBoxLayout(self._optimizer_weights_widget) + optimizer_layout.setContentsMargins(0, 0, 0, 0) + optimizer_layout.setSpacing(4) + self._optimizer_weights_fields = QWidget() + self._optimizer_weights_fields_layout = QGridLayout( + self._optimizer_weights_fields + ) + self._optimizer_weights_fields_layout.setContentsMargins(0, 0, 0, 0) + self._optimizer_weights_fields_layout.setHorizontalSpacing(8) + self._optimizer_weights_fields_layout.setVerticalSpacing(6) + optimizer_layout.addWidget(self._optimizer_weights_fields) + self._optimizer_weights_row = QWidget() + optimizer_row_layout = QVBoxLayout(self._optimizer_weights_row) + optimizer_row_layout.setContentsMargins(0, 0, 0, 0) + optimizer_row_layout.setSpacing(4) + optimizer_header_row = QHBoxLayout() + optimizer_header_row.setContentsMargins(0, 0, 0, 0) + optimizer_header_row.setSpacing(8) + self._optimizer_weights_label = self._make_form_label( + "Optimizer weights", + tooltip="Per-parameter weights multiplied into the optimizer step size. 0 freezes a parameter; 1 leaves it unchanged. Not available for B-spline transforms.", + ) + optimizer_header_row.addWidget(self._optimizer_weights_label) + optimizer_header_row.addWidget(self._optimizer_weights_check) + optimizer_header_row.addStretch(1) + optimizer_row_layout.addLayout(optimizer_header_row) + optimizer_row_layout.addWidget(self._optimizer_weights_fields) + advanced_layout.addRow(self._optimizer_weights_row) + advanced_group_layout.addWidget(self._advanced_content) self._advanced_toggle.toggled.connect(self._on_advanced_toggled) self._metric_combo.currentTextChanged.connect( @@ -759,6 +936,12 @@ def _setup_ui(self) -> None: self._transform_combo.currentTextChanged.connect( self._update_transform_dependent_visibility ) + self._transform_combo.currentTextChanged.connect( + lambda _text: self._sync_optimizer_weight_editor() + ) + self._optimizer_weights_check.toggled.connect( + lambda checked: self._optimizer_weights_fields.setVisible(checked) + ) self._on_advanced_toggled(False) self._register_panel = QWidget() @@ -1036,6 +1219,171 @@ def _volumewise_moving_preview_layer_name(self) -> str: _validate_registration_selection = validate_registration_selection _on_moving_layer_changed = on_moving_layer_changed + def _optimizer_weight_values(self) -> list[float]: + """Return the currently visible optimizer-weight values.""" + return [spin.value() for spin in self._optimizer_weight_spins] + + def _infer_registration_spatial_ndim(self) -> int | None: + """Return the spatial dimensionality of the current registration input.""" + moving_layer = self._selected_layer(self._moving_combo) + if moving_layer is None: + return None + try: + data = _get_source_dataarray(moving_layer) + except Exception: + return None + if self._operation() == "register_volume": + data = _prepare_between_scan_data(data) + return len([dim for dim in data.dims if dim != TIME_DIM]) + + def _sync_optimizer_weight_editor( + self, + *, + values: list[float] | None = None, + enabled: bool | None = None, + ) -> None: + """Rebuild the optimizer-weight editor for the current transform.""" + while self._optimizer_weights_fields_layout.count() > 0: + item = self._optimizer_weights_fields_layout.takeAt(0) + if item is None: + continue + widget = item.widget() + if widget is not None: + widget.deleteLater() + self._optimizer_weight_spins = [] + + transform = self._current_transform_model() + ndim = self._infer_registration_spatial_ndim() or 3 + labels = _get_optimizer_weight_labels(transform, ndim) + is_bspline = transform == "bspline" + if enabled is not None: + self._optimizer_weights_check.setChecked(enabled and not is_bspline) + elif is_bspline: + self._optimizer_weights_check.setChecked(False) + + self._optimizer_weights_fields.setVisible( + self._optimizer_weights_check.isChecked() and not is_bspline + ) + + def _make_weight_cell(label: str, value: float) -> QWidget: + cell = QWidget() + cell_layout = QVBoxLayout(cell) + cell_layout.setContentsMargins(0, 0, 0, 0) + cell_layout.setSpacing(2) + title = QLabel(label) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + spin = QDoubleSpinBox() + spin.setRange(0.0, 1e6) + spin.setDecimals(3) + spin.setSingleStep(0.1) + spin.setValue(value) + spin.setToolTip( + "Per-parameter optimizer weight. 0 freezes the parameter; 1 leaves it unchanged." + ) + cell_layout.addWidget(title) + cell_layout.addWidget(spin) + self._optimizer_weight_spins.append(spin) + return cell + + if transform == "affine": + matrix_labels = labels[: ndim * ndim] + translation_labels = labels[ndim * ndim :] + index = 0 + for row in range(ndim): + for col in range(ndim): + value = ( + values[index] + if values is not None and index < len(values) + else 1.0 + ) + self._optimizer_weights_fields_layout.addWidget( + _make_weight_cell(matrix_labels[index], value), row, col + ) + index += 1 + for col, label in enumerate(translation_labels): + value = ( + values[index] if values is not None and index < len(values) else 1.0 + ) + self._optimizer_weights_fields_layout.addWidget( + _make_weight_cell(label, value), ndim, col + ) + index += 1 + return + + if transform == "rigid": + split_index = 1 if ndim == 2 else 3 + angle_labels = labels[:split_index] + translation_labels = labels[split_index:] + index = 0 + for col, label in enumerate(angle_labels): + value = ( + values[index] if values is not None and index < len(values) else 1.0 + ) + self._optimizer_weights_fields_layout.addWidget( + _make_weight_cell(label, value), 0, col + ) + index += 1 + for col, label in enumerate(translation_labels): + value = ( + values[index] if values is not None and index < len(values) else 1.0 + ) + self._optimizer_weights_fields_layout.addWidget( + _make_weight_cell(label, value), 1, col + ) + index += 1 + return + + for index, label in enumerate(labels): + value = values[index] if values is not None and index < len(values) else 1.0 + self._optimizer_weights_fields_layout.addWidget( + _make_weight_cell(label, value), 0, index + ) + + def _spatial_info( + self, + ) -> tuple[ + tuple[int, ...] | None, tuple[float, ...] | None, tuple[float, ...] | None + ]: + """Return shape, scale, and translation for the first spatial image layer.""" + for layer in self.viewer.layers: + if layer._type_string != "image": + continue + da = layer.metadata.get("xarray") + if da is not None: + spatial_indices = [ + i for i, dim in enumerate(da.dims) if dim in SPATIAL_DIMS_WITH_POSE + ] + if not spatial_indices: + continue + shape = tuple(da.shape[i] for i in spatial_indices) + scale = tuple(float(layer.scale[i]) for i in spatial_indices) + translate = tuple(float(layer.translate[i]) for i in spatial_indices) + return shape, scale, translate + if layer.data.ndim >= 4: + return ( + layer.data.shape[1:], + tuple(float(s) for s in layer.scale[1:]), + tuple(float(t) for t in layer.translate[1:]), + ) + return None, None, None + + def _create_labels_layer(self, *, name: str = "Labels (3D)") -> None: + """Add a new Labels layer aligned to the current spatial image grid.""" + import numpy as np + + shape, scale, translate = self._spatial_info() + shape = shape or (64, 64, 64) + kwargs: dict[str, object] = {} + if scale is not None: + kwargs["scale"] = scale + if translate is not None: + kwargs["translate"] = translate + self.viewer.add_labels( + np.zeros(shape, dtype=np.int32), + name=name, + **kwargs, + ) + def _operation(self) -> Literal["register_volume", "register_volumewise"]: """Return the currently selected registration workflow.""" if self._time_series_radio.isChecked(): @@ -1065,10 +1413,17 @@ def _update_multi_resolution_enabled(self, checked: bool) -> None: self._smoothing_sigmas_row.setVisible(checked) def _update_transform_dependent_visibility(self, transform: str) -> None: - """Show or hide transform-specific basic parameters.""" + """Show or hide transform-specific parameter inputs.""" + is_bspline = transform == "bspline" self._mesh_size_row.setVisible( - self._operation() == "register_volume" and transform == "bspline" + self._operation() == "register_volume" and is_bspline ) + self._optimizer_weights_check.setEnabled(not is_bspline) + self._optimizer_weights_row.setVisible( + self._operation() == "register_volume" or not is_bspline + ) + if is_bspline: + self._optimizer_weights_fields.hide() def _on_mode_changed(self) -> None: """Update the panel when the registration mode changes.""" @@ -1084,9 +1439,14 @@ def _on_mode_changed(self) -> None: self._fixed_label.setVisible(not is_volumewise) self._fixed_combo.setVisible(not is_volumewise) self._fixed_combo.setEnabled(not is_volumewise) + self._fixed_mask_label.setVisible(not is_volumewise) + self._fixed_mask_row.setVisible(not is_volumewise) + self._moving_mask_label.setVisible(not is_volumewise) + self._moving_mask_row.setVisible(not is_volumewise) self._reference_time_label.setVisible(is_volumewise) self._reference_time_spin.setVisible(is_volumewise) self._n_jobs_row.setVisible(is_volumewise) + self._sitk_threads_row.setVisible(not is_volumewise) self._learning_rate_auto_check.setVisible(not is_volumewise) self._fill_value_row.setVisible(not is_volumewise) @@ -1193,6 +1553,11 @@ def _run_registration(self) -> None: initialization = cast( "InitializationSelection", self._initialization_combo.currentData() ) + optimizer_weights = ( + self._optimizer_weight_values() + if self._optimizer_weights_check.isChecked() and transform != "bspline" + else None + ) self._abort_event = Event() if operation == "register_volume": @@ -1209,6 +1574,20 @@ def _run_registration(self) -> None: self._set_error(str(exc)) return + fixed_mask = None + fixed_mask_layer_name = None + fixed_mask_layer = self._selected_layer(self._fixed_mask_combo) + if fixed_mask_layer is not None: + fixed_mask = _get_source_dataarray(fixed_mask_layer) > 0 + fixed_mask_layer_name = fixed_mask_layer.name + + moving_mask = None + moving_mask_layer_name = None + moving_mask_layer = self._selected_layer(self._moving_mask_combo) + if moving_mask_layer is not None: + moving_mask = _get_source_dataarray(moving_mask_layer) > 0 + moving_mask_layer_name = moving_mask_layer.name + moving = _prepare_between_scan_data(moving) fixed = _prepare_between_scan_data(fixed) moving = _apply_registration_scale(moving, scale_mode) @@ -1247,6 +1626,7 @@ def _run_registration(self) -> None: "initialization": initialization, "shrink_factors": shrink_factors, "smoothing_sigmas": smoothing_sigmas, + "optimizer_weights": optimizer_weights, "keep_diagnostics": self._keep_diagnostics_check.isChecked(), "fill_value": None if self._fill_value_auto_check.isChecked() @@ -1257,6 +1637,9 @@ def _run_registration(self) -> None: self._mesh_size_x_spin.value(), ), "fixed_layer_name": fixed_layer.name, + "fixed_mask_layer_name": fixed_mask_layer_name, + "moving_mask_layer_name": moving_mask_layer_name, + "sitk_threads": self._sitk_threads_spin.value(), } if initial_transform_source is not None: volume_payload["initial_transform_source"] = initial_transform_source @@ -1290,6 +1673,8 @@ def _run_registration(self) -> None: worker = thread_worker(register_volume)( moving, fixed, + fixed_mask=fixed_mask, + moving_mask=moving_mask, transform_type=volume_payload["transform"], metric=volume_payload["metric"], learning_rate=learning_rate, @@ -1302,9 +1687,11 @@ def _run_registration(self) -> None: convergence_minimum_value=volume_payload["convergence_minimum_value"], convergence_window_size=volume_payload["convergence_window_size"], initialization=initialization_arg, + optimizer_weights=volume_payload["optimizer_weights"], shrink_factors=volume_payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=volume_payload["smoothing_sigmas"] or (6, 2, 1), fill_value=volume_payload["fill_value"], + sitk_threads=volume_payload["sitk_threads"], show_progress=True, progress_plotter=progress_plotter, abort_event=self._abort_event, @@ -1342,6 +1729,7 @@ def _run_registration(self) -> None: "initialization": initialization, "shrink_factors": shrink_factors, "smoothing_sigmas": smoothing_sigmas, + "optimizer_weights": optimizer_weights, "keep_diagnostics": self._keep_diagnostics_check.isChecked(), "fill_value": None if self._fill_value_auto_check.isChecked() @@ -1384,6 +1772,7 @@ def _run_registration(self) -> None: ], convergence_window_size=volumewise_payload["convergence_window_size"], initialization=get_selected_center_initialization(self), + optimizer_weights=volumewise_payload["optimizer_weights"], shrink_factors=volumewise_payload["shrink_factors"] or (6, 2, 1), smoothing_sigmas=volumewise_payload["smoothing_sigmas"] or (6, 2, 1), keep_diagnostics=volumewise_payload["keep_diagnostics"], diff --git a/src/confusius/_napari/_registration/_panel_parameters.py b/src/confusius/_napari/_registration/_panel_parameters.py index 07009a47..0270ad62 100644 --- a/src/confusius/_napari/_registration/_panel_parameters.py +++ b/src/confusius/_napari/_registration/_panel_parameters.py @@ -48,6 +48,9 @@ def get_default_registration_parameters( "fill_value": 0.0, "reference_time": 0, "n_jobs": -1, + "sitk_threads": -1, + "optimizer_weights_enabled": False, + "optimizer_weights_values": [], "keep_diagnostics": False, "advanced_open": False, } @@ -90,6 +93,9 @@ def get_registration_parameters(panel: "RegistrationPanel") -> "ModeParameters": "fill_value": panel._fill_value_spin.value(), "reference_time": panel._reference_time_spin.value(), "n_jobs": panel._n_jobs_spin.value(), + "sitk_threads": panel._sitk_threads_spin.value(), + "optimizer_weights_enabled": panel._optimizer_weights_check.isChecked(), + "optimizer_weights_values": panel._optimizer_weight_values(), "keep_diagnostics": panel._keep_diagnostics_check.isChecked(), "advanced_open": panel._advanced_toggle.isChecked(), } @@ -157,9 +163,14 @@ def set_registration_parameters( panel._fill_value_spin.setValue(params["fill_value"]) panel._reference_time_spin.setValue(params["reference_time"]) panel._n_jobs_spin.setValue(params["n_jobs"]) + panel._sitk_threads_spin.setValue(params["sitk_threads"]) panel._keep_diagnostics_check.setChecked(params["keep_diagnostics"]) panel._advanced_toggle.setChecked(params["advanced_open"]) panel._on_advanced_toggled(panel._advanced_toggle.isChecked()) panel._update_metric_dependent_visibility(panel._metric_combo.currentText()) panel._update_multi_resolution_enabled(panel._multi_resolution_check.isChecked()) panel._update_transform_dependent_visibility(panel._transform_combo.currentText()) + panel._sync_optimizer_weight_editor( + values=params.get("optimizer_weights_values"), + enabled=params.get("optimizer_weights_enabled", False), + ) diff --git a/src/confusius/_napari/_registration/_panel_selection.py b/src/confusius/_napari/_registration/_panel_selection.py index ccbd8bd4..03c1e919 100644 --- a/src/confusius/_napari/_registration/_panel_selection.py +++ b/src/confusius/_napari/_registration/_panel_selection.py @@ -40,21 +40,36 @@ def refresh_layers(panel: "RegistrationPanel") -> None: """ moving_name = panel._moving_combo.currentText() fixed_name = panel._fixed_combo.currentText() + fixed_mask_name = panel._fixed_mask_combo.currentText() + moving_mask_name = panel._moving_mask_combo.currentText() layer_names = [ layer.name for layer in panel.viewer.layers if _is_registration_source_layer(layer) ] + labels_layer_names = [ + layer.name for layer in panel.viewer.layers if layer._type_string == "labels" + ] panel._moving_combo.blockSignals(True) panel._fixed_combo.blockSignals(True) + panel._fixed_mask_combo.blockSignals(True) + panel._moving_mask_combo.blockSignals(True) panel._moving_combo.clear() panel._fixed_combo.clear() + panel._fixed_mask_combo.clear() + panel._moving_mask_combo.clear() panel._moving_combo.addItems(layer_names) panel._fixed_combo.addItems(layer_names) + panel._fixed_mask_combo.addItem("") + panel._moving_mask_combo.addItem("") + panel._fixed_mask_combo.addItems(labels_layer_names) + panel._moving_mask_combo.addItems(labels_layer_names) panel._moving_combo.blockSignals(False) panel._fixed_combo.blockSignals(False) + panel._fixed_mask_combo.blockSignals(False) + panel._moving_mask_combo.blockSignals(False) moving_index = panel._moving_combo.findText(moving_name) if moving_index >= 0: @@ -69,9 +84,18 @@ def refresh_layers(panel: "RegistrationPanel") -> None: ): panel._fixed_combo.setCurrentIndex(1) + fixed_mask_index = panel._fixed_mask_combo.findText(fixed_mask_name) + if fixed_mask_index >= 0: + panel._fixed_mask_combo.setCurrentIndex(fixed_mask_index) + + moving_mask_index = panel._moving_mask_combo.findText(moving_mask_name) + if moving_mask_index >= 0: + panel._moving_mask_combo.setCurrentIndex(moving_mask_index) + update_reference_time_bounds(panel) panel._sync_manual_transform_event_connections() refresh_transform_controls(panel) + panel._sync_optimizer_weight_editor() validate_registration_selection(panel) @@ -429,4 +453,5 @@ def on_moving_layer_changed(panel: "RegistrationPanel", _name: str) -> None: del _name update_reference_time_bounds(panel) refresh_transform_controls(panel) + panel._sync_optimizer_weight_editor() validate_registration_selection(panel) diff --git a/src/confusius/_napari/_tour.py b/src/confusius/_napari/_tour.py index e40ed22a..970b1cd8 100644 --- a/src/confusius/_napari/_tour.py +++ b/src/confusius/_napari/_tour.py @@ -1010,10 +1010,11 @@ def _restore_state() -> None: ), TourStep( target=_panel_attr("Registration", RegistrationPanel, "_moving_combo"), - title="Moving and Fixed Layers", + title="Moving, Masks, and Fixed Layers", body=( - "For Between scans, choose the Moving layer to align " - "and the Fixed layer that defines the target space." + "For Between scans, choose the Moving layer to align, " + "optional Moving/Fixed mask label layers to restrict the " + "metric, and the Fixed layer that defines the target space." ), anchor="left", spotlight_rect=_panel_attr_rect( @@ -1021,8 +1022,12 @@ def _restore_state() -> None: RegistrationPanel, "_moving_label", "_moving_combo", + "_moving_mask_label", + "_moving_mask_row", "_fixed_label", "_fixed_combo", + "_fixed_mask_label", + "_fixed_mask_row", ), tooltip_target=_dock_widget, pre_action=_run_actions( diff --git a/src/confusius/_napari/_widget.py b/src/confusius/_napari/_widget.py index 5eba4c42..557df17d 100644 --- a/src/confusius/_napari/_widget.py +++ b/src/confusius/_napari/_widget.py @@ -243,7 +243,7 @@ class ConfUSIusWidget(QWidget): def __init__(self, napari_viewer: napari.Viewer) -> None: super().__init__() self.viewer = napari_viewer - self.setMinimumWidth(400) + self.setMinimumWidth(430) self.setSizePolicy( QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding, diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 475cb7e0..c0cecb2c 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -89,6 +89,18 @@ def test_combo_populated_on_layer_add(self, viewer, registration_panel): assert registration_panel._moving_combo.count() == 1 assert registration_panel._moving_combo.itemText(0) == "vol" + def test_mask_combos_only_list_labels_layers(self, viewer, registration_panel): + viewer.add_image(np.zeros((4, 6, 8)), name="vol") + viewer.add_labels(np.zeros((4, 6, 8), dtype=np.int32), name="mask") + + registration_panel._refresh_layers() + + assert registration_panel._fixed_mask_combo.count() == 2 + assert registration_panel._fixed_mask_combo.itemText(0) == "" + assert registration_panel._fixed_mask_combo.itemText(1) == "mask" + assert registration_panel._moving_mask_combo.count() == 2 + assert registration_panel._moving_mask_combo.itemText(1) == "mask" + def test_ignores_lazy_non_numpy_layers(self, viewer, registration_panel): import dask.array as da @@ -125,6 +137,32 @@ def test_parallel_jobs_is_in_advanced_parameters(self, registration_panel): assert not registration_panel._n_jobs_row.isHidden() assert registration_panel._n_jobs_spin.parent() is not None + def test_between_scan_shows_masks_and_sitk_threads(self, registration_panel): + registration_panel._advanced_toggle.setChecked(True) + + assert not registration_panel._fixed_mask_row.isHidden() + assert not registration_panel._moving_mask_row.isHidden() + assert not registration_panel._sitk_threads_row.isHidden() + + registration_panel._time_series_radio.setChecked(True) + + assert registration_panel._fixed_mask_row.isHidden() + assert registration_panel._moving_mask_row.isHidden() + assert registration_panel._sitk_threads_row.isHidden() + + def test_operation_row_order_is_moving_moving_mask_fixed_fixed_mask( + self, registration_panel + ): + layout = registration_panel._register_panel.layout().itemAt(0).widget().layout() + labels = [ + layout.itemAt(i, layout.ItemRole.LabelRole).widget().text() + for i in range(layout.rowCount()) + if layout.itemAt(i, layout.ItemRole.LabelRole) is not None + ] + assert labels.index("Moving layer") < labels.index("Moving mask") + assert labels.index("Moving mask") < labels.index("Fixed layer") + assert labels.index("Fixed layer") < labels.index("Fixed mask") + def test_transform_target_label_is_apply_to(self, registration_panel): label = registration_panel._transforms_panel.layout().labelForField( registration_panel._transform_target_combo @@ -323,6 +361,7 @@ def test_mesh_size_is_basic_and_only_visible_for_bspline(self, registration_pane registration_panel._transform_combo.setCurrentText("bspline") assert not registration_panel._mesh_size_row.isHidden() + assert not registration_panel._optimizer_weights_check.isEnabled() registration_panel._mesh_size_z_spin.setValue(5) registration_panel._mesh_size_y_spin.setValue(7) @@ -333,12 +372,56 @@ def test_mesh_size_is_basic_and_only_visible_for_bspline(self, registration_pane registration_panel._transform_combo.setCurrentText("rigid") assert registration_panel._mesh_size_row.isHidden() + assert registration_panel._optimizer_weights_check.isEnabled() registration_panel._time_series_radio.setChecked(True) assert registration_panel._mesh_size_row.isHidden() class TestRunRegistration: + def test_create_labels_layer_matches_spatial_shape_of_time_series( + self, viewer, registration_panel + ): + moving = xr.DataArray( + np.zeros((2, 4, 6, 8), dtype=np.float32), + dims=["time", "z", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(2), dims=["time"]), + "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), + "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), + }, + ) + layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + layer.scale = (1.0, 0.3, 0.2, 0.1) + layer.translate = (0.0, 1.0, 2.0, 3.0) + + registration_panel._create_labels_layer() + + labels = viewer.layers["Labels (3D)"] + assert np.asarray(labels.data).shape == (4, 6, 8) + assert tuple(labels.scale) == (0.3, 0.2, 0.1) + assert tuple(labels.translate) == (1.0, 2.0, 3.0) + + def test_mask_buttons_create_named_layers(self, viewer, registration_panel): + moving = xr.DataArray( + np.zeros((2, 4, 6, 8), dtype=np.float32), + dims=["time", "z", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(2), dims=["time"]), + "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), + "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), + }, + ) + viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + + registration_panel._new_moving_mask_btn.click() + registration_panel._new_fixed_mask_btn.click() + + assert "Moving mask" in viewer.layers + assert "Fixed mask" in viewer.layers + def test_between_scan_run_uses_selected_initial_transform( self, viewer, registration_panel, monkeypatch ): @@ -530,6 +613,149 @@ def _runner(*args, **kwargs): assert args[0].dims == ("z", "y", "x") assert registration_panel._worker is not None + def test_between_scan_run_passes_masks_sitk_threads_and_optimizer_weights( + self, viewer, registration_panel, monkeypatch + ): + moving = xr.DataArray( + np.zeros((4, 6, 8), dtype=np.float32), + dims=["z", "y", "x"], + coords={ + "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), + "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), + }, + ) + fixed = xr.DataArray( + np.ones((4, 6, 8), dtype=np.float32), + dims=["z", "y", "x"], + coords=moving.coords, + ) + viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + viewer.add_image(fixed.values, name="fixed", metadata={"xarray": fixed}) + viewer.add_labels(np.ones((4, 6, 8), dtype=np.int32), name="fixed mask") + viewer.add_labels(np.ones((4, 6, 8), dtype=np.int32), name="moving mask") + registration_panel._refresh_layers() + registration_panel._moving_combo.setCurrentText("moving") + registration_panel._fixed_combo.setCurrentText("fixed") + registration_panel._fixed_mask_combo.setCurrentText("fixed mask") + registration_panel._moving_mask_combo.setCurrentText("moving mask") + registration_panel._sitk_threads_spin.setValue(3) + registration_panel._optimizer_weights_check.setChecked(True) + assert len(registration_panel._optimizer_weight_spins) == 6 + for spin, value in zip( + registration_panel._optimizer_weight_spins, + [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + strict=False, + ): + spin.setValue(value) + + captured: dict[str, object] = {} + + class _FakeSignal: + def connect(self, _slot): + return None + + class _FakeWorker: + def __init__(self) -> None: + self.returned = _FakeSignal() + self.errored = _FakeSignal() + self.finished = _FakeSignal() + + def start(self) -> None: + return None + + def _fake_thread_worker(func): + def _runner(*args, **kwargs): + captured["func"] = func + captured["args"] = args + captured["kwargs"] = kwargs + return _FakeWorker() + + return _runner + + monkeypatch.setattr( + "confusius._napari._registration._panel.thread_worker", + _fake_thread_worker, + ) + monkeypatch.setattr( + "confusius._napari._registration._panel.create_volume_progress_plotter", + lambda *_args, **_kwargs: None, + ) + + registration_panel._run_registration() + + kwargs = cast("dict[str, Any]", captured["kwargs"]) + assert kwargs["sitk_threads"] == 3 + assert kwargs["optimizer_weights"] == [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + assert kwargs["fixed_mask"].dtype == bool + assert kwargs["moving_mask"].dtype == bool + assert registration_panel._worker is not None + + def test_volumewise_run_passes_optimizer_weights( + self, viewer, registration_panel, monkeypatch + ): + moving = xr.DataArray( + np.zeros((2, 4, 6, 8), dtype=np.float32), + dims=["time", "z", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(2), dims=["time"]), + "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), + "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), + }, + ) + viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + registration_panel._time_series_radio.setChecked(True) + registration_panel._refresh_layers() + registration_panel._moving_combo.setCurrentText("moving") + registration_panel._transform_combo.setCurrentText("translation") + registration_panel._optimizer_weights_check.setChecked(True) + for spin, value in zip( + registration_panel._optimizer_weight_spins, + [0.7, 0.8, 0.9], + strict=False, + ): + spin.setValue(value) + + captured: dict[str, object] = {} + + class _FakeSignal: + def connect(self, _slot): + return None + + class _FakeWorker: + def __init__(self) -> None: + self.returned = _FakeSignal() + self.errored = _FakeSignal() + self.finished = _FakeSignal() + + def start(self) -> None: + return None + + def _fake_thread_worker(func): + def _runner(*args, **kwargs): + captured["func"] = func + captured["args"] = args + captured["kwargs"] = kwargs + return _FakeWorker() + + return _runner + + monkeypatch.setattr( + "confusius._napari._registration._panel.thread_worker", + _fake_thread_worker, + ) + monkeypatch.setattr( + "confusius._napari._registration._panel.setup_volumewise_progress", + lambda *_args, **_kwargs: None, + ) + + registration_panel._run_registration() + + kwargs = cast("dict[str, Any]", captured["kwargs"]) + assert kwargs["optimizer_weights"] == [0.7, 0.8, 0.9] + assert registration_panel._worker is not None + class TestAbort: def test_abort_sets_cancellation_event(self, registration_panel): From 565dcaac694c2b31a639a556d150280fd3265c6a Mon Sep 17 00:00:00 2001 From: Felipe Cybis Pereira Date: Thu, 2 Jul 2026 15:36:14 +0200 Subject: [PATCH 64/72] layout refactoring fixing possible overflows and form rows that are not defaulting to oneline --- src/confusius/_napari/_registration/_panel.py | 109 +++++++++++------- .../test_napari/test_registration_panel.py | 36 ++++-- 2 files changed, 98 insertions(+), 47 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 7803f43b..8497d10a 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -40,6 +40,14 @@ get_registration_parameters, set_registration_parameters, ) +from confusius._napari._registration._panel_progress import ( + create_volume_progress_plotter, + setup_volumewise_progress, +) +from confusius._napari._registration._panel_results import ( + on_volume_registration_finished, + on_volumewise_registration_finished, +) from confusius._napari._registration._panel_selection import ( current_metric, current_resample_interpolation, @@ -54,15 +62,6 @@ update_reference_time_bounds, validate_registration_selection, ) -from confusius._napari._registration._panel_progress import ( - create_volume_progress_plotter, - setup_volumewise_progress, -) -from confusius._napari._registration._panel_results import ( - on_volume_registration_finished, - on_volumewise_registration_finished, -) -from confusius._napari._registration._panel_worker_state import on_registration_failed from confusius._napari._registration._panel_transforms import ( apply_selected_transform, get_available_transform_payloads, @@ -72,7 +71,6 @@ refresh_transform_controls, save_selected_transform, ) -from confusius._napari._registration._transform_payloads import TransformPayload from confusius._napari._registration._panel_utils import ( ScientificDoubleSpinBox, _apply_registration_scale, @@ -81,10 +79,12 @@ _parse_comma_separated_ints, _prepare_between_scan_data, ) +from confusius._napari._registration._panel_worker_state import on_registration_failed from confusius._napari._registration._progress import ( NapariProgressBridge, NapariRegistrationProgressReporterBridge, ) +from confusius._napari._registration._transform_payloads import TransformPayload from confusius.registration import register_volume, register_volumewise if TYPE_CHECKING: @@ -312,13 +312,28 @@ def _make_form_label(self, text: str, *, tooltip: str | None = None) -> QLabel: return label def _left_aligned_widget(self, widget: QWidget) -> QWidget: - """Wrap a compact widget so it stays left-aligned inside a form row.""" + """Wrap a compact widget so it stays left-aligned inside a form row. + + The trailing spacer has stretch 0, so an Expanding widget with a + maximum width still grows up to its cap before the spacer absorbs the + remainder; fixed-size widgets stay at their size hint. + + Parameters + ---------- + widget : QWidget + Widget to wrap. + + Returns + ------- + QWidget + Container holding `widget` left-aligned. + """ container = QWidget() layout = QHBoxLayout(container) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - layout.addWidget(widget) - layout.addStretch(1) + layout.addWidget(widget, stretch=1) + layout.addStretch() return container def _make_advanced_row( @@ -348,13 +363,19 @@ def _make_advanced_row( Container widget added to `layout`. """ container = QWidget() - row_layout = QHBoxLayout(container) + # A one-row QFormLayout (not QHBoxLayout) so the label can wrap above + # the field on narrow docks: an HBox row's minimum width is label + + # field, which forced horizontal overflow when the advanced section + # opened (see issue #183 for the same overflow in the signals panel). + row_layout = QFormLayout(container) row_layout.setContentsMargins(0, 0, 0, 0) - row_layout.setSpacing(8) + row_layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) + row_layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow + ) lbl = self._make_form_label(label, tooltip=tooltip) lbl.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - row_layout.addWidget(lbl) - row_layout.addWidget(widget, stretch=1) + row_layout.addRow(lbl, widget) layout.addRow(container) return container @@ -428,7 +449,6 @@ def _setup_ui(self) -> None: self._moving_label = QLabel("Moving layer") self._moving_combo = QComboBox() - self._moving_combo.setMinimumContentsLength(18) self._moving_combo.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) @@ -442,7 +462,6 @@ def _setup_ui(self) -> None: operation_layout.addRow(self._moving_label, self._moving_combo) self._moving_mask_combo = QComboBox() - self._moving_mask_combo.setMinimumContentsLength(18) self._moving_mask_combo.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) @@ -476,7 +495,6 @@ def _setup_ui(self) -> None: self._fixed_label = QLabel("Fixed layer") self._fixed_combo = QComboBox() - self._fixed_combo.setMinimumContentsLength(18) self._fixed_combo.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) @@ -489,7 +507,6 @@ def _setup_ui(self) -> None: operation_layout.addRow(self._fixed_label, self._fixed_combo) self._fixed_mask_combo = QComboBox() - self._fixed_mask_combo.setMinimumContentsLength(18) self._fixed_mask_combo.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) @@ -531,6 +548,12 @@ def _setup_ui(self) -> None: operation_layout.addRow(self._reference_time_label, self._reference_time_spin) self._n_jobs_spin = QSpinBox() + # Expanding: QFormLayout's ExpandingFieldsGrow only grows fields whose + # policy is Expanding/MinimumExpanding, and spinboxes are not by + # default — they would otherwise stay at hint width in advanced rows. + self._n_jobs_spin.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) self._n_jobs_spin.setRange(-128, 128) self._n_jobs_spin.setMaximumWidth(56) self._n_jobs_spin.setSpecialValueText("auto") @@ -642,7 +665,6 @@ def _setup_ui(self) -> None: ) self._initialization_combo = QComboBox() - self._initialization_combo.setMinimumContentsLength(18) self._initialization_combo.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) @@ -668,7 +690,6 @@ def _setup_ui(self) -> None: self._learning_rate_edit.setToolTip( "Optimizer step size. Accepts decimal (0.1) or scientific notation (1e-5)." ) - self._learning_rate_edit.setMaximumWidth(96) self._learning_rate_edit.setEnabled(False) self._learning_rate_auto_check.toggled.connect( lambda checked: self._learning_rate_edit.setEnabled(not checked) @@ -698,7 +719,7 @@ def _setup_ui(self) -> None: self._advanced_group = QWidget() advanced_group_layout = QVBoxLayout(self._advanced_group) - advanced_group_layout.setContentsMargins(6, 6, 6, 6) + advanced_group_layout.setContentsMargins(0, 6, 0, 6) advanced_group_layout.setSpacing(6) advanced_header = QWidget() @@ -720,6 +741,11 @@ def _setup_ui(self) -> None: self._advanced_content = QWidget() advanced_layout = QFormLayout(self._advanced_content) + # Left indent + top gap set the advanced rows apart from the main + # parameters. Keep the right margin at 0: side margins add 1:1 to the + # panel's minimum width, and the left indent is only "free" because + # the group layout below carries no right margin either. + advanced_layout.setContentsMargins(9, 6, 0, 0) advanced_layout.setSpacing(6) advanced_layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) advanced_layout.setFieldGrowthPolicy( @@ -727,6 +753,9 @@ def _setup_ui(self) -> None: ) self._histogram_bins_spin = QSpinBox() + self._histogram_bins_spin.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) self._histogram_bins_spin.setRange(8, 512) self._histogram_bins_spin.setToolTip( "Number of histogram bins for Mattes mutual information metric." @@ -739,16 +768,16 @@ def _setup_ui(self) -> None: ) self._convergence_min_edit = ScientificDoubleSpinBox() - self._convergence_min_edit.setRange(1e-10, 1.0) - self._convergence_min_edit.setSingleStep(1e-6) self._convergence_min_edit.setSizePolicy( - QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - self._convergence_min_edit.setMinimumWidth(260) + self._convergence_min_edit.setRange(1e-10, 1.0) + self._convergence_min_edit.setSingleStep(1e-6) + # Cap the width instead of setting a hard minimum: a minimum this wide + # would raise the whole panel's minimum width when the advanced + # section opens (issue #183 pattern). Expanding + max lets the field + # fill up to 260px when space allows and shrink on narrow docks. self._convergence_min_edit.setMaximumWidth(260) - convergence_min_line_edit = self._convergence_min_edit.lineEdit() - if convergence_min_line_edit is not None: - convergence_min_line_edit.setMinimumWidth(220) self._convergence_min_edit.setToolTip( "Convergence threshold. Accepts decimal (0.000001) or scientific notation (1e-6)." ) @@ -760,8 +789,10 @@ def _setup_ui(self) -> None: ) self._convergence_window_spin = QSpinBox() - self._convergence_window_spin.setRange(1, 1000) - self._convergence_window_spin.setMaximumWidth(40) + self._convergence_window_spin.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._convergence_window_spin.setRange(1, 100) self._convergence_window_spin.setToolTip( "Number of recent metric values for convergence estimation." ) @@ -773,9 +804,6 @@ def _setup_ui(self) -> None: ) self._multi_resolution_check = QCheckBox("Enabled") - self._multi_resolution_check.setSizePolicy( - QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed - ) self._multi_resolution_check.setToolTip( "Run registration from coarse to fine resolution levels." ) @@ -809,7 +837,7 @@ def _setup_ui(self) -> None: ) self._interpolation_combo = QComboBox() - self._interpolation_combo.setMinimumContentsLength(14) + self._interpolation_combo.setMinimumContentsLength(8) self._interpolation_combo.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) @@ -832,6 +860,9 @@ def _setup_ui(self) -> None: "Automatically use the minimum intensity of the fixed image as fill value." ) self._fill_value_spin = QDoubleSpinBox() + # Ignored horizontal policy: the spinbox's minimum size hint spans its + # widest possible text ("-1000000.000"), which would force the whole + # panel wider than the dock on narrow layouts. self._fill_value_spin.setSizePolicy( QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed ) @@ -848,6 +879,7 @@ def _setup_ui(self) -> None: self._update_multi_resolution_enabled ) fill_value_row = QHBoxLayout() + fill_value_row.setContentsMargins(0, 0, 0, 0) fill_value_row.addWidget(self._fill_value_auto_check) fill_value_row.addWidget(self._fill_value_spin, stretch=1) fill_value_container = QWidget() @@ -860,9 +892,6 @@ def _setup_ui(self) -> None: ) self._keep_diagnostics_check = QCheckBox("Keep full traces") - self._keep_diagnostics_check.setSizePolicy( - QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed - ) self._keep_diagnostics_check.setToolTip( "Whether to keep the full per-frame optimizer traces for within-scan registration." ) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index c0cecb2c..75457c18 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -221,6 +221,19 @@ def test_advanced_group_is_collapsed_by_default(self, registration_panel): registration_panel._advanced_toggle.click() assert not registration_panel._advanced_content.isHidden() + def test_opening_advanced_group_does_not_widen_panel_minimum( + self, registration_panel + ): + # Regression test for the issue #183 overflow pattern: advanced rows + # must wrap on narrow docks instead of raising the panel's minimum + # width, which forced horizontal overflow in the sidebar scroll area. + registration_panel.show() + QApplication.processEvents() + closed_min_width = registration_panel.minimumSizeHint().width() + registration_panel._advanced_toggle.setChecked(True) + QApplication.processEvents() + assert registration_panel.minimumSizeHint().width() <= closed_min_width + def test_scientific_notation_spinboxes_parse_values(self, registration_panel): registration_panel._learning_rate_auto_check.setChecked(False) registration_panel._learning_rate_edit.lineEdit().setText("1e-5") @@ -392,7 +405,9 @@ def test_create_labels_layer_matches_spatial_shape_of_time_series( "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), }, ) - layer = viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) + layer = viewer.add_image( + moving.values, name="moving", metadata={"xarray": moving} + ) layer.scale = (1.0, 0.3, 0.2, 0.1) layer.translate = (0.0, 1.0, 2.0, 3.0) @@ -1283,7 +1298,8 @@ def test_volume_result_adds_new_layer_with_transform_metadata( "resample_interpolation": "linear", } - on_registration_finished(registration_panel, + on_registration_finished( + registration_panel, payload, (registered, transform, diagnostics), ) @@ -1328,7 +1344,8 @@ def test_volume_result_adds_bspline_transform_metadata( "resample_interpolation": "linear", } - on_registration_finished(registration_panel, + on_registration_finished( + registration_panel, payload, (registered, transform, diagnostics), ) @@ -1423,7 +1440,8 @@ def test_volume_result_replaces_preview_layer( "use_multi_resolution": False, "resample_interpolation": "linear", } - on_registration_finished(registration_panel, + on_registration_finished( + registration_panel, payload, (registered, transform, diagnostics), ) @@ -1557,7 +1575,9 @@ def test_progress_layer_data_updates_on_iteration( assert moving.colormap.name != "cyan" assert moving.blending != "additive" - def test_create_volume_progress_plotter_creates_metric_plotter_dock(self, viewer, registration_panel): + def test_create_volume_progress_plotter_creates_metric_plotter_dock( + self, viewer, registration_panel + ): """`create_volume_progress_plotter` lazily creates and docks the metric plotter.""" moving_data = xr.DataArray( np.zeros((4, 6), dtype=np.float32), @@ -1717,11 +1737,13 @@ def test_unique_transform_and_result_names(self, viewer, registration_panel): transform = np.eye(3) diagnostics = _FakeDiagnostics() - on_registration_finished(registration_panel, + on_registration_finished( + registration_panel, payload, (fixed.copy(), transform, diagnostics), ) - on_registration_finished(registration_panel, + on_registration_finished( + registration_panel, payload, (fixed.copy(), transform, diagnostics), ) From 4c5f55b63b20b6345422481d0259b393b7e1c2ba Mon Sep 17 00:00:00 2001 From: Felipe Cybis Pereira Date: Thu, 2 Jul 2026 16:10:19 +0200 Subject: [PATCH 65/72] refactor(napari): harmonize panel labels and layouts - drop trailing ":" from form labels (signals, video, save panels) - align the signals source dropdowns in one grid column - let the y-limit spin boxes share the full row width instead of a 96px cap; Ignored size policy keeps the issue #183 overflow away --- src/confusius/_napari/_data/_save_panel.py | 4 +- src/confusius/_napari/_signals/_panel.py | 52 ++++++++++---------- src/confusius/_napari/_video/_video_panel.py | 2 +- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/confusius/_napari/_data/_save_panel.py b/src/confusius/_napari/_data/_save_panel.py index 82c58236..b35ff653 100644 --- a/src/confusius/_napari/_data/_save_panel.py +++ b/src/confusius/_napari/_data/_save_panel.py @@ -110,8 +110,8 @@ def _setup_ui(self) -> None: "via the ConfUSIus reader." ) - layer_form.addRow("Save layer:", self._layer_combo) - layer_form.addRow("Coordinates from:", self._template_combo) + layer_form.addRow("Save layer", self._layer_combo) + layer_form.addRow("Coordinates from", self._template_combo) save_layout.addLayout(layer_form) self._path_edit = QLineEdit() diff --git a/src/confusius/_napari/_signals/_panel.py b/src/confusius/_napari/_signals/_panel.py index 2f5fed63..e1f7d13c 100644 --- a/src/confusius/_napari/_signals/_panel.py +++ b/src/confusius/_napari/_signals/_panel.py @@ -12,11 +12,13 @@ QComboBox, QDockWidget, QDoubleSpinBox, + QGridLayout, QGroupBox, QHBoxLayout, QLabel, QPushButton, QRadioButton, + QSizePolicy, QVBoxLayout, QWidget, ) @@ -56,11 +58,14 @@ def _setup_ui(self) -> None: layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(8) - # Source group. + # Source group. A grid keeps the three combos left-aligned in one + # column regardless of the leading radio/label text widths. source_group = QGroupBox("Source") self._source_group = source_group - source_layout = QVBoxLayout(source_group) - source_layout.setSpacing(4) + source_layout = QGridLayout(source_group) + source_layout.setHorizontalSpacing(6) + source_layout.setVerticalSpacing(4) + source_layout.setColumnStretch(1, 1) self._source_btn_group = QButtonGroup(self) @@ -68,20 +73,19 @@ def _setup_ui(self) -> None: self._radio_mouse = QRadioButton("Mouse (Shift + hover)") self._radio_mouse.setChecked(True) self._source_btn_group.addButton(self._radio_mouse, 0) - source_layout.addWidget(self._radio_mouse) + source_layout.addWidget(self._radio_mouse, 0, 0, 1, 3) # Points row. Text is part of the radio button (same pattern as the Mouse row) # so the indicator and label are always flush with no gap. - points_row = QHBoxLayout() - self._radio_points = QRadioButton("Points:") + self._radio_points = QRadioButton("Points") self._source_btn_group.addButton(self._radio_points, 1) - points_row.addWidget(self._radio_points) + source_layout.addWidget(self._radio_points, 1, 0) self._points_combo = QComboBox() self._points_combo.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) self._points_combo.setEnabled(False) - points_row.addWidget(self._points_combo, stretch=1) + source_layout.addWidget(self._points_combo, 1, 1) self._new_points_btn = QPushButton("+") self._new_points_btn.setStyleSheet("font-weight: bold; font-size: 14px;") self._new_points_btn.setToolTip( @@ -89,20 +93,18 @@ def _setup_ui(self) -> None: "Points will be visible at all time steps." ) self._new_points_btn.clicked.connect(self._create_points_layer) - points_row.addWidget(self._new_points_btn) - source_layout.addLayout(points_row) + source_layout.addWidget(self._new_points_btn, 1, 2) # Labels row. - labels_row = QHBoxLayout() - self._radio_labels = QRadioButton("Labels:") + self._radio_labels = QRadioButton("Labels") self._source_btn_group.addButton(self._radio_labels, 2) - labels_row.addWidget(self._radio_labels) + source_layout.addWidget(self._radio_labels, 2, 0) self._labels_combo = QComboBox() self._labels_combo.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) self._labels_combo.setEnabled(False) - labels_row.addWidget(self._labels_combo, stretch=1) + source_layout.addWidget(self._labels_combo, 2, 1) self._new_labels_btn = QPushButton("+") self._new_labels_btn.setStyleSheet("font-weight: bold; font-size: 14px;") self._new_labels_btn.setToolTip( @@ -110,21 +112,19 @@ def _setup_ui(self) -> None: "Labels will be visible at all time steps." ) self._new_labels_btn.clicked.connect(self._create_labels_layer) - labels_row.addWidget(self._new_labels_btn) - source_layout.addLayout(labels_row) + source_layout.addWidget(self._new_labels_btn, 2, 2) - # Reference image (enabled in points/labels mode). - ref_row = QHBoxLayout() - self._ref_label = QLabel("Reference:") + # Reference image (enabled in points/labels mode). Spans the "+" + # column so its right edge stays flush with the buttons above. + self._ref_label = QLabel("Reference") self._ref_label.setEnabled(False) - ref_row.addWidget(self._ref_label) + source_layout.addWidget(self._ref_label, 3, 0) self._ref_combo = QComboBox() self._ref_combo.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) self._ref_combo.setEnabled(False) - ref_row.addWidget(self._ref_combo, stretch=1) - source_layout.addLayout(ref_row) + source_layout.addWidget(self._ref_combo, 3, 1, 1, 2) layout.addWidget(source_group) @@ -190,13 +190,15 @@ def _setup_ui(self) -> None: spin = QDoubleSpinBox() spin.setObjectName(f"y{lim}_spin") spin.setAlignment(Qt.AlignmentFlag.AlignCenter) + # Ignored horizontal policy: the minimum size hint spans the + # widest possible value ("-1000000000.00"), which overflowed the + # panel in issue #183. The layout stretch shares the row width. + spin.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) spin.setRange(-1e9, 1e9) spin.setValue(-1.0 if lim == "min" else 1.0) - spin.setMaximumWidth(96) spin.valueChanged.connect(self._apply_settings) spinbox.append(spin) - yminmax_row.addWidget(spin) - yminmax_row.addStretch(1) + yminmax_row.addWidget(spin, stretch=1) axis_layout.addLayout(yminmax_row) self._ymin_spin, self._ymax_spin = spinbox diff --git a/src/confusius/_napari/_video/_video_panel.py b/src/confusius/_napari/_video/_video_panel.py index f9f10098..4518923a 100644 --- a/src/confusius/_napari/_video/_video_panel.py +++ b/src/confusius/_napari/_video/_video_panel.py @@ -366,7 +366,7 @@ def _setup_ui(self) -> None: playback_layout.setSpacing(4) step_row = QHBoxLayout() - step_row.addWidget(QLabel("Frame step:")) + step_row.addWidget(QLabel("Frame step")) self._step_spin = QSpinBox() self._step_spin.setRange(1, 100) self._step_spin.setValue(1) From 40c1dd82a5b320cda169c4bb69977d6b4ca9c21d Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 2 Jul 2026 15:15:32 +0100 Subject: [PATCH 66/72] feat(napari): add inverse transform apply --- src/confusius/_napari/_registration/_panel.py | 14 +- .../_napari/_registration/_panel_results.py | 10 ++ .../_registration/_panel_transforms.py | 130 +++++++++++++++++- .../_registration/_transform_payloads.py | 100 +++++++++++--- .../test_napari/test_registration_panel.py | 95 +++++++++++++ 5 files changed, 324 insertions(+), 25 deletions(-) diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 8497d10a..8f724b3a 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -63,6 +63,7 @@ validate_registration_selection, ) from confusius._napari._registration._panel_transforms import ( + apply_selected_inverse_transform, apply_selected_transform, get_available_transform_payloads, get_selected_center_initialization, @@ -206,6 +207,7 @@ class ApplyTransformPayload(TypedDict): moving_layer_name: str target_layer_name: str transform_source: str + direction: Literal["forward", "inverse"] def _get_optimizer_weight_labels( @@ -287,6 +289,9 @@ def __init__(self, viewer: napari.Viewer) -> None: self._save_transform_callback = lambda: save_selected_transform(self) self._load_transform_callback = lambda: load_transform(self) self._apply_transform_callback = lambda: apply_selected_transform(self) + self._apply_inverse_transform_callback = lambda: ( + apply_selected_inverse_transform(self) + ) self._setup_ui() self.viewer.layers.events.inserted.connect(self._refresh_layers) self.viewer.layers.events.removed.connect(self._refresh_layers) @@ -1001,7 +1006,7 @@ def _setup_ui(self) -> None: transforms_group = QGroupBox("Transforms") transforms_group.setToolTip( - "Save, load, and reapply affine transforms from registration results or manual napari layer transforms." + "Save, load, and reapply transforms from registration results or manual napari layer transforms." ) transforms_layout = QFormLayout(transforms_group) transforms_layout.setSpacing(6) @@ -1038,11 +1043,16 @@ def _setup_ui(self) -> None: self._save_transform_btn.clicked.connect(self._save_transform_callback) self._load_transform_btn = QPushButton("Load") self._load_transform_btn.clicked.connect(self._load_transform_callback) - self._apply_transform_btn = QPushButton("Apply") + self._apply_transform_btn = QPushButton("Forward") self._apply_transform_btn.clicked.connect(self._apply_transform_callback) + self._apply_inverse_transform_btn = QPushButton("Inverse") + self._apply_inverse_transform_btn.clicked.connect( + self._apply_inverse_transform_callback + ) transform_buttons.addWidget(self._save_transform_btn) transform_buttons.addWidget(self._load_transform_btn) transform_buttons.addWidget(self._apply_transform_btn) + transform_buttons.addWidget(self._apply_inverse_transform_btn) transforms_layout.addRow(transform_buttons) self._transforms_panel = transforms_group diff --git a/src/confusius/_napari/_registration/_panel_results.py b/src/confusius/_napari/_registration/_panel_results.py index 79ef9c2d..7d010d99 100644 --- a/src/confusius/_napari/_registration/_panel_results.py +++ b/src/confusius/_napari/_registration/_panel_results.py @@ -18,6 +18,8 @@ from confusius._napari._registration._panel_utils import ( _gamma_needs_reset, _get_image_display_kwargs_from_layer, + _get_source_dataarray, + _prepare_between_scan_data, ) from confusius.plotting.napari import plot_napari @@ -241,10 +243,17 @@ def on_volume_registration_finished( transform_name = panel._make_unique_transform_name( f"{payload['moving_layer_name']} → {payload['fixed_layer_name']} ({payload['transform']})" ) + source_layer = panel._get_layer_by_name(payload["moving_layer_name"]) + source_data = ( + _prepare_between_scan_data(_get_source_dataarray(source_layer)) + if source_layer is not None + else None + ) if isinstance(transform, np.ndarray): metadata["confusius_transform"] = make_affine_transform_payload( np.asarray(transform, dtype=float), reference=registered, + source=source_data, source_layer_name=payload["moving_layer_name"], target_layer_name=payload["fixed_layer_name"], operation=payload["operation"], @@ -257,6 +266,7 @@ def on_volume_registration_finished( metadata["confusius_transform"] = make_bspline_transform_payload( transform, reference=registered, + source=source_data, source_layer_name=payload["moving_layer_name"], target_layer_name=payload["fixed_layer_name"], operation=payload["operation"], diff --git a/src/confusius/_napari/_registration/_panel_transforms.py b/src/confusius/_napari/_registration/_panel_transforms.py index 3fa96891..36ac3516 100644 --- a/src/confusius/_napari/_registration/_panel_transforms.py +++ b/src/confusius/_napari/_registration/_panel_transforms.py @@ -4,31 +4,36 @@ from collections.abc import Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Literal, cast import numpy as np import numpy.typing as npt import xarray as xr +from napari.layers.utils.layer_utils import calc_data_range from napari.qt.threading import thread_worker from napari.utils.notifications import show_error, show_info from qtpy.QtWidgets import QFileDialog from confusius._dims import SPATIAL_DIMS from confusius._napari._registration._panel_utils import ( + _get_image_display_kwargs_from_layer, _get_source_dataarray, _prepare_between_scan_data, ) from confusius._napari._registration._panel_worker_state import on_registration_failed from confusius._napari._registration._transform_payloads import ( AffineTransformPayload, + OutputGridPayload, TransformPayload, get_affine_transform_from_payload, get_bspline_transform_from_payload, + get_input_grid_from_payload, get_output_grid_from_payload, load_transform_payload, make_output_grid_payload, save_transform_payload, ) +from confusius.plotting.napari import plot_napari from confusius.registration import resample_volume if TYPE_CHECKING: @@ -157,6 +162,7 @@ def _make_manual_transform_payload(layer: "Layer") -> AffineTransformPayload: "transform_model": "affine", "metric": "manual", "output_grid": make_output_grid_payload(spatial_data), + "input_grid": make_output_grid_payload(spatial_data), "diagnostics": { "metric": "manual", "final_metric_value": 0.0, @@ -687,6 +693,44 @@ def load_transform(panel: "RegistrationPanel") -> None: show_info(f"Loaded transform: {panel._loaded_transform_payload['name']}") +def _get_inverse_output_grid( + panel: "RegistrationPanel", payload: TransformPayload +) -> OutputGridPayload: + """Return the output grid to use when applying a transform inverse. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel that owns the transform selection. + payload : TransformPayload + Selected transform payload. + + Returns + ------- + OutputGridPayload + Grid of the original moving/source layer. + + Raises + ------ + ValueError + If the payload predates `input_grid` and the source layer is not available to + re-derive it. + """ + input_grid = get_input_grid_from_payload(payload) + if input_grid is not None: + return input_grid + + source_layer = panel._get_layer_by_name(payload["source_layer_name"]) + if source_layer is None: + raise ValueError( + "Transform payload does not contain an input grid. Reload the original " + "source layer or re-save the transform from a newer registration result." + ) + + source = _prepare_between_scan_data(_get_source_dataarray(source_layer)) + return make_output_grid_payload(source) + + def apply_selected_transform(panel: "RegistrationPanel") -> None: """Start a background resampling worker for the selected transform and target layer. @@ -729,6 +773,64 @@ def apply_selected_transform(panel: "RegistrationPanel") -> None: "moving_layer_name": moving_layer.name, "target_layer_name": payload["target_layer_name"], "transform_source": payload["name"], + "direction": "forward", + } + panel._worker = worker + panel._begin_work() + + worker.returned.connect( + lambda result: on_apply_transform_finished(panel, apply_payload, result) + ) + worker.errored.connect(lambda exc: on_registration_failed(panel, exc)) + worker.finished.connect(panel._end_work) + worker.start() + + +def apply_selected_inverse_transform(panel: "RegistrationPanel") -> None: + """Start a background resampling worker for the inverse of the selected transform. + + Parameters + ---------- + panel : RegistrationPanel + Registration panel whose selected transform and target layer should be used. + """ + payload = get_selected_transform_payload(panel) + if payload is None: + panel._set_error("Select a transform to apply.") + return + + moving_layer = panel._selected_layer(panel._transform_target_combo) + if moving_layer is None: + panel._set_error("Select an input layer to transform.") + return + + try: + moving = _get_source_dataarray(moving_layer) + if payload["kind"] == "affine": + transform = np.linalg.inv(get_affine_transform_from_payload(payload)) + else: + raise ValueError( + "Inverse apply for B-spline transforms is not available yet." + ) + output_grid = _get_inverse_output_grid(panel, payload) + except Exception as exc: # noqa: BLE001 + panel._set_error(str(exc)) + return + + worker = thread_worker(resample_volume)( + moving, + transform, + shape=output_grid["shape"], + spacing=output_grid["spacing"], + origin=output_grid["origin"], + dims=output_grid["dims"], + interpolation=panel._current_resample_interpolation(), + ) + apply_payload: ApplyTransformPayload = { + "moving_layer_name": moving_layer.name, + "target_layer_name": payload["source_layer_name"], + "transform_source": payload["name"], + "direction": "inverse", } panel._worker = worker panel._begin_work() @@ -758,15 +860,35 @@ def on_apply_transform_finished( """ registered = result.copy(deep=False) registered.attrs = registered.attrs.copy() - registered.attrs["registration_operation"] = "apply_transform" + registered.attrs["registration_operation"] = ( + "apply_inverse_transform" + if payload["direction"] == "inverse" + else "apply_transform" + ) name = panel._make_unique_layer_name( f"{payload['moving_layer_name']} → {payload['target_layer_name']}" ) - layer = cast("Any", panel.viewer.add_image(registered.values, name=name)) + source_layer = panel._get_layer_by_name(payload["moving_layer_name"]) + display_kwargs = ( + _get_image_display_kwargs_from_layer(source_layer) + if source_layer is not None + else {} + ) + contrast_limits = tuple(calc_data_range(registered.data)) + _, layer = plot_napari( + registered, + viewer=panel.viewer, + name=name, + show_colorbar=False, + contrast_limits=contrast_limits, + **display_kwargs, + ) layer.metadata["xarray"] = registered layer.metadata["transform_source"] = payload["transform_source"] - layer.metadata["registration_operation"] = "apply_transform" + layer.metadata["registration_operation"] = registered.attrs[ + "registration_operation" + ] layer.metadata["registration_parameters"] = payload.copy() panel.viewer.layers.selection.active = layer panel._status.hide() diff --git a/src/confusius/_napari/_registration/_transform_payloads.py b/src/confusius/_napari/_registration/_transform_payloads.py index 59b8cad8..0ab5d772 100644 --- a/src/confusius/_napari/_registration/_transform_payloads.py +++ b/src/confusius/_napari/_registration/_transform_payloads.py @@ -4,7 +4,15 @@ import json from pathlib import Path -from typing import TYPE_CHECKING, Literal, SupportsFloat, SupportsIndex, TypedDict, cast +from typing import ( + TYPE_CHECKING, + Literal, + NotRequired, + SupportsFloat, + SupportsIndex, + TypedDict, + cast, +) import numpy as np import numpy.typing as npt @@ -59,6 +67,7 @@ class AffineTransformPayload(TypedDict): transform_model: str metric: str output_grid: OutputGridPayload + input_grid: NotRequired[OutputGridPayload] diagnostics: TransformDiagnosticsPayload @@ -74,6 +83,7 @@ class BSplineTransformPayload(TypedDict): transform_model: str metric: str output_grid: OutputGridPayload + input_grid: NotRequired[OutputGridPayload] diagnostics: TransformDiagnosticsPayload @@ -137,6 +147,7 @@ def make_affine_transform_payload( affine: npt.NDArray[np.floating], *, reference: "xr.DataArray", + source: "xr.DataArray | None" = None, source_layer_name: str, target_layer_name: str, operation: str, @@ -153,6 +164,9 @@ def make_affine_transform_payload( Affine transform in homogeneous coordinates. reference : xarray.DataArray Fixed/reference DataArray defining the output resampling grid. + source : xarray.DataArray, optional + Original moving/source DataArray defining the inverse-apply resampling grid. If + not provided, `input_grid` is omitted from the payload. source_layer_name : str Name of the moving/source layer used when estimating the transform. target_layer_name : str @@ -177,7 +191,7 @@ def make_affine_transform_payload( payload_name = ( name or f"{source_layer_name} → {target_layer_name} ({transform_model})" ) - return { + payload: AffineTransformPayload = { "kind": "affine", "name": payload_name, "affine": affine.tolist(), @@ -189,6 +203,9 @@ def make_affine_transform_payload( "output_grid": make_output_grid_payload(reference), "diagnostics": _make_diagnostics_payload(diagnostics), } + if source is not None: + payload["input_grid"] = make_output_grid_payload(source) + return payload def _serialize_bspline_dataarray(transform: "xr.DataArray") -> BSplineDataArrayPayload: @@ -249,6 +266,7 @@ def make_bspline_transform_payload( transform: "xr.DataArray", *, reference: "xr.DataArray", + source: "xr.DataArray | None" = None, source_layer_name: str, target_layer_name: str, operation: str, @@ -265,6 +283,9 @@ def make_bspline_transform_payload( B-spline control-point grid. reference : xarray.DataArray Fixed/reference DataArray defining the output resampling grid. + source : xarray.DataArray, optional + Original moving/source DataArray defining the inverse-apply resampling grid. If + not provided, `input_grid` is omitted from the payload. source_layer_name : str Name of the moving/source layer used when estimating the transform. target_layer_name : str @@ -288,7 +309,7 @@ def make_bspline_transform_payload( payload_name = ( name or f"{source_layer_name} → {target_layer_name} ({transform_model})" ) - return { + payload: BSplineTransformPayload = { "kind": "bspline", "name": payload_name, "bspline": _serialize_bspline_dataarray(transform), @@ -300,6 +321,9 @@ def make_bspline_transform_payload( "output_grid": make_output_grid_payload(reference), "diagnostics": _make_diagnostics_payload(diagnostics), } + if source is not None: + payload["input_grid"] = make_output_grid_payload(source) + return payload def get_affine_transform_from_payload( @@ -351,22 +375,12 @@ def get_bspline_transform_from_payload(payload: "Mapping[str, object]") -> xr.Da return _deserialize_bspline_dataarray(cast("BSplineDataArrayPayload", bspline)) -def get_output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: - """Return the output grid stored in a transform payload. - - Parameters - ---------- - payload : mapping - Transform payload loaded from metadata or disk. - - Returns - ------- - OutputGridPayload - Output-grid description stored in the payload. - """ - grid = payload.get("output_grid") +def _coerce_grid_payload( + grid: object, *, field_name: str, missing_message: str +) -> OutputGridPayload: + """Return a validated grid payload from a raw mapping field.""" if not isinstance(grid, dict): - raise ValueError("Transform payload does not contain an output grid.") + raise ValueError(missing_message) grid_dict = cast("dict[str, object]", grid) dims = grid_dict.get("dims") @@ -375,7 +389,7 @@ def get_output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridP origin = grid_dict.get("origin") units = grid_dict.get("units") if not all(isinstance(v, list) for v in (dims, shape, spacing, origin, units)): - raise ValueError("Transform payload output grid is malformed.") + raise ValueError(f"Transform payload {field_name} is malformed.") dims_list = cast("list[object]", dims) shape_list = cast("list[SupportsIndex]", shape) @@ -392,6 +406,51 @@ def get_output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridP } +def get_output_grid_from_payload(payload: "Mapping[str, object]") -> OutputGridPayload: + """Return the output grid stored in a transform payload. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + OutputGridPayload + Output-grid description stored in the payload. + """ + return _coerce_grid_payload( + payload.get("output_grid"), + field_name="output grid", + missing_message="Transform payload does not contain an output grid.", + ) + + +def get_input_grid_from_payload( + payload: "Mapping[str, object]", +) -> OutputGridPayload | None: + """Return the input grid stored in a transform payload, if present. + + Parameters + ---------- + payload : mapping + Transform payload loaded from metadata or disk. + + Returns + ------- + OutputGridPayload or None + Input-grid description stored in the payload, or `None` when the payload does + not carry one. + """ + if "input_grid" not in payload: + return None + return _coerce_grid_payload( + payload.get("input_grid"), + field_name="input grid", + missing_message="Transform payload does not contain an input grid.", + ) + + def _save_bspline_transform_payload( path: str | Path, payload: BSplineTransformPayload ) -> None: @@ -466,6 +525,9 @@ def _load_bspline_transform_payload(path: str | Path) -> BSplineTransformPayload "TransformDiagnosticsPayload", payload_metadata["diagnostics"] ), } + input_grid = get_input_grid_from_payload(payload_metadata) + if input_grid is not None: + payload["input_grid"] = input_grid return payload diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 75457c18..5bfd18fe 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -18,12 +18,14 @@ ) from confusius._napari._registration._panel_results import on_registration_finished from confusius._napari._registration._panel_transforms import ( + apply_selected_inverse_transform, apply_selected_transform, refresh_transform_controls, ) from confusius._napari._registration._transform_payloads import ( get_affine_transform_from_payload, get_bspline_transform_from_payload, + get_input_grid_from_payload, get_output_grid_from_payload, load_transform_payload, make_affine_transform_payload, @@ -986,6 +988,7 @@ def test_affine_payload_roundtrip(self, tmp_path): payload = make_affine_transform_payload( np.eye(3), reference=reference, + source=reference, source_layer_name="moving", target_layer_name="fixed", operation="register_volume", @@ -1001,6 +1004,7 @@ def test_affine_payload_roundtrip(self, tmp_path): assert loaded["source_layer_name"] == "moving" assert loaded["name"] == "moving → fixed (rigid)" assert get_output_grid_from_payload(loaded)["shape"] == [4, 6] + assert get_input_grid_from_payload(loaded)["shape"] == [4, 6] np.testing.assert_array_equal( get_affine_transform_from_payload(loaded), np.eye(3) ) @@ -1018,6 +1022,7 @@ def test_bspline_payload_roundtrip(self, tmp_path): payload = make_bspline_transform_payload( transform, reference=reference, + source=reference, source_layer_name="moving", target_layer_name="fixed", operation="register_volume", @@ -1033,6 +1038,7 @@ def test_bspline_payload_roundtrip(self, tmp_path): assert loaded["name"] == "moving → fixed (bspline)" assert loaded["kind"] == "bspline" assert get_output_grid_from_payload(loaded)["shape"] == [3, 4] + assert get_input_grid_from_payload(loaded)["shape"] == [3, 4] xr.testing.assert_identical( get_bspline_transform_from_payload(loaded), transform.astype(float), @@ -1156,6 +1162,95 @@ def _runner(*args, **kwargs): ) assert registration_panel._worker is not None + def test_apply_inverse_transform_uses_inverse_affine_and_input_grid( + self, viewer, registration_panel, monkeypatch + ): + source = xr.DataArray( + np.arange(12, dtype=np.float32).reshape(3, 4), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(3) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(4) * 0.1, dims=["x"]), + }, + ) + target = xr.DataArray( + np.arange(30, dtype=np.float32).reshape(5, 6), + dims=["y", "x"], + coords={ + "y": xr.DataArray(np.arange(5) * 0.3, dims=["y"]), + "x": xr.DataArray(np.arange(6) * 0.15, dims=["x"]), + }, + ) + affine = np.array( + [[1.0, 0.0, 0.5], [0.0, 1.0, -0.25], [0.0, 0.0, 1.0]], + dtype=float, + ) + payload = make_affine_transform_payload( + affine, + reference=target, + source=source, + source_layer_name="source", + target_layer_name="target", + operation="register_volume", + transform_model="affine", + metric="correlation", + diagnostics=_FakeDiagnostics(), + ) + viewer.add_image(source.values, name="source", metadata={"xarray": source}) + viewer.add_image(target.values, name="target", metadata={"xarray": target}) + viewer.add_image( + target.values, + name="Registered", + metadata={"xarray": target, "confusius_transform": payload}, + ) + refresh_transform_controls(registration_panel) + registration_panel._transform_source_combo.setCurrentText( + "source → target (affine)" + ) + registration_panel._transform_target_combo.setCurrentText("target") + + captured: dict[str, object] = {} + + class _FakeSignal: + def connect(self, _slot): + return None + + class _FakeWorker: + def __init__(self) -> None: + self.returned = _FakeSignal() + self.errored = _FakeSignal() + self.finished = _FakeSignal() + + def start(self) -> None: + return None + + def _fake_thread_worker(func): + def _runner(*args, **kwargs): + captured["func"] = func + captured["args"] = args + captured["kwargs"] = kwargs + return _FakeWorker() + + return _runner + + monkeypatch.setattr( + "confusius._napari._registration._panel_transforms.thread_worker", + _fake_thread_worker, + ) + + apply_selected_inverse_transform(registration_panel) + + func = cast("Any", captured["func"]) + args = cast("tuple[Any, ...]", captured["args"]) + kwargs = cast("dict[str, Any]", captured["kwargs"]) + assert func.__name__ == "resample_volume" + np.testing.assert_allclose(args[1], np.linalg.inv(affine)) + assert kwargs["shape"] == [3, 4] + assert kwargs["spacing"] == [0.2, 0.1] + assert kwargs["origin"] == [0.0, 0.0] + assert kwargs["dims"] == ["y", "x"] + assert registration_panel._worker is not None + class TestPluginWidget: def test_registration_panel_is_present_in_main_widget(self, viewer): From 77a8900fd79799a51d92d2786a5e0e7e62b264b6 Mon Sep 17 00:00:00 2001 From: Felipe Cybis Pereira Date: Thu, 2 Jul 2026 16:26:09 +0200 Subject: [PATCH 67/72] NapariProgressBridge -> NapariRegistrationProgressBridge --- .../_napari/_registration/_metric_plotter.py | 8 +++--- src/confusius/_napari/_registration/_panel.py | 4 +-- .../_napari/_registration/_panel_progress.py | 4 +-- .../_napari/_registration/_progress.py | 14 +++++----- .../test_napari/test_registration_progress.py | 26 ++++++++++--------- 5 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/confusius/_napari/_registration/_metric_plotter.py b/src/confusius/_napari/_registration/_metric_plotter.py index 95b9612d..a82eac76 100644 --- a/src/confusius/_napari/_registration/_metric_plotter.py +++ b/src/confusius/_napari/_registration/_metric_plotter.py @@ -115,10 +115,10 @@ def _apply_theme(self) -> None: def add_metric(self, value: float) -> None: """Append a metric value and schedule a redraw. - Called from the GUI thread via the `NapariProgressBridge.metric_updated` - signal. Rapid iteration events are coalesced through a single-shot - timer so the canvas is redrawn at most once per ~16 ms regardless of - the worker-side event rate. + Called from the GUI thread via the + `NapariRegistrationProgressPlotterBridge.metric_updated` signal. Rapid iteration + events are coalesced through a single-shot timer so the canvas is redrawn at + most once per ~16 ms regardless of the worker-side event rate. Parameters ---------- diff --git a/src/confusius/_napari/_registration/_panel.py b/src/confusius/_napari/_registration/_panel.py index 8f724b3a..61fbb499 100644 --- a/src/confusius/_napari/_registration/_panel.py +++ b/src/confusius/_napari/_registration/_panel.py @@ -82,7 +82,7 @@ ) from confusius._napari._registration._panel_worker_state import on_registration_failed from confusius._napari._registration._progress import ( - NapariProgressBridge, + NapariRegistrationProgressPlotterBridge, NapariRegistrationProgressReporterBridge, ) from confusius._napari._registration._transform_payloads import TransformPayload @@ -261,7 +261,7 @@ def __init__(self, viewer: napari.Viewer) -> None: self._loaded_transform_payload: TransformPayload | None = None self._optimizer_weight_spins: list[QDoubleSpinBox] = [] # Per-run progress state. Set on the GUI thread before the worker starts. - self._progress_bridge: NapariProgressBridge | None = None + self._progress_bridge: NapariRegistrationProgressPlotterBridge | None = None self._progress_layer: Image | None = None self._progress_fixed_layer: Image | None = None self._progress_moving_layer: Image | None = None diff --git a/src/confusius/_napari/_registration/_panel_progress.py b/src/confusius/_napari/_registration/_panel_progress.py index e836c75b..7f853c94 100644 --- a/src/confusius/_napari/_registration/_panel_progress.py +++ b/src/confusius/_napari/_registration/_panel_progress.py @@ -18,7 +18,7 @@ _preserve_view, ) from confusius._napari._registration._progress import ( - NapariProgressBridge, + NapariRegistrationProgressPlotterBridge, NapariRegistrationProgressReporter, NapariRegistrationProgressReporterBridge, make_napari_progress_factory, @@ -383,7 +383,7 @@ def create_volume_progress_plotter( panel._set_error(f"Could not create progress layer: {exc}") raise - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() bridge.iterated.connect(lambda arr: update_progress_layer(panel, arr)) panel._progress_bridge = bridge panel._progress_layer = cast("Image", layer) diff --git a/src/confusius/_napari/_registration/_progress.py b/src/confusius/_napari/_registration/_progress.py index 9ff48066..5f1897ff 100644 --- a/src/confusius/_napari/_registration/_progress.py +++ b/src/confusius/_napari/_registration/_progress.py @@ -9,7 +9,7 @@ constructed and signal slots connected on the GUI thread before the registration worker thread starts: -- [`NapariProgressBridge`][confusius._napari._registration._progress.NapariProgressBridge] +- [`NapariRegistrationProgressPlotterBridge`][confusius._napari._registration._progress.NapariRegistrationProgressPlotterBridge] is a lightweight `QObject` that lives on the GUI thread and exposes Qt signals. The worker thread calls `emit` on it; Qt marshals the slot invocations back to the GUI thread via an automatically-detected queued connection. @@ -21,7 +21,7 @@ Connection lifecycle: -1. The panel constructs a `NapariProgressBridge` on the GUI thread and connects its +1. The panel constructs a `NapariRegistrationProgressPlotterBridge` on the GUI thread and connects its `iterated` signal to a slot that writes the array into the resampled napari layer. 2. The panel builds a factory (via [`make_napari_progress_factory`][confusius._napari._registration._progress.make_napari_progress_factory]) @@ -48,7 +48,7 @@ from confusius.registration import RegistrationDiagnostics, RegistrationProgress -class NapariProgressBridge(QObject): +class NapariRegistrationProgressPlotterBridge(QObject): """Thread-boundary signal bridge for napari registration progress. Construct this on the GUI thread before starting the registration worker. Connect @@ -85,7 +85,7 @@ class NapariRegistrationProgressPlotter: Parameters ---------- - bridge : NapariProgressBridge + bridge : NapariRegistrationProgressPlotterBridge GUI-thread signal bridge. Stored by reference; never accessed for GUI APIs from this object. registration_method : SimpleITK.ImageRegistrationMethod @@ -108,7 +108,7 @@ class NapariRegistrationProgressPlotter: def __init__( self, - bridge: NapariProgressBridge, + bridge: NapariRegistrationProgressPlotterBridge, registration_method: "sitk.ImageRegistrationMethod", fixed_img: "sitk.Image", moving_img: "sitk.Image", @@ -240,7 +240,7 @@ def close(self) -> None: def make_napari_progress_factory( - bridge: NapariProgressBridge, + bridge: NapariRegistrationProgressPlotterBridge, ) -> "Callable[..., RegistrationProgress]": """Return a progress-plotter factory bound to a bridge. @@ -252,7 +252,7 @@ def make_napari_progress_factory( Parameters ---------- - bridge : NapariProgressBridge + bridge : NapariRegistrationProgressPlotterBridge GUI-thread bridge the constructed reporter will emit through. Returns diff --git a/tests/unit/test_napari/test_registration_progress.py b/tests/unit/test_napari/test_registration_progress.py index eac4da62..37d29b83 100644 --- a/tests/unit/test_napari/test_registration_progress.py +++ b/tests/unit/test_napari/test_registration_progress.py @@ -9,8 +9,8 @@ import SimpleITK as sitk from confusius._napari._registration._progress import ( - NapariProgressBridge, NapariRegistrationProgressPlotter, + NapariRegistrationProgressPlotterBridge, NapariRegistrationProgressReporter, NapariRegistrationProgressReporterBridge, make_napari_progress_factory, @@ -65,11 +65,11 @@ def __call__(self, payload: Any) -> None: self.payloads.append(payload) -class TestNapariProgressBridge: +class TestNapariRegistrationProgressPlotterBridge: """Signal bridge behaviour.""" def test_iterated_signal_is_emitted(self, qtbot): - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() spy = _SignalSpy() bridge.iterated.connect(spy) with qtbot.waitSignal(bridge.iterated, timeout=1000): @@ -78,12 +78,12 @@ def test_iterated_signal_is_emitted(self, qtbot): np.testing.assert_array_equal(spy.payloads[0], np.zeros((2, 2))) def test_finished_signal_is_emitted(self, qtbot): - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() with qtbot.waitSignal(bridge.finished, timeout=1000): bridge.finished.emit() def test_metric_updated_signal_is_emitted(self, qtbot): - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() with qtbot.waitSignal(bridge.metric_updated, timeout=1000): bridge.metric_updated.emit(0.42) @@ -93,7 +93,7 @@ class TestNapariRegistrationProgressPlotter: def test_update_resamples_and_emits_array(self, qtbot, fixed_img_2d, moving_img_2d): reg = _make_registration_method(ndim=2) - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() spy = _SignalSpy() bridge.iterated.connect(spy) @@ -117,7 +117,7 @@ def test_update_resamples_and_emits_array(self, qtbot, fixed_img_2d, moving_img_ def test_update_emits_metric_value(self, qtbot, fixed_img_2d, moving_img_2d): """`update()` also forwards the current optimizer metric value.""" reg = _make_registration_method(ndim=2) - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() metric_spy = _SignalSpy() bridge.metric_updated.connect(metric_spy) @@ -140,7 +140,7 @@ def test_update_skips_metric_when_plot_metric_false( ): """`plot_metric=False` suppresses the metric_updated emission.""" reg = _make_registration_method(ndim=2) - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() metric_spy = _SignalSpy() bridge.metric_updated.connect(metric_spy) @@ -161,7 +161,7 @@ def test_update_skips_metric_when_plot_metric_false( def test_close_emits_finished_signal(self, qtbot, fixed_img_2d, moving_img_2d): reg = _make_registration_method(ndim=2) - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() reporter = NapariRegistrationProgressPlotter( bridge, reg, @@ -179,7 +179,9 @@ class TestNapariRegistrationProgressReporterBridge: def test_frame_progress_signal_is_emitted(self, qtbot): bridge = NapariRegistrationProgressReporterBridge() payloads: list[tuple[int, int]] = [] - bridge.frame_progress.connect(lambda completed, total: payloads.append((completed, total))) + bridge.frame_progress.connect( + lambda completed, total: payloads.append((completed, total)) + ) with qtbot.waitSignal(bridge.frame_progress, timeout=1000): bridge.frame_progress.emit(1, 3) @@ -250,7 +252,7 @@ class TestMakeNapariProgressFactory: def test_factory_returns_napari_volume_progress( self, qtbot, fixed_img_2d, moving_img_2d ): - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() factory = make_napari_progress_factory(bridge) reg = _make_registration_method(ndim=2) @@ -289,7 +291,7 @@ def test_factory_is_invoked_and_iterated_signal_fires(self, qtbot): }, ) - bridge = NapariProgressBridge() + bridge = NapariRegistrationProgressPlotterBridge() spy = _SignalSpy() bridge.iterated.connect(spy) factory = make_napari_progress_factory(bridge) From f018c579ccfb35545ea7edd4ea23bba8bdf842bb Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 2 Jul 2026 15:31:08 +0100 Subject: [PATCH 68/72] test(napari): use viewer proxy in registration tests --- .../test_registration_metric_plotter.py | 4 +- .../test_napari/test_registration_panel.py | 222 ++++++++++-------- 2 files changed, 132 insertions(+), 94 deletions(-) diff --git a/tests/unit/test_napari/test_registration_metric_plotter.py b/tests/unit/test_napari/test_registration_metric_plotter.py index f948c207..6a16bb2d 100644 --- a/tests/unit/test_napari/test_registration_metric_plotter.py +++ b/tests/unit/test_napari/test_registration_metric_plotter.py @@ -6,12 +6,12 @@ @pytest.fixture -def registration_metric_plotter(make_napari_viewer): +def registration_metric_plotter(make_napari_viewer_proxy): from confusius._napari._registration._metric_plotter import ( RegistrationMetricPlotter, ) - viewer = make_napari_viewer() + viewer = make_napari_viewer_proxy() return RegistrationMetricPlotter(viewer) diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 5bfd18fe..7ef448ff 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -36,7 +36,12 @@ @pytest.fixture -def viewer(make_napari_viewer): +def viewer(make_napari_viewer_proxy): + return make_napari_viewer_proxy() + + +@pytest.fixture +def real_viewer(make_napari_viewer): return make_napari_viewer() @@ -47,6 +52,13 @@ def registration_panel(viewer): return RegistrationPanel(viewer) +@pytest.fixture +def real_registration_panel(real_viewer): + from confusius._napari._registration._panel import RegistrationPanel + + return RegistrationPanel(real_viewer) + + def _FakeDiagnostics( *, metric: str = "correlation", @@ -259,7 +271,7 @@ def test_scale_defaults_to_db(self, registration_panel): assert registration_panel._scale_combo.currentText() == "decibel" def test_scale_preprocessing_resets_gamma_for_previews( - self, viewer, registration_panel + self, real_viewer, real_registration_panel ): moving_data = xr.DataArray( np.ones((4, 6), dtype=np.float32), @@ -274,13 +286,13 @@ def test_scale_preprocessing_resets_gamma_for_previews( dims=["y", "x"], coords=moving_data.coords, ) - moving = viewer.add_image(moving_data.values, name="moving") - fixed_layer = viewer.add_image(fixed.values, name="fixed") + moving = real_viewer.add_image(moving_data.values, name="moving") + fixed_layer = real_viewer.add_image(fixed.values, name="fixed") moving.gamma = 0.4 fixed_layer.gamma = 0.6 create_volume_progress_plotter( - registration_panel, + real_registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, @@ -288,13 +300,13 @@ def test_scale_preprocessing_resets_gamma_for_previews( layer_name="Registered (rigid)", scale_mode="sqrt", ) - assert viewer.layers["Fixed"].gamma == pytest.approx(1.0) - assert viewer.layers["Moving"].gamma == pytest.approx(1.0) - assert viewer.layers["Registered (rigid)"].gamma == pytest.approx(1.0) + assert real_viewer.layers["Fixed"].gamma == pytest.approx(1.0) + assert real_viewer.layers["Moving"].gamma == pytest.approx(1.0) + assert real_viewer.layers["Registered (rigid)"].gamma == pytest.approx(1.0) - teardown_volume_progress(registration_panel) + teardown_volume_progress(real_registration_panel) create_volume_progress_plotter( - registration_panel, + real_registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, @@ -302,12 +314,12 @@ def test_scale_preprocessing_resets_gamma_for_previews( layer_name="Registered (rigid)", scale_mode="off", ) - assert viewer.layers["Fixed"].gamma == pytest.approx(0.6) - assert viewer.layers["Moving"].gamma == pytest.approx(0.4) - assert viewer.layers["Registered (rigid)"].gamma == pytest.approx(0.4) + assert real_viewer.layers["Fixed"].gamma == pytest.approx(0.6) + assert real_viewer.layers["Moving"].gamma == pytest.approx(0.4) + assert real_viewer.layers["Registered (rigid)"].gamma == pytest.approx(0.4) def test_create_volume_progress_plotter_preserves_camera_view( - self, viewer, registration_panel + self, real_viewer, real_registration_panel ): moving_data = xr.DataArray( np.ones((5, 4, 6), dtype=np.float32), @@ -323,17 +335,21 @@ def test_create_volume_progress_plotter_preserves_camera_view( dims=["z", "y", "x"], coords=moving_data.coords, ) - moving = viewer.add_image(moving_data.values, name="moving") - fixed_layer = viewer.add_image(fixed.values, name="fixed") + moving = real_viewer.add_image(moving_data.values, name="moving") + fixed_layer = real_viewer.add_image(fixed.values, name="fixed") # User navigates to a custom 3D view before launching the run. - viewer.dims.ndisplay = 3 - viewer.camera.center = (1.0, 2.0, 3.0) - viewer.camera.zoom = 7.0 - before = (tuple(viewer.camera.center), viewer.camera.zoom, viewer.dims.ndisplay) + real_viewer.dims.ndisplay = 3 + real_viewer.camera.center = (1.0, 2.0, 3.0) + real_viewer.camera.zoom = 7.0 + before = ( + tuple(real_viewer.camera.center), + real_viewer.camera.zoom, + real_viewer.dims.ndisplay, + ) create_volume_progress_plotter( - registration_panel, + real_registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, @@ -342,7 +358,11 @@ def test_create_volume_progress_plotter_preserves_camera_view( scale_mode="off", ) - after = (tuple(viewer.camera.center), viewer.camera.zoom, viewer.dims.ndisplay) + after = ( + tuple(real_viewer.camera.center), + real_viewer.camera.zoom, + real_viewer.dims.ndisplay, + ) assert after == before def test_metric_specific_rows_follow_metric(self, registration_panel): @@ -791,25 +811,27 @@ def test_abort_sets_cancellation_event(self, registration_panel): class TestValidation: - def test_same_moving_and_fixed_is_flagged(self, viewer, registration_panel): - viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="same") - registration_panel._refresh_layers() - registration_panel._moving_combo.setCurrentText("same") - registration_panel._fixed_combo.setCurrentText("same") + def test_same_moving_and_fixed_is_flagged( + self, real_viewer, real_registration_panel + ): + real_viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="same") + real_registration_panel._refresh_layers() + real_registration_panel._moving_combo.setCurrentText("same") + real_registration_panel._fixed_combo.setCurrentText("same") - assert not registration_panel._validate_registration_selection() - assert not registration_panel._layer_validation.isHidden() - assert "must be different" in registration_panel._layer_validation.text() + assert not real_registration_panel._validate_registration_selection() + assert not real_registration_panel._layer_validation.isHidden() + assert "must be different" in real_registration_panel._layer_validation.text() def test_between_scans_with_single_layer_flags_fixed( - self, viewer, registration_panel + self, real_viewer, real_registration_panel ): - viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="only") - registration_panel._refresh_layers() - registration_panel._moving_combo.setCurrentText("only") + real_viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="only") + real_registration_panel._refresh_layers() + real_registration_panel._moving_combo.setCurrentText("only") - assert not registration_panel._validate_registration_selection() - assert "must be different" in registration_panel._layer_validation.text() + assert not real_registration_panel._validate_registration_selection() + assert "must be different" in real_registration_panel._layer_validation.text() def test_within_scan_requires_time_dimension(self, viewer, registration_panel): viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="vol") @@ -1253,10 +1275,10 @@ def _runner(*args, **kwargs): class TestPluginWidget: - def test_registration_panel_is_present_in_main_widget(self, viewer): + def test_registration_panel_is_present_in_main_widget(self, real_viewer): from confusius._napari._widget import ConfUSIusWidget - widget = ConfUSIusWidget(viewer) + widget = ConfUSIusWidget(real_viewer) assert "Registration" in widget._accordion_panels @@ -1454,7 +1476,7 @@ def test_volume_result_adds_bspline_transform_metadata( ) def test_volume_result_replaces_preview_layer( - self, viewer, registration_panel, qtbot + self, real_viewer, real_registration_panel, qtbot ): """A preview layer created by `create_volume_progress_plotter` is removed after `_on_registration_finished` so the final result is the only @@ -1467,7 +1489,9 @@ def test_volume_result_replaces_preview_layer( "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), }, ) - moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") + moving = real_viewer.add_image( + np.zeros((4, 6), dtype=np.float32), name="moving" + ) fixed = xr.DataArray( np.ones((4, 6), dtype=np.float32), dims=["y", "x"], @@ -1476,10 +1500,12 @@ def test_volume_result_replaces_preview_layer( "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), }, ) - fixed_layer = viewer.add_image(np.ones((4, 6), dtype=np.float32), name="fixed") + fixed_layer = real_viewer.add_image( + np.ones((4, 6), dtype=np.float32), name="fixed" + ) factory = create_volume_progress_plotter( - registration_panel, + real_registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, @@ -1489,21 +1515,21 @@ def test_volume_result_replaces_preview_layer( ) assert factory is not None assert {"Fixed", "Moving", "Registered (rigid)"}.issubset( - {layer.name for layer in viewer.layers} + {layer.name for layer in real_viewer.layers} ) - assert registration_panel._progress_layer is not None - assert registration_panel._progress_bridge is not None - assert registration_panel._progress_fixed_layer is not None - assert registration_panel._progress_moving_layer is not None + assert real_registration_panel._progress_layer is not None + assert real_registration_panel._progress_bridge is not None + assert real_registration_panel._progress_fixed_layer is not None + assert real_registration_panel._progress_moving_layer is not None # Original layers are left untouched. assert fixed_layer.colormap.name != "red" assert moving.colormap.name != "cyan" assert moving.blending != "additive" assert moving.visible # Dedicated preview layers carry the registration styling. - fixed_preview = viewer.layers["Fixed"] - moving_preview = viewer.layers["Moving"] - preview_layer = viewer.layers["Registered (rigid)"] + fixed_preview = real_viewer.layers["Fixed"] + moving_preview = real_viewer.layers["Moving"] + preview_layer = real_viewer.layers["Registered (rigid)"] assert fixed_preview.colormap.name == "red" assert moving_preview.colormap.name == "cyan" assert moving_preview.blending == "additive" @@ -1536,7 +1562,7 @@ def test_volume_result_replaces_preview_layer( "resample_interpolation": "linear", } on_registration_finished( - registration_panel, + real_registration_panel, payload, (registered, transform, diagnostics), ) @@ -1544,13 +1570,13 @@ def test_volume_result_replaces_preview_layer( # The resampled preview is kept and promoted to the final registered # layer so the user can keep reviewing the fixed / moving / result # stack after the run. - assert registration_panel._progress_layer is None - assert registration_panel._progress_bridge is None + assert real_registration_panel._progress_layer is None + assert real_registration_panel._progress_bridge is None assert {"Fixed", "Moving", "Registered (rigid)"}.issubset( - {layer.name for layer in viewer.layers} + {layer.name for layer in real_viewer.layers} ) - assert not viewer.layers["Moving"].visible - result_layer = viewer.layers["Registered (rigid)"] + assert not real_viewer.layers["Moving"].visible + result_layer = real_viewer.layers["Registered (rigid)"] assert result_layer.colormap.name == "cyan" assert result_layer.blending == "additive" # Original source layers remain untouched. @@ -1564,7 +1590,7 @@ def test_volume_result_replaces_preview_layer( ) def test_create_volume_progress_plotter_applies_initial_transform_to_preview_layers( - self, viewer, registration_panel + self, real_viewer, real_registration_panel ): moving_data = xr.DataArray( np.arange(24, dtype=np.float32).reshape(4, 6), @@ -1574,20 +1600,22 @@ def test_create_volume_progress_plotter_applies_initial_transform_to_preview_lay "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), }, ) - moving = viewer.add_image(moving_data.values, name="moving") + moving = real_viewer.add_image(moving_data.values, name="moving") fixed = xr.DataArray( np.zeros((4, 6), dtype=np.float32), dims=["y", "x"], coords=moving_data.coords, ) - fixed_layer = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="fixed") + fixed_layer = real_viewer.add_image( + np.zeros((4, 6), dtype=np.float32), name="fixed" + ) initial_transform = np.array( [[1.0, 0.0, 0.2], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=float, ) create_volume_progress_plotter( - registration_panel, + real_registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, @@ -1599,16 +1627,16 @@ def test_create_volume_progress_plotter_applies_initial_transform_to_preview_lay expected = resample_like(moving_data, fixed, initial_transform) np.testing.assert_allclose( - np.asarray(viewer.layers["Moving"].data), + np.asarray(real_viewer.layers["Moving"].data), np.asarray(expected.data), ) np.testing.assert_allclose( - np.asarray(viewer.layers["Registered (rigid)"].data), + np.asarray(real_viewer.layers["Registered (rigid)"].data), np.asarray(expected.data), ) def test_progress_layer_data_updates_on_iteration( - self, viewer, registration_panel, qtbot + self, real_viewer, real_registration_panel, qtbot ): """`_update_progress_layer` writes the iterated array into the preview layer's data, refreshing the canvas.""" @@ -1620,7 +1648,9 @@ def test_progress_layer_data_updates_on_iteration( "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), }, ) - moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") + moving = real_viewer.add_image( + np.zeros((4, 6), dtype=np.float32), name="moving" + ) fixed = xr.DataArray( np.zeros((4, 6), dtype=np.float32), dims=["y", "x"], @@ -1629,10 +1659,12 @@ def test_progress_layer_data_updates_on_iteration( "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), }, ) - fixed_layer = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="fixed") + fixed_layer = real_viewer.add_image( + np.zeros((4, 6), dtype=np.float32), name="fixed" + ) create_volume_progress_plotter( - registration_panel, + real_registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, @@ -1642,36 +1674,38 @@ def test_progress_layer_data_updates_on_iteration( ) # The preview is seeded with the moving image resampled onto the # fixed grid, so it's visible and meaningful from the start. - preview_layer = viewer.layers["Registered (rigid)"] + preview_layer = real_viewer.layers["Registered (rigid)"] assert preview_layer.visible next_arr = np.full((4, 6), 0.5, dtype=np.float32) - update_progress_layer(registration_panel, next_arr) + update_progress_layer(real_registration_panel, next_arr) np.testing.assert_array_equal( - np.asarray(viewer.layers["Registered (rigid)"].data), next_arr + np.asarray(real_viewer.layers["Registered (rigid)"].data), next_arr ) # Shape mismatch is silently ignored. - update_progress_layer(registration_panel, np.zeros((3, 6), dtype=np.float32)) + update_progress_layer( + real_registration_panel, np.zeros((3, 6), dtype=np.float32) + ) np.testing.assert_array_equal( - np.asarray(viewer.layers["Registered (rigid)"].data), next_arr + np.asarray(real_viewer.layers["Registered (rigid)"].data), next_arr ) # Teardown removes only the in-flight registered layer while leaving # the reusable fixed / moving previews and originals untouched. - teardown_volume_progress(registration_panel) - assert registration_panel._progress_layer is None - assert registration_panel._progress_bridge is None - assert "Registered (rigid)" not in {layer.name for layer in viewer.layers} - assert "Fixed" in {layer.name for layer in viewer.layers} - assert "Moving" in {layer.name for layer in viewer.layers} + teardown_volume_progress(real_registration_panel) + assert real_registration_panel._progress_layer is None + assert real_registration_panel._progress_bridge is None + assert "Registered (rigid)" not in {layer.name for layer in real_viewer.layers} + assert "Fixed" in {layer.name for layer in real_viewer.layers} + assert "Moving" in {layer.name for layer in real_viewer.layers} assert moving.visible assert moving.colormap.name != "cyan" assert moving.blending != "additive" def test_create_volume_progress_plotter_creates_metric_plotter_dock( - self, viewer, registration_panel + self, real_viewer, real_registration_panel ): """`create_volume_progress_plotter` lazily creates and docks the metric plotter.""" moving_data = xr.DataArray( @@ -1682,7 +1716,9 @@ def test_create_volume_progress_plotter_creates_metric_plotter_dock( "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), }, ) - moving = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="moving") + moving = real_viewer.add_image( + np.zeros((4, 6), dtype=np.float32), name="moving" + ) fixed = xr.DataArray( np.zeros((4, 6), dtype=np.float32), dims=["y", "x"], @@ -1691,13 +1727,15 @@ def test_create_volume_progress_plotter_creates_metric_plotter_dock( "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), }, ) - fixed_layer = viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="fixed") + fixed_layer = real_viewer.add_image( + np.zeros((4, 6), dtype=np.float32), name="fixed" + ) - assert registration_panel._metric_plotter is None - assert registration_panel._metric_dock is None + assert real_registration_panel._metric_plotter is None + assert real_registration_panel._metric_dock is None create_volume_progress_plotter( - registration_panel, + real_registration_panel, moving_layer=moving, fixed_layer=fixed_layer, moving=moving_data, @@ -1706,24 +1744,24 @@ def test_create_volume_progress_plotter_creates_metric_plotter_dock( scale_mode="off", ) - assert registration_panel._metric_plotter is not None - assert registration_panel._metric_dock is not None + assert real_registration_panel._metric_plotter is not None + assert real_registration_panel._metric_dock is not None # The plotter is parented to a dock (i.e. it has been re-parented # away from its original parent). - assert registration_panel._metric_plotter.parent() is not None + assert real_registration_panel._metric_plotter.parent() is not None # Feeding a value through the bridge populates the plotter's buffer. - bridge = registration_panel._progress_bridge + bridge = real_registration_panel._progress_bridge assert bridge is not None bridge.metric_updated.emit(0.5) # Force a render so the throttled redraw is observed synchronously. - registration_panel._metric_plotter._render() - assert registration_panel._metric_plotter.metric_values == [0.5] + real_registration_panel._metric_plotter._render() + assert real_registration_panel._metric_plotter.metric_values == [0.5] # Tearing down keeps the plotter (so the user can inspect the trace). - teardown_volume_progress(registration_panel) - assert registration_panel._metric_plotter is not None - assert registration_panel._metric_plotter.metric_values == [0.5] + teardown_volume_progress(real_registration_panel) + assert real_registration_panel._metric_plotter is not None + assert real_registration_panel._metric_plotter.metric_values == [0.5] def test_volumewise_result_adds_registered_layer(self, viewer, registration_panel): registered = xr.DataArray( From 3f6c2576d29193cc01c55295be2df4fab86e41ff Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 2 Jul 2026 17:44:08 +0100 Subject: [PATCH 69/72] test(napari): trim registration test noise --- .../test_registration_metric_plotter.py | 70 +- .../test_napari/test_registration_panel.py | 834 +++++------------- .../test_napari/test_registration_progress.py | 92 +- 3 files changed, 245 insertions(+), 751 deletions(-) diff --git a/tests/unit/test_napari/test_registration_metric_plotter.py b/tests/unit/test_napari/test_registration_metric_plotter.py index 6a16bb2d..b3e6e72e 100644 --- a/tests/unit/test_napari/test_registration_metric_plotter.py +++ b/tests/unit/test_napari/test_registration_metric_plotter.py @@ -25,9 +25,6 @@ def test_add_metric_appends_value(self, registration_metric_plotter) -> None: registration_metric_plotter.add_metric(0.5) registration_metric_plotter.add_metric(0.25) registration_metric_plotter.add_metric(0.1) - # The QTimer is single-shot at 16ms; force a render so the line - # state is finalised before we read it. - registration_metric_plotter._render() # type: ignore[attr-defined] assert registration_metric_plotter.metric_values == [0.5, 0.25, 0.1] def test_metric_values_returns_a_copy( @@ -36,7 +33,6 @@ def test_metric_values_returns_a_copy( registration_metric_plotter.add_metric(1.0) snapshot = registration_metric_plotter.metric_values snapshot.append(99.0) - # Mutating the snapshot must not affect the internal buffer. assert registration_metric_plotter.metric_values == [1.0] def test_reset_clears_buffer(self, registration_metric_plotter) -> None: @@ -45,69 +41,11 @@ def test_reset_clears_buffer(self, registration_metric_plotter) -> None: registration_metric_plotter.reset() assert registration_metric_plotter.metric_values == [] - def test_reset_after_data_keeps_axes_valid( + def test_add_metric_after_reset_starts_new_run( self, registration_metric_plotter ) -> None: registration_metric_plotter.add_metric(0.5) + registration_metric_plotter.add_metric(0.25) registration_metric_plotter.reset() - registration_metric_plotter._render() # type: ignore[attr-defined] - # After reset + render, the line data is empty but the axes are - # still configured. - line = registration_metric_plotter._metric_line # type: ignore[attr-defined] - assert list(line.get_xdata()) == [] - assert list(line.get_ydata()) == [] - - -class TestRegistrationMetricPlotterThrottling: - """The redraw timer coalesces rapid `add_metric` calls.""" - - def test_single_timer_per_burst( - self, registration_metric_plotter, qtbot - ) -> None: - # Burst a series of values without yielding to the event loop; the - # timer should be active but only one render should fire when the - # loop runs. - for v in [0.1, 0.2, 0.3, 0.4, 0.5]: - registration_metric_plotter.add_metric(v) - # The buffer holds every value; the canvas will be redrawn once the - # timer fires. - assert registration_metric_plotter.metric_values == [0.1, 0.2, 0.3, 0.4, 0.5] - timer = registration_metric_plotter._redraw_timer # type: ignore[attr-defined] - assert timer.isSingleShot() - assert timer.interval() == 16 - - def test_render_after_timer_fire( - self, registration_metric_plotter, qtbot - ) -> None: - registration_metric_plotter.add_metric(0.5) - # Wait for the throttled redraw to fire. - with qtbot.waitSignal( - registration_metric_plotter._redraw_timer.timeout, # type: ignore[attr-defined] - timeout=2000, - ): - pass - line = registration_metric_plotter._metric_line # type: ignore[attr-defined] - npt_import = pytest.importorskip("numpy") - npt_import.testing.assert_array_equal( - npt_import.asarray(line.get_xdata()), npt_import.asarray([1]) - ) - npt_import.testing.assert_array_equal( - npt_import.asarray(line.get_ydata()), npt_import.asarray([0.5]) - ) - - -class TestRegistrationMetricPlotterLayout: - """Construction and theme integration.""" - - def test_widget_has_minimum_height(self, registration_metric_plotter) -> None: - assert registration_metric_plotter.minimumHeight() >= 100 - - def test_size_hint(self, registration_metric_plotter) -> None: - hint = registration_metric_plotter.sizeHint() - assert hint.width() >= 400 - assert hint.height() >= 150 - - def test_metric_line_created(self, registration_metric_plotter) -> None: - assert registration_metric_plotter._metric_line is not None # type: ignore[attr-defined] - assert registration_metric_plotter._axes.get_xlabel() == "Iteration" # type: ignore[attr-defined] - assert registration_metric_plotter._axes.get_ylabel() == "Metric value" # type: ignore[attr-defined] \ No newline at end of file + registration_metric_plotter.add_metric(0.1) + assert registration_metric_plotter.metric_values == [0.1] diff --git a/tests/unit/test_napari/test_registration_panel.py b/tests/unit/test_napari/test_registration_panel.py index 7ef448ff..33a673af 100644 --- a/tests/unit/test_napari/test_registration_panel.py +++ b/tests/unit/test_napari/test_registration_panel.py @@ -32,7 +32,11 @@ make_bspline_transform_payload, save_transform_payload, ) -from confusius.registration import RegistrationDiagnostics, resample_like +from confusius.registration import ( + RegistrationDiagnostics, + resample_like, + resample_volume, +) @pytest.fixture @@ -96,6 +100,47 @@ def _make_bspline_transform() -> xr.DataArray: ) +def _install_immediate_thread_worker(monkeypatch: pytest.MonkeyPatch) -> None: + class _Signal: + def __init__(self) -> None: + self._slots: list[Any] = [] + + def connect(self, slot: Any) -> None: + self._slots.append(slot) + + def emit(self, *args: Any) -> None: + for slot in list(self._slots): + slot(*args) + + class _Worker: + def __init__(self, func: Any, args: tuple[Any, ...], kwargs: dict[str, Any]): + self._func = func + self._args = args + self._kwargs = kwargs + self.returned = _Signal() + self.errored = _Signal() + self.finished = _Signal() + + def start(self) -> None: + try: + self.returned.emit(self._func(*self._args, **self._kwargs)) + except Exception as exc: # noqa: BLE001 + self.errored.emit(exc) + finally: + self.finished.emit() + + def _thread_worker(func: Any) -> Any: + def _runner(*args: Any, **kwargs: Any) -> _Worker: + return _Worker(func, args, kwargs) + + return _runner + + monkeypatch.setattr( + "confusius._napari._registration._panel_transforms.thread_worker", + _thread_worker, + ) + + class TestRefreshLayers: def test_combo_populated_on_layer_add(self, viewer, registration_panel): assert registration_panel._moving_combo.count() == 0 @@ -129,12 +174,12 @@ def test_ignores_lazy_non_numpy_layers(self, viewer, registration_panel): class TestOperationMode: def test_panel_switch_shows_one_subpanel(self, registration_panel): - assert registration_panel._register_panel_radio.isCheckable() - assert registration_panel._transforms_panel_radio.isCheckable() assert not registration_panel._register_panel.isHidden() assert registration_panel._transforms_panel.isHidden() + registration_panel._transforms_panel_radio.setChecked(True) registration_panel._on_panel_changed() + assert registration_panel._register_panel.isHidden() assert not registration_panel._transforms_panel.isHidden() @@ -144,13 +189,6 @@ def test_volumewise_hides_fixed_selector(self, registration_panel): assert not registration_panel._reference_time_spin.isHidden() assert not registration_panel._n_jobs_row.isHidden() - def test_parallel_jobs_is_in_advanced_parameters(self, registration_panel): - registration_panel._time_series_radio.setChecked(True) - registration_panel._advanced_toggle.setChecked(True) - - assert not registration_panel._n_jobs_row.isHidden() - assert registration_panel._n_jobs_spin.parent() is not None - def test_between_scan_shows_masks_and_sitk_threads(self, registration_panel): registration_panel._advanced_toggle.setChecked(True) @@ -164,26 +202,6 @@ def test_between_scan_shows_masks_and_sitk_threads(self, registration_panel): assert registration_panel._moving_mask_row.isHidden() assert registration_panel._sitk_threads_row.isHidden() - def test_operation_row_order_is_moving_moving_mask_fixed_fixed_mask( - self, registration_panel - ): - layout = registration_panel._register_panel.layout().itemAt(0).widget().layout() - labels = [ - layout.itemAt(i, layout.ItemRole.LabelRole).widget().text() - for i in range(layout.rowCount()) - if layout.itemAt(i, layout.ItemRole.LabelRole) is not None - ] - assert labels.index("Moving layer") < labels.index("Moving mask") - assert labels.index("Moving mask") < labels.index("Fixed layer") - assert labels.index("Fixed layer") < labels.index("Fixed mask") - - def test_transform_target_label_is_apply_to(self, registration_panel): - label = registration_panel._transforms_panel.layout().labelForField( - registration_panel._transform_target_combo - ) - assert label is not None - assert label.text() == "Apply to" - def test_volume_shows_fixed_selector(self, registration_panel): registration_panel._time_series_radio.setChecked(True) registration_panel._single_volume_radio.setChecked(True) @@ -191,11 +209,6 @@ def test_volume_shows_fixed_selector(self, registration_panel): assert registration_panel._reference_time_spin.isHidden() assert registration_panel._n_jobs_row.isHidden() - def test_defaults_transform_to_rigid(self, registration_panel): - assert registration_panel._transform_combo.currentText() == "rigid" - registration_panel._time_series_radio.setChecked(True) - assert registration_panel._transform_combo.currentText() == "rigid" - def test_learning_rate_auto_disables_edit(self, registration_panel): assert registration_panel._learning_rate_auto_check.isChecked() assert not registration_panel._learning_rate_edit.isEnabled() @@ -231,8 +244,9 @@ def test_mode_switch_preserves_session_parameters(self, registration_panel): def test_advanced_group_is_collapsed_by_default(self, registration_panel): assert not registration_panel._advanced_toggle.isChecked() assert registration_panel._advanced_content.isHidden() - assert registration_panel._advanced_toggle.text() == "Advanced" + registration_panel._advanced_toggle.click() + assert not registration_panel._advanced_content.isHidden() def test_opening_advanced_group_does_not_widen_panel_minimum( @@ -258,7 +272,9 @@ def test_scientific_notation_spinboxes_parse_values(self, registration_panel): registration_panel._convergence_min_edit.interpretText() assert registration_panel._convergence_min_edit.value() == pytest.approx(2.5e-7) - def test_spinbox_defaults_and_minima(self, registration_panel): + def test_default_parameter_values(self, registration_panel): + assert registration_panel._transform_combo.currentText() == "rigid" + assert registration_panel._scale_combo.currentText() == "decibel" assert registration_panel._learning_rate_edit.minimum() == pytest.approx(1e-10) assert registration_panel._learning_rate_edit.value() == pytest.approx(0.1) assert registration_panel._convergence_min_edit.minimum() == pytest.approx( @@ -267,8 +283,9 @@ def test_spinbox_defaults_and_minima(self, registration_panel): assert registration_panel._convergence_min_edit.value() == pytest.approx(1e-6) assert registration_panel._iterations_spin.singleStep() == 100 - def test_scale_defaults_to_db(self, registration_panel): - assert registration_panel._scale_combo.currentText() == "decibel" + registration_panel._time_series_radio.setChecked(True) + + assert registration_panel._transform_combo.currentText() == "rigid" def test_scale_preprocessing_resets_gamma_for_previews( self, real_viewer, real_registration_panel @@ -384,39 +401,27 @@ def test_multi_resolution_toggle_hides_dependent_inputs(self, registration_panel assert not registration_panel._shrink_factors_row.isHidden() assert not registration_panel._smoothing_sigmas_row.isHidden() - def test_initialization_is_in_basic_parameters(self, registration_panel): - assert registration_panel._initialization_combo.parent() is not None - def test_mesh_size_is_basic_and_only_visible_for_bspline(self, registration_panel): - assert registration_panel._mesh_size_row.parent() is not None assert registration_panel._mesh_size_row.isHidden() - assert registration_panel._mesh_size_z_spin.value() == 10 - assert registration_panel._mesh_size_y_spin.value() == 10 - assert registration_panel._mesh_size_x_spin.value() == 10 + assert registration_panel._optimizer_weights_check.isEnabled() registration_panel._transform_combo.setCurrentText("bspline") + assert not registration_panel._mesh_size_row.isHidden() assert not registration_panel._optimizer_weights_check.isEnabled() - registration_panel._mesh_size_z_spin.setValue(5) - registration_panel._mesh_size_y_spin.setValue(7) - registration_panel._mesh_size_x_spin.setValue(9) - assert registration_panel._mesh_size_z_spin.value() == 5 - assert registration_panel._mesh_size_y_spin.value() == 7 - assert registration_panel._mesh_size_x_spin.value() == 9 - registration_panel._transform_combo.setCurrentText("rigid") + assert registration_panel._mesh_size_row.isHidden() assert registration_panel._optimizer_weights_check.isEnabled() registration_panel._time_series_radio.setChecked(True) + assert registration_panel._mesh_size_row.isHidden() class TestRunRegistration: - def test_create_labels_layer_matches_spatial_shape_of_time_series( - self, viewer, registration_panel - ): + def test_mask_buttons_create_named_layers(self, viewer, registration_panel): moving = xr.DataArray( np.zeros((2, 4, 6, 8), dtype=np.float32), dims=["time", "z", "y", "x"], @@ -433,31 +438,15 @@ def test_create_labels_layer_matches_spatial_shape_of_time_series( layer.scale = (1.0, 0.3, 0.2, 0.1) layer.translate = (0.0, 1.0, 2.0, 3.0) - registration_panel._create_labels_layer() - - labels = viewer.layers["Labels (3D)"] - assert np.asarray(labels.data).shape == (4, 6, 8) - assert tuple(labels.scale) == (0.3, 0.2, 0.1) - assert tuple(labels.translate) == (1.0, 2.0, 3.0) - - def test_mask_buttons_create_named_layers(self, viewer, registration_panel): - moving = xr.DataArray( - np.zeros((2, 4, 6, 8), dtype=np.float32), - dims=["time", "z", "y", "x"], - coords={ - "time": xr.DataArray(np.arange(2), dims=["time"]), - "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), - "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), - }, - ) - viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) - registration_panel._new_moving_mask_btn.click() registration_panel._new_fixed_mask_btn.click() - assert "Moving mask" in viewer.layers - assert "Fixed mask" in viewer.layers + moving_mask = viewer.layers["Moving mask"] + fixed_mask = viewer.layers["Fixed mask"] + assert np.asarray(moving_mask.data).shape == (4, 6, 8) + assert tuple(moving_mask.scale) == (0.3, 0.2, 0.1) + assert tuple(moving_mask.translate) == (1.0, 2.0, 3.0) + assert np.asarray(fixed_mask.data).shape == (4, 6, 8) def test_between_scan_run_uses_selected_initial_transform( self, viewer, registration_panel, monkeypatch @@ -581,6 +570,10 @@ def test_between_scan_run_uses_selected_manual_napari_transform( ) viewer.add_image(fixed.values, name="fixed", metadata={"xarray": fixed}) + registration_panel._refresh_layers() + registration_panel._moving_combo.setCurrentText("moving") + registration_panel._fixed_combo.setCurrentText("fixed") + manual_affine = np.eye(5) manual_affine[0, 4] = 9.0 manual_affine[1, 4] = 0.5 @@ -588,11 +581,8 @@ def test_between_scan_run_uses_selected_manual_napari_transform( manual_affine[3, 3] = 1.1 manual_affine[3, 4] = 0.75 moving_layer.affine = manual_affine + QApplication.processEvents() - registration_panel._refresh_layers() - refresh_transform_controls(registration_panel) - registration_panel._moving_combo.setCurrentText("moving") - registration_panel._fixed_combo.setCurrentText("fixed") for i in range(registration_panel._initialization_combo.count()): if registration_panel._initialization_combo.itemData(i) == ( "manual", @@ -650,42 +640,16 @@ def _runner(*args, **kwargs): assert args[0].dims == ("z", "y", "x") assert registration_panel._worker is not None - def test_between_scan_run_passes_masks_sitk_threads_and_optimizer_weights( - self, viewer, registration_panel, monkeypatch + @pytest.mark.parametrize( + ("within_scan", "transform", "weights"), + [ + (False, "rigid", [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]), + (True, "translation", [0.7, 0.8, 0.9]), + ], + ) + def test_run_passes_mode_specific_worker_kwargs( + self, viewer, registration_panel, monkeypatch, within_scan, transform, weights ): - moving = xr.DataArray( - np.zeros((4, 6, 8), dtype=np.float32), - dims=["z", "y", "x"], - coords={ - "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), - "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), - }, - ) - fixed = xr.DataArray( - np.ones((4, 6, 8), dtype=np.float32), - dims=["z", "y", "x"], - coords=moving.coords, - ) - viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) - viewer.add_image(fixed.values, name="fixed", metadata={"xarray": fixed}) - viewer.add_labels(np.ones((4, 6, 8), dtype=np.int32), name="fixed mask") - viewer.add_labels(np.ones((4, 6, 8), dtype=np.int32), name="moving mask") - registration_panel._refresh_layers() - registration_panel._moving_combo.setCurrentText("moving") - registration_panel._fixed_combo.setCurrentText("fixed") - registration_panel._fixed_mask_combo.setCurrentText("fixed mask") - registration_panel._moving_mask_combo.setCurrentText("moving mask") - registration_panel._sitk_threads_spin.setValue(3) - registration_panel._optimizer_weights_check.setChecked(True) - assert len(registration_panel._optimizer_weight_spins) == 6 - for spin, value in zip( - registration_panel._optimizer_weight_spins, - [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - strict=False, - ): - spin.setValue(value) - captured: dict[str, object] = {} class _FakeSignal: @@ -714,83 +678,83 @@ def _runner(*args, **kwargs): "confusius._napari._registration._panel.thread_worker", _fake_thread_worker, ) - monkeypatch.setattr( - "confusius._napari._registration._panel.create_volume_progress_plotter", - lambda *_args, **_kwargs: None, - ) - registration_panel._run_registration() - - kwargs = cast("dict[str, Any]", captured["kwargs"]) - assert kwargs["sitk_threads"] == 3 - assert kwargs["optimizer_weights"] == [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] - assert kwargs["fixed_mask"].dtype == bool - assert kwargs["moving_mask"].dtype == bool - assert registration_panel._worker is not None - - def test_volumewise_run_passes_optimizer_weights( - self, viewer, registration_panel, monkeypatch - ): - moving = xr.DataArray( - np.zeros((2, 4, 6, 8), dtype=np.float32), - dims=["time", "z", "y", "x"], - coords={ - "time": xr.DataArray(np.arange(2), dims=["time"]), - "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), - "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), - }, - ) - viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) - registration_panel._time_series_radio.setChecked(True) - registration_panel._refresh_layers() - registration_panel._moving_combo.setCurrentText("moving") - registration_panel._transform_combo.setCurrentText("translation") + if within_scan: + moving = xr.DataArray( + np.zeros((2, 4, 6, 8), dtype=np.float32), + dims=["time", "z", "y", "x"], + coords={ + "time": xr.DataArray(np.arange(2), dims=["time"]), + "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), + "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), + }, + ) + viewer.add_image( + moving.values, name="moving", metadata={"xarray": moving} + ) + registration_panel._time_series_radio.setChecked(True) + registration_panel._refresh_layers() + registration_panel._moving_combo.setCurrentText("moving") + monkeypatch.setattr( + "confusius._napari._registration._panel.setup_volumewise_progress", + lambda *_args, **_kwargs: None, + ) + else: + moving = xr.DataArray( + np.zeros((4, 6, 8), dtype=np.float32), + dims=["z", "y", "x"], + coords={ + "z": xr.DataArray(np.arange(4) * 0.3, dims=["z"]), + "y": xr.DataArray(np.arange(6) * 0.2, dims=["y"]), + "x": xr.DataArray(np.arange(8) * 0.1, dims=["x"]), + }, + ) + fixed = xr.DataArray( + np.ones((4, 6, 8), dtype=np.float32), + dims=["z", "y", "x"], + coords=moving.coords, + ) + viewer.add_image( + moving.values, name="moving", metadata={"xarray": moving} + ) + viewer.add_image(fixed.values, name="fixed", metadata={"xarray": fixed}) + viewer.add_labels( + np.ones((4, 6, 8), dtype=np.int32), name="fixed mask" + ) + viewer.add_labels( + np.ones((4, 6, 8), dtype=np.int32), name="moving mask" + ) + registration_panel._refresh_layers() + registration_panel._moving_combo.setCurrentText("moving") + registration_panel._fixed_combo.setCurrentText("fixed") + registration_panel._fixed_mask_combo.setCurrentText("fixed mask") + registration_panel._moving_mask_combo.setCurrentText("moving mask") + registration_panel._sitk_threads_spin.setValue(3) + monkeypatch.setattr( + "confusius._napari._registration._panel.create_volume_progress_plotter", + lambda *_args, **_kwargs: None, + ) + + registration_panel._transform_combo.setCurrentText(transform) registration_panel._optimizer_weights_check.setChecked(True) for spin, value in zip( registration_panel._optimizer_weight_spins, - [0.7, 0.8, 0.9], + weights, strict=False, ): spin.setValue(value) - captured: dict[str, object] = {} - - class _FakeSignal: - def connect(self, _slot): - return None - - class _FakeWorker: - def __init__(self) -> None: - self.returned = _FakeSignal() - self.errored = _FakeSignal() - self.finished = _FakeSignal() - - def start(self) -> None: - return None - - def _fake_thread_worker(func): - def _runner(*args, **kwargs): - captured["func"] = func - captured["args"] = args - captured["kwargs"] = kwargs - return _FakeWorker() - - return _runner - - monkeypatch.setattr( - "confusius._napari._registration._panel.thread_worker", - _fake_thread_worker, - ) - monkeypatch.setattr( - "confusius._napari._registration._panel.setup_volumewise_progress", - lambda *_args, **_kwargs: None, - ) - registration_panel._run_registration() kwargs = cast("dict[str, Any]", captured["kwargs"]) - assert kwargs["optimizer_weights"] == [0.7, 0.8, 0.9] + assert kwargs["optimizer_weights"] == weights + if within_scan: + assert kwargs["reference_time"] == 0 + else: + assert kwargs["sitk_threads"] == 3 + assert kwargs["fixed_mask"].dtype == bool + assert kwargs["moving_mask"].dtype == bool assert registration_panel._worker is not None @@ -823,16 +787,6 @@ def test_same_moving_and_fixed_is_flagged( assert not real_registration_panel._layer_validation.isHidden() assert "must be different" in real_registration_panel._layer_validation.text() - def test_between_scans_with_single_layer_flags_fixed( - self, real_viewer, real_registration_panel - ): - real_viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="only") - real_registration_panel._refresh_layers() - real_registration_panel._moving_combo.setCurrentText("only") - - assert not real_registration_panel._validate_registration_selection() - assert "must be different" in real_registration_panel._layer_validation.text() - def test_within_scan_requires_time_dimension(self, viewer, registration_panel): viewer.add_image(np.zeros((4, 6), dtype=np.float32), name="vol") registration_panel._refresh_layers() @@ -874,128 +828,6 @@ def test_between_scans_accepts_time_series_by_averaging( assert registration_panel._validate_registration_selection() - def test_initial_transform_dropdown_lists_available_transforms( - self, viewer, registration_panel - ): - reference = xr.DataArray( - np.ones((4, 6), dtype=np.float32), - dims=["y", "x"], - coords={ - "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), - }, - ) - payload = make_affine_transform_payload( - np.eye(3), - reference=reference, - source_layer_name="moving", - target_layer_name="fixed", - operation="register_volume", - transform_model="rigid", - metric="correlation", - diagnostics=_FakeDiagnostics(), - ) - - viewer.add_image( - reference.values, - name="Registered", - metadata={"confusius_transform": payload}, - ) - refresh_transform_controls(registration_panel) - - assert registration_panel._initialization_combo.itemText(0) == "center_geometry" - assert registration_panel._initialization_combo.count() >= 4 - assert any( - registration_panel._initialization_combo.itemData(i) - == ("layer", "Registered") - for i in range(registration_panel._initialization_combo.count()) - ) - - def test_initial_transform_dropdown_lists_manual_napari_transforms( - self, viewer, registration_panel - ): - moving = xr.DataArray( - np.zeros((2, 4, 6), dtype=np.float32), - dims=["z", "y", "x"], - coords={ - "z": xr.DataArray(np.arange(2), dims=["z"]), - "y": xr.DataArray(np.arange(4), dims=["y"]), - "x": xr.DataArray(np.arange(6), dims=["x"]), - }, - ) - layer = viewer.add_image( - moving.values, name="moving", metadata={"xarray": moving} - ) - manual_affine = np.eye(4) - manual_affine[0, 3] = 1.0 - layer.affine = manual_affine - - refresh_transform_controls(registration_panel) - - assert any( - registration_panel._initialization_combo.itemData(i) == ("manual", "moving") - for i in range(registration_panel._initialization_combo.count()) - ) - - def test_transform_source_dropdown_lists_manual_napari_transforms( - self, viewer, registration_panel - ): - moving = xr.DataArray( - np.zeros((2, 4, 6), dtype=np.float32), - dims=["z", "y", "x"], - coords={ - "z": xr.DataArray(np.arange(2), dims=["z"]), - "y": xr.DataArray(np.arange(4), dims=["y"]), - "x": xr.DataArray(np.arange(6), dims=["x"]), - }, - ) - layer = viewer.add_image( - moving.values, name="moving", metadata={"xarray": moving} - ) - manual_affine = np.eye(4) - manual_affine[0, 3] = 1.0 - layer.affine = manual_affine - - refresh_transform_controls(registration_panel) - - assert any( - registration_panel._transform_source_combo.itemData(i) - == ("manual", "moving") - for i in range(registration_panel._transform_source_combo.count()) - ) - - def test_initial_transform_dropdown_updates_when_manual_transform_changes( - self, viewer, registration_panel - ): - moving = xr.DataArray( - np.zeros((2, 4, 6), dtype=np.float32), - dims=["z", "y", "x"], - coords={ - "z": xr.DataArray(np.arange(2), dims=["z"]), - "y": xr.DataArray(np.arange(4), dims=["y"]), - "x": xr.DataArray(np.arange(6), dims=["x"]), - }, - ) - layer = viewer.add_image( - moving.values, name="moving", metadata={"xarray": moving} - ) - registration_panel._refresh_layers() - - assert not any( - registration_panel._initialization_combo.itemData(i) == ("manual", "moving") - for i in range(registration_panel._initialization_combo.count()) - ) - - manual_affine = np.eye(4) - manual_affine[0, 3] = 1.0 - layer.affine = manual_affine - QApplication.processEvents() - - assert any( - registration_panel._initialization_combo.itemData(i) == ("manual", "moving") - for i in range(registration_panel._initialization_combo.count()) - ) - class TestTransforms: def test_affine_payload_roundtrip(self, tmp_path): @@ -1111,15 +943,16 @@ def test_apply_transform_uses_bspline_payload( self, viewer, registration_panel, monkeypatch ): moving = xr.DataArray( - np.zeros((3, 4), dtype=np.float32), + np.arange(12, dtype=np.float32).reshape(3, 4), dims=["y", "x"], coords={ "y": xr.DataArray(np.arange(3) * 0.2, dims=["y"]), "x": xr.DataArray(np.arange(4) * 0.1, dims=["x"]), }, ) + transform = _make_bspline_transform().astype(float) payload = make_bspline_transform_payload( - _make_bspline_transform(), + transform, reference=moving, source_layer_name="moving", target_layer_name="fixed", @@ -1128,11 +961,7 @@ def test_apply_transform_uses_bspline_payload( metric="correlation", diagnostics=_FakeDiagnostics(), ) - viewer.add_image( - moving.values, - name="moving", - metadata={"xarray": moving}, - ) + viewer.add_image(moving.values, name="moving", metadata={"xarray": moving}) viewer.add_image( moving.values, name="Registered (bspline)", @@ -1143,46 +972,39 @@ def test_apply_transform_uses_bspline_payload( "moving → fixed (bspline)" ) registration_panel._transform_target_combo.setCurrentText("moving") + _install_immediate_thread_worker(monkeypatch) - captured: dict[str, object] = {} - - class _FakeSignal: - def connect(self, _slot): - return None - - class _FakeWorker: - def __init__(self) -> None: - self.returned = _FakeSignal() - self.errored = _FakeSignal() - self.finished = _FakeSignal() - - def start(self) -> None: - return None - - def _fake_thread_worker(func): - def _runner(*args, **kwargs): - captured["func"] = func - captured["args"] = args - captured["kwargs"] = kwargs - return _FakeWorker() + output_grid = get_output_grid_from_payload(payload) + expected = xr.DataArray( + np.full(output_grid["shape"], 7.0, dtype=np.float32), + dims=output_grid["dims"], + coords={ + dim: xr.DataArray( + output_grid["origin"][i] + + np.arange(output_grid["shape"][i]) * output_grid["spacing"][i], + dims=[dim], + ) + for i, dim in enumerate(output_grid["dims"]) + }, + ) - return _runner + def _fake_resample_volume(*args: Any, **kwargs: Any) -> xr.DataArray: + return expected monkeypatch.setattr( - "confusius._napari._registration._panel_transforms.thread_worker", - _fake_thread_worker, + "confusius._napari._registration._panel_transforms.resample_volume", + _fake_resample_volume, ) apply_selected_transform(registration_panel) - func = cast("Any", captured["func"]) - args = cast("tuple[Any, ...]", captured["args"]) - assert func.__name__ == "resample_volume" - xr.testing.assert_identical( - args[1], - _make_bspline_transform().astype(float), - ) - assert registration_panel._worker is not None + layer = viewer.layers["moving → fixed"] + result = layer.metadata["xarray"] + np.testing.assert_array_equal(result.values, expected.values) + np.testing.assert_allclose(result.coords["y"], expected.coords["y"]) + np.testing.assert_allclose(result.coords["x"], expected.coords["x"]) + assert layer.metadata["transform_source"] == "moving → fixed (bspline)" + assert layer.metadata["registration_operation"] == "apply_transform" def test_apply_inverse_transform_uses_inverse_affine_and_input_grid( self, viewer, registration_panel, monkeypatch @@ -1230,65 +1052,34 @@ def test_apply_inverse_transform_uses_inverse_affine_and_input_grid( "source → target (affine)" ) registration_panel._transform_target_combo.setCurrentText("target") - - captured: dict[str, object] = {} - - class _FakeSignal: - def connect(self, _slot): - return None - - class _FakeWorker: - def __init__(self) -> None: - self.returned = _FakeSignal() - self.errored = _FakeSignal() - self.finished = _FakeSignal() - - def start(self) -> None: - return None - - def _fake_thread_worker(func): - def _runner(*args, **kwargs): - captured["func"] = func - captured["args"] = args - captured["kwargs"] = kwargs - return _FakeWorker() - - return _runner - - monkeypatch.setattr( - "confusius._napari._registration._panel_transforms.thread_worker", - _fake_thread_worker, - ) + _install_immediate_thread_worker(monkeypatch) apply_selected_inverse_transform(registration_panel) - func = cast("Any", captured["func"]) - args = cast("tuple[Any, ...]", captured["args"]) - kwargs = cast("dict[str, Any]", captured["kwargs"]) - assert func.__name__ == "resample_volume" - np.testing.assert_allclose(args[1], np.linalg.inv(affine)) - assert kwargs["shape"] == [3, 4] - assert kwargs["spacing"] == [0.2, 0.1] - assert kwargs["origin"] == [0.0, 0.0] - assert kwargs["dims"] == ["y", "x"] - assert registration_panel._worker is not None - - -class TestPluginWidget: - def test_registration_panel_is_present_in_main_widget(self, real_viewer): - from confusius._napari._widget import ConfUSIusWidget - - widget = ConfUSIusWidget(real_viewer) - - assert "Registration" in widget._accordion_panels + input_grid = get_input_grid_from_payload(payload) + expected = resample_volume( + target, + np.linalg.inv(affine), + shape=input_grid["shape"], + spacing=input_grid["spacing"], + origin=input_grid["origin"], + dims=input_grid["dims"], + interpolation="linear", + ) + layer = viewer.layers["target → source"] + result = layer.metadata["xarray"] + np.testing.assert_allclose(result.values, expected.values) + assert tuple(result.dims) == tuple(source.dims) + np.testing.assert_allclose(result.coords["y"], source.coords["y"]) + np.testing.assert_allclose(result.coords["x"], source.coords["x"]) + assert layer.metadata["transform_source"] == "source → target (affine)" + assert layer.metadata["registration_operation"] == "apply_inverse_transform" class TestVolumewiseProgress: def test_setup_updates_progress_bar_and_output_layer( self, viewer, registration_panel ): - from confusius._napari._registration._panel import _get_source_dataarray - moving = xr.DataArray( np.linspace(-2.0, 3.0, 3 * 4 * 6, dtype=np.float32).reshape(3, 4, 6), dims=["time", "y", "x"], @@ -1303,8 +1094,6 @@ def test_setup_updates_progress_bar_and_output_layer( name="series", metadata={"xarray": moving}, ) - moving = _get_source_dataarray(moving_layer) - progress = setup_volumewise_progress( registration_panel, moving_layer=moving_layer, @@ -1316,22 +1105,12 @@ def test_setup_updates_progress_bar_and_output_layer( assert registration_panel._volumewise_progress_layer is not None assert registration_panel._volumewise_moving_preview_layer is not None assert registration_panel._progress.maximum() == 3 - assert registration_panel._progress.isTextVisible() - assert registration_panel._progress.minimumHeight() >= 16 - assert moving_layer.colormap.name != "red" - - moving_preview_layer = viewer.layers["Moving"] - assert moving_preview_layer.colormap.name == "red" - preview_layer = viewer.layers["Motion corrected"] - assert preview_layer.colormap.name == "cyan" - assert preview_layer.blending == "additive" + assert registration_panel._progress.value() == 0 np.testing.assert_array_equal( - np.asarray(preview_layer.data), + np.asarray(viewer.layers["Motion corrected"].data), np.full(moving.shape, float(moving.min()), dtype=np.float32), ) - assert registration_panel._progress.value() == 0 - frame = xr.DataArray( np.ones((4, 6), dtype=np.float32), dims=["y", "x"], @@ -1348,44 +1127,6 @@ def test_setup_updates_progress_bar_and_output_layer( np.asarray(frame.values), ) - def test_frame_completion_updates_frame_progress(self, viewer, registration_panel): - moving = xr.DataArray( - np.zeros((3, 4, 6), dtype=np.float32), - dims=["time", "y", "x"], - coords={ - "time": xr.DataArray(np.arange(3), dims=["time"]), - "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), - }, - ) - moving_layer = viewer.add_image( - moving.values, - name="series", - metadata={"xarray": moving}, - ) - progress = setup_volumewise_progress( - registration_panel, - moving_layer=moving_layer, - moving=moving, - layer_name="Motion corrected", - scale_mode="off", - ) - - frame = xr.DataArray( - np.ones((4, 6), dtype=np.float32), - dims=["y", "x"], - coords={ - "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), - }, - ) - progress.frame_completed(0, frame, _FakeDiagnostics(n_iterations=2)) - progress.frame_completed(1, frame, _FakeDiagnostics(n_iterations=1)) - progress.frame_completed(2, frame, _FakeDiagnostics(n_iterations=3)) - - assert registration_panel._progress.maximum() == 3 - assert registration_panel._progress.value() == 3 - class TestFinishedCallbacks: def test_volume_result_adds_new_layer_with_transform_metadata( @@ -1476,11 +1217,8 @@ def test_volume_result_adds_bspline_transform_metadata( ) def test_volume_result_replaces_preview_layer( - self, real_viewer, real_registration_panel, qtbot + self, real_viewer, real_registration_panel ): - """A preview layer created by `create_volume_progress_plotter` is removed - after `_on_registration_finished` so the final result is the only - layer with that name.""" moving_data = xr.DataArray( np.zeros((4, 6), dtype=np.float32), dims=["y", "x"], @@ -1504,7 +1242,7 @@ def test_volume_result_replaces_preview_layer( np.ones((4, 6), dtype=np.float32), name="fixed" ) - factory = create_volume_progress_plotter( + create_volume_progress_plotter( real_registration_panel, moving_layer=moving, fixed_layer=fixed_layer, @@ -1513,43 +1251,13 @@ def test_volume_result_replaces_preview_layer( layer_name="Registered (rigid)", scale_mode="off", ) - assert factory is not None assert {"Fixed", "Moving", "Registered (rigid)"}.issubset( {layer.name for layer in real_viewer.layers} ) - assert real_registration_panel._progress_layer is not None - assert real_registration_panel._progress_bridge is not None - assert real_registration_panel._progress_fixed_layer is not None - assert real_registration_panel._progress_moving_layer is not None - # Original layers are left untouched. - assert fixed_layer.colormap.name != "red" - assert moving.colormap.name != "cyan" - assert moving.blending != "additive" - assert moving.visible - # Dedicated preview layers carry the registration styling. - fixed_preview = real_viewer.layers["Fixed"] - moving_preview = real_viewer.layers["Moving"] - preview_layer = real_viewer.layers["Registered (rigid)"] - assert fixed_preview.colormap.name == "red" - assert moving_preview.colormap.name == "cyan" - assert moving_preview.blending == "additive" - assert not moving_preview.visible - assert preview_layer.colormap.name == "cyan" - assert preview_layer.blending == "additive" - assert preview_layer.visible - np.testing.assert_array_equal( - np.asarray(preview_layer.data), - np.asarray(moving.data), - ) - np.testing.assert_array_equal( - np.asarray(moving_preview.data), - np.asarray(preview_layer.data), - ) registered = fixed.copy() transform = np.eye(3) diagnostics = _FakeDiagnostics() - payload = { "operation": "register_volume", "moving_layer_name": "moving", @@ -1567,27 +1275,16 @@ def test_volume_result_replaces_preview_layer( (registered, transform, diagnostics), ) - # The resampled preview is kept and promoted to the final registered - # layer so the user can keep reviewing the fixed / moving / result - # stack after the run. assert real_registration_panel._progress_layer is None assert real_registration_panel._progress_bridge is None - assert {"Fixed", "Moving", "Registered (rigid)"}.issubset( - {layer.name for layer in real_viewer.layers} - ) - assert not real_viewer.layers["Moving"].visible result_layer = real_viewer.layers["Registered (rigid)"] - assert result_layer.colormap.name == "cyan" - assert result_layer.blending == "additive" - # Original source layers remain untouched. - assert moving.visible - assert moving.colormap.name != "cyan" - assert moving.blending != "additive" - assert fixed_layer.colormap.name != "red" - assert np.array_equal( + np.testing.assert_array_equal( np.asarray(result_layer.data), np.asarray(registered.values), ) + assert {"Fixed", "Moving", "Registered (rigid)"}.issubset( + {layer.name for layer in real_viewer.layers} + ) def test_create_volume_progress_plotter_applies_initial_transform_to_preview_layers( self, real_viewer, real_registration_panel @@ -1636,10 +1333,8 @@ def test_create_volume_progress_plotter_applies_initial_transform_to_preview_lay ) def test_progress_layer_data_updates_on_iteration( - self, real_viewer, real_registration_panel, qtbot + self, real_viewer, real_registration_panel ): - """`_update_progress_layer` writes the iterated array into the preview - layer's data, refreshing the canvas.""" moving_data = xr.DataArray( np.zeros((4, 6), dtype=np.float32), dims=["y", "x"], @@ -1672,19 +1367,13 @@ def test_progress_layer_data_updates_on_iteration( layer_name="Registered (rigid)", scale_mode="off", ) - # The preview is seeded with the moving image resampled onto the - # fixed grid, so it's visible and meaningful from the start. - preview_layer = real_viewer.layers["Registered (rigid)"] - assert preview_layer.visible next_arr = np.full((4, 6), 0.5, dtype=np.float32) update_progress_layer(real_registration_panel, next_arr) - np.testing.assert_array_equal( np.asarray(real_viewer.layers["Registered (rigid)"].data), next_arr ) - # Shape mismatch is silently ignored. update_progress_layer( real_registration_panel, np.zeros((3, 6), dtype=np.float32) ) @@ -1692,113 +1381,11 @@ def test_progress_layer_data_updates_on_iteration( np.asarray(real_viewer.layers["Registered (rigid)"].data), next_arr ) - # Teardown removes only the in-flight registered layer while leaving - # the reusable fixed / moving previews and originals untouched. teardown_volume_progress(real_registration_panel) assert real_registration_panel._progress_layer is None - assert real_registration_panel._progress_bridge is None assert "Registered (rigid)" not in {layer.name for layer in real_viewer.layers} - assert "Fixed" in {layer.name for layer in real_viewer.layers} - assert "Moving" in {layer.name for layer in real_viewer.layers} - assert moving.visible - assert moving.colormap.name != "cyan" - assert moving.blending != "additive" - - def test_create_volume_progress_plotter_creates_metric_plotter_dock( - self, real_viewer, real_registration_panel - ): - """`create_volume_progress_plotter` lazily creates and docks the metric plotter.""" - moving_data = xr.DataArray( - np.zeros((4, 6), dtype=np.float32), - dims=["y", "x"], - coords={ - "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), - }, - ) - moving = real_viewer.add_image( - np.zeros((4, 6), dtype=np.float32), name="moving" - ) - fixed = xr.DataArray( - np.zeros((4, 6), dtype=np.float32), - dims=["y", "x"], - coords={ - "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), - }, - ) - fixed_layer = real_viewer.add_image( - np.zeros((4, 6), dtype=np.float32), name="fixed" - ) - - assert real_registration_panel._metric_plotter is None - assert real_registration_panel._metric_dock is None - - create_volume_progress_plotter( - real_registration_panel, - moving_layer=moving, - fixed_layer=fixed_layer, - moving=moving_data, - fixed=fixed, - layer_name="Registered (rigid)", - scale_mode="off", - ) - - assert real_registration_panel._metric_plotter is not None - assert real_registration_panel._metric_dock is not None - # The plotter is parented to a dock (i.e. it has been re-parented - # away from its original parent). - assert real_registration_panel._metric_plotter.parent() is not None - - # Feeding a value through the bridge populates the plotter's buffer. - bridge = real_registration_panel._progress_bridge - assert bridge is not None - bridge.metric_updated.emit(0.5) - # Force a render so the throttled redraw is observed synchronously. - real_registration_panel._metric_plotter._render() - assert real_registration_panel._metric_plotter.metric_values == [0.5] - - # Tearing down keeps the plotter (so the user can inspect the trace). - teardown_volume_progress(real_registration_panel) - assert real_registration_panel._metric_plotter is not None - assert real_registration_panel._metric_plotter.metric_values == [0.5] def test_volumewise_result_adds_registered_layer(self, viewer, registration_panel): - registered = xr.DataArray( - np.ones((3, 4, 6), dtype=np.float32), - dims=["time", "y", "x"], - coords={ - "time": xr.DataArray(np.arange(3), dims=["time"]), - "y": xr.DataArray(np.arange(4) * 0.2, dims=["y"]), - "x": xr.DataArray(np.arange(6) * 0.1, dims=["x"]), - }, - attrs={"motion_params": object()}, - ) - - payload = { - "operation": "register_volumewise", - "moving_layer_name": "series", - "transform": "rigid", - "metric": "correlation", - "learning_rate": "auto", - "number_of_iterations": 100, - "use_multi_resolution": False, - "resample_interpolation": "linear", - "reference_time": 1, - } - - on_registration_finished(registration_panel, payload, registered) - - layer = viewer.layers["Motion corrected"] - assert layer.metadata["reference_time"] == 1 - assert layer.metadata["registration_operation"] == "register_volumewise" - assert "registration_status" not in layer.metadata - assert ( - layer.metadata["xarray"].attrs["registration_operation"] - == "register_volumewise" - ) - - def test_volumewise_finished_keeps_preview_layers(self, viewer, registration_panel): moving = xr.DataArray( np.zeros((3, 4, 6), dtype=np.float32), dims=["time", "y", "x"], @@ -1841,11 +1428,18 @@ def test_volumewise_finished_keeps_preview_layers(self, viewer, registration_pan on_registration_finished(registration_panel, payload, registered) + layer = viewer.layers["Motion corrected"] + np.testing.assert_array_equal(np.asarray(layer.data), registered.values) + assert layer.metadata["reference_time"] == 1 + assert layer.metadata["registration_operation"] == "register_volumewise" assert {"Moving", "Motion corrected"}.issubset( - {layer.name for layer in viewer.layers} + {existing.name for existing in viewer.layers} + ) + assert "registration_status" not in layer.metadata + assert ( + layer.metadata["xarray"].attrs["registration_operation"] + == "register_volumewise" ) - assert viewer.layers["series"].colormap.name != "red" - assert viewer.layers["Moving"].colormap.name == "red" def test_unique_transform_and_result_names(self, viewer, registration_panel): fixed = xr.DataArray( diff --git a/tests/unit/test_napari/test_registration_progress.py b/tests/unit/test_napari/test_registration_progress.py index 37d29b83..30618c5f 100644 --- a/tests/unit/test_napari/test_registration_progress.py +++ b/tests/unit/test_napari/test_registration_progress.py @@ -65,29 +65,6 @@ def __call__(self, payload: Any) -> None: self.payloads.append(payload) -class TestNapariRegistrationProgressPlotterBridge: - """Signal bridge behaviour.""" - - def test_iterated_signal_is_emitted(self, qtbot): - bridge = NapariRegistrationProgressPlotterBridge() - spy = _SignalSpy() - bridge.iterated.connect(spy) - with qtbot.waitSignal(bridge.iterated, timeout=1000): - bridge.iterated.emit(np.zeros((2, 2), dtype=np.float32)) - assert len(spy.payloads) == 1 - np.testing.assert_array_equal(spy.payloads[0], np.zeros((2, 2))) - - def test_finished_signal_is_emitted(self, qtbot): - bridge = NapariRegistrationProgressPlotterBridge() - with qtbot.waitSignal(bridge.finished, timeout=1000): - bridge.finished.emit() - - def test_metric_updated_signal_is_emitted(self, qtbot): - bridge = NapariRegistrationProgressPlotterBridge() - with qtbot.waitSignal(bridge.metric_updated, timeout=1000): - bridge.metric_updated.emit(0.42) - - class TestNapariRegistrationProgressPlotter: """Per-iteration reporter behaviour.""" @@ -129,11 +106,12 @@ def test_update_emits_metric_value(self, qtbot, fixed_img_2d, moving_img_2d): resample_kwargs={"fill_value": 0.0}, ) + expected_metric = float(reg.GetMetricValue()) + with qtbot.waitSignal(bridge.metric_updated, timeout=2000): reporter.update() - assert len(metric_spy.payloads) == 1 - assert isinstance(metric_spy.payloads[0], float) + assert metric_spy.payloads == [pytest.approx(expected_metric)] def test_update_skips_metric_when_plot_metric_false( self, qtbot, fixed_img_2d, moving_img_2d @@ -173,42 +151,6 @@ def test_close_emits_finished_signal(self, qtbot, fixed_img_2d, moving_img_2d): reporter.close() -class TestNapariRegistrationProgressReporterBridge: - """Signal bridge behaviour for volumewise registration.""" - - def test_frame_progress_signal_is_emitted(self, qtbot): - bridge = NapariRegistrationProgressReporterBridge() - payloads: list[tuple[int, int]] = [] - bridge.frame_progress.connect( - lambda completed, total: payloads.append((completed, total)) - ) - - with qtbot.waitSignal(bridge.frame_progress, timeout=1000): - bridge.frame_progress.emit(1, 3) - - assert payloads == [(1, 3)] - - def test_frame_completed_signal_is_emitted(self, qtbot): - bridge = NapariRegistrationProgressReporterBridge() - payloads: list[tuple[int, np.ndarray]] = [] - bridge.frame_completed.connect( - lambda index, array: payloads.append((index, array)) - ) - expected = np.ones((2, 2), dtype=np.float32) - - with qtbot.waitSignal(bridge.frame_completed, timeout=1000): - bridge.frame_completed.emit(2, expected) - - assert len(payloads) == 1 - assert payloads[0][0] == 2 - np.testing.assert_array_equal(payloads[0][1], expected) - - def test_finished_signal_is_emitted(self, qtbot): - bridge = NapariRegistrationProgressReporterBridge() - with qtbot.waitSignal(bridge.finished, timeout=1000): - bridge.finished.emit() - - class TestNapariRegistrationProgressReporter: """Aggregate per-frame progress for volumewise registration.""" @@ -238,6 +180,24 @@ def test_frame_completed_emits_progress_and_array(self, qtbot): assert frame_payloads[0][0] == 1 np.testing.assert_array_equal(frame_payloads[0][1], frame.values) + def test_frame_completed_accumulates_unique_progress(self, qtbot): + import xarray as xr + + bridge = NapariRegistrationProgressReporterBridge() + reporter = NapariRegistrationProgressReporter(bridge, n_frames=3) + progress_payloads: list[tuple[int, int]] = [] + bridge.frame_progress.connect( + lambda completed, total: progress_payloads.append((completed, total)) + ) + frame = xr.DataArray(np.ones((2, 2), dtype=np.float32), dims=("y", "x")) + diagnostics = object() + + reporter.frame_completed(1, frame, diagnostics) # type: ignore[arg-type] + reporter.frame_completed(2, frame, diagnostics) # type: ignore[arg-type] + + qtbot.waitUntil(lambda: len(progress_payloads) == 2, timeout=1000) + assert progress_payloads == [(1, 3), (2, 3)] + def test_close_emits_finished_signal(self, qtbot): bridge = NapariRegistrationProgressReporterBridge() reporter = NapariRegistrationProgressReporter(bridge, n_frames=3) @@ -266,10 +226,12 @@ def test_factory_returns_napari_volume_progress( ) assert isinstance(plotter, NapariRegistrationProgressPlotter) - assert plotter._bridge is bridge - assert plotter._method is reg - assert plotter._fixed_img is fixed_img_2d - assert plotter._moving_img is moving_img_2d + + with qtbot.waitSignals( + [bridge.metric_updated, bridge.iterated, bridge.finished], timeout=2000 + ): + plotter.update() + plotter.close() class TestRegisterVolumeWithNapariFactory: From 2db518b8a261df8376c7f4ef9383499dce4c6a56 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Thu, 2 Jul 2026 17:57:39 +0100 Subject: [PATCH 70/72] test(napari): handle nan registration metric --- tests/unit/test_napari/test_registration_progress.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_napari/test_registration_progress.py b/tests/unit/test_napari/test_registration_progress.py index 30618c5f..bc0b86cb 100644 --- a/tests/unit/test_napari/test_registration_progress.py +++ b/tests/unit/test_napari/test_registration_progress.py @@ -111,7 +111,12 @@ def test_update_emits_metric_value(self, qtbot, fixed_img_2d, moving_img_2d): with qtbot.waitSignal(bridge.metric_updated, timeout=2000): reporter.update() - assert metric_spy.payloads == [pytest.approx(expected_metric)] + assert len(metric_spy.payloads) == 1 + emitted_metric = metric_spy.payloads[0] + if np.isnan(expected_metric): + assert np.isnan(emitted_metric) + else: + assert emitted_metric == pytest.approx(expected_metric) def test_update_skips_metric_when_plot_metric_false( self, qtbot, fixed_img_2d, moving_img_2d From 5876d3bbbe4e4f5092dbafcdc6f72997137d2f34 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sat, 4 Jul 2026 09:14:25 +0100 Subject: [PATCH 71/72] docs: minor touchup --- docs/gui/plugin.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/gui/plugin.md b/docs/gui/plugin.md index 393344b5..51b2db1c 100644 --- a/docs/gui/plugin.md +++ b/docs/gui/plugin.md @@ -194,7 +194,8 @@ signals (from the current source mode) and imported signals: ## Events Panel -The Events Panel annotates *periods of time*—not individual frames—following the [BIDS +The Events Panel allows annotating *periods of time*—not individual frames—following the +[BIDS events](https://bids-specification.readthedocs.io/en/stable/modality-agnostic-files/events.html) convention (`onset`, `duration`, and an optional `trial_type`). Annotated events shade the [signal plot](#signals-panel) and are named in the time overlay while they are From 6a80238e45a6fc93d9f0f129673b8af387154abe Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Tue, 7 Jul 2026 22:41:39 +0100 Subject: [PATCH 72/72] chore: misc improve in error --- src/confusius/datasets/_osf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/confusius/datasets/_osf.py b/src/confusius/datasets/_osf.py index 8b3823dd..d0543070 100644 --- a/src/confusius/datasets/_osf.py +++ b/src/confusius/datasets/_osf.py @@ -236,7 +236,7 @@ def _validate_index(index: object, data_dir: Path) -> None: f"The dataset index in {data_dir} has an outdated or unrecognised structure " f"(every entry must define {sorted(_INDEX_ENTRY_KEYS)}). This local dataset was " f"likely downloaded with an older version of confusius. Delete the dataset " - f"directory and fetch it again to update it:\n\n {data_dir}\n" + f"directory and fetch it again to update it." )