From 5cea41255336dd4e33bec02dd6bc25a6fe22877f Mon Sep 17 00:00:00 2001 From: payam Date: Tue, 13 Oct 2026 17:59:11 +0200 Subject: [PATCH 1/5] add toolbar, shortcuts, and ICP progress to coregistration gui --- mne/gui/_coreg.py | 122 +++++++++++++++++++++++++++++++--- mne/gui/tests/test_coreg.py | 31 +++++++++ mne/viz/backends/_abstract.py | 12 +++- mne/viz/backends/_notebook.py | 18 ++++- mne/viz/backends/_qt.py | 35 ++++++++-- 5 files changed, 198 insertions(+), 20 deletions(-) diff --git a/mne/gui/_coreg.py b/mne/gui/_coreg.py index de218f124b3..2a1646bb411 100644 --- a/mne/gui/_coreg.py +++ b/mne/gui/_coreg.py @@ -69,6 +69,10 @@ from ..viz.utils import safe_event +class _ICPStopped(Exception): + """Raised from the ICP callback to stop a running fit.""" + + class _WorkerData: def __init__(self, name, params=None): self._name = name @@ -421,6 +425,7 @@ def _get_default(var, val): self._reset_fitting_parameters() self._configure_dialogs() self._configure_status_bar() + self._configure_tool_bar() self._configure_dock() self._configure_picking() self._configure_legend() @@ -781,6 +786,7 @@ def _lock_fids_changed(self, change=None): # MRI fiducials "save_mri_fids", # View options + "head_shape_points", "helmet", "meg", "head_opacity", @@ -1531,34 +1537,58 @@ def _fit_icp(self): def _fits_icp(self): self._fit_icp_real(update_head=True) + def _stop_icp(self): + self._icp_stop_requested = True + def _fit_icp_real(self, *, update_head): with self._lock(params=True, fitting=True): self._current_icp_iterations = 0 + self._icp_stop_requested = False updates = ["hsp", "hpi", "sensors", "head_fids", "helmet"] if update_head: updates.insert(0, "head") + self._widgets["status_progress"].set_range([0, 0]) + self._forward_widget_command( + "status_progress", "show", value=None, input_value=False + ) + self._forward_widget_command("stop_icp", "set_enabled", True) def callback(iteration, n_iterations): - self._display_message(f"Fitting ICP - iteration {iteration + 1}") + self._display_message( + f"Fitting ICP - iteration {iteration + 1} (max {n_iterations}), " + "Esc to stop" + ) self._update_plot(updates) self._current_icp_iterations += 1 self._update_distance_estimation() self._update_parameters() - self._renderer._process_events() # allow a draw or cancel + self._renderer._process_events() # allow a draw or a Stop click + if self._icp_stop_requested: + raise _ICPStopped start = time.time() - self.coreg.fit_icp( - n_iterations=self._icp_n_iterations, - lpa_weight=self._lpa_weight, - nasion_weight=self._nasion_weight, - rpa_weight=self._rpa_weight, - callback=callback, - verbose=self._verbose, - ) + stopped = False + try: + self.coreg.fit_icp( + n_iterations=self._icp_n_iterations, + lpa_weight=self._lpa_weight, + nasion_weight=self._nasion_weight, + rpa_weight=self._rpa_weight, + callback=callback, + verbose=self._verbose, + ) + except _ICPStopped: # keep the alignment from the last iteration + stopped = True + finally: + self._forward_widget_command("stop_icp", "set_enabled", False) + self._forward_widget_command( + "status_progress", "hide", value=None, input_value=False + ) end = time.time() self._display_message() self._display_message( - f"Fitting ICP finished in {end - start:.2f} seconds and " + f"Fitting ICP {'stopped' if stopped else 'finished'} in " + f"{end - start:.2f} seconds and " f"{self._current_icp_iterations} iterations." ) del self._current_icp_iterations @@ -1920,6 +1950,14 @@ def _configure_dock(self): name="View Options", collapse=collapse, ) + self._widgets["head_shape_points"] = self._renderer._dock_add_check_box( + name="Show head shape points", + value=self._head_shape_points, + callback=self._set_head_shape_points, + tooltip="Show/hide the digitized head shape points", + layout=view_options_layout, + shortcut="h", + ) self._widgets["helmet"] = self._renderer._dock_add_check_box( name="Show MEG helmet", value=self._helmet, @@ -2080,6 +2118,14 @@ def _configure_dock(self): tooltip="Find rotation and translation to match the head shape points", layout=fit_layout, ) + self._widgets["stop_icp"] = self._renderer._dock_add_button( + name="Stop", + callback=self._stop_icp, + tooltip="Stop the running ICP fit, keeping the alignment so far", + layout=fit_layout, + shortcut="Esc", + ) + self._forward_widget_command("stop_icp", "set_enabled", False) self._renderer._layout_add_widget(param_layout, fit_layout) trans_layout = self._renderer._dock_add_group_box( name="HEAD <> MRI Transform", @@ -2190,6 +2236,60 @@ def _configure_status_bar(self): self._forward_widget_command( "status_message", "hide", value=None, input_value=False ) + self._widgets["status_progress"] = self._renderer._status_bar_add_progress_bar() + self._forward_widget_command( + "status_progress", "hide", value=None, input_value=False + ) + + def _configure_tool_bar(self): + self._renderer._tool_bar_initialize(name="Toolbar") + self._renderer._tool_bar_add_file_button( + name="screenshot", + desc="Take a screenshot", + func=self._save_screenshot, + ) + self._renderer._tool_bar_add_button( + name="visibility", + desc="Toggle Controls", + func=self._toggle_interface, + icon_name="visibility_on", + shortcut="i", + ) + self._renderer._tool_bar_add_spacer() + self._renderer._tool_bar_add_button( + name="help", desc="Help", func=self._show_help, shortcut="?" + ) + self._interface_visible = True + self._help_canvas = self._renderer._window_get_help_canvas( + [ + ("?", "Display help window"), + ("i", "Toggle interface"), + ("h", "Show/hide head shape points"), + ("Esc", "Stop the running ICP fit"), + ], + [ + ("Left-click-and-drag", "Rotate the view"), + ("Middle-click-and-drag", "Pan the view"), + ("Right-click-and-drag / scroll", "Zoom the view"), + ("Left-click on the head", "Place the current MRI fiducial (unlocked)"), + ], + ) + + def _save_screenshot(self, filename): + self._renderer.screenshot(filename=filename) + + def _toggle_interface(self): + self._interface_visible = not self._interface_visible + with self._renderer._window_ensure_minimum_sizes(): + if self._interface_visible: + self._renderer._dock_show() + else: + self._renderer._dock_hide() + icon = "visibility_on" if self._interface_visible else "visibility_off" + self._renderer._tool_bar_update_button_icon(name="visibility", icon_name=icon) + + def _show_help(self): + self._help_canvas.show() def _clean(self): if not self._accept_close_event: diff --git a/mne/gui/tests/test_coreg.py b/mne/gui/tests/test_coreg.py index 16a59176878..57245b4731e 100644 --- a/mne/gui/tests/test_coreg.py +++ b/mne/gui/tests/test_coreg.py @@ -324,6 +324,37 @@ def test_coreg_gui_pyvista_basic(tmp_path, monkeypatch, renderer_interactive_pyv assert " Date: Tue, 13 Oct 2026 18:06:28 +0200 Subject: [PATCH 2/5] changelog --- doc/changes/dev/14412.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14412.newfeature.rst diff --git a/doc/changes/dev/14412.newfeature.rst b/doc/changes/dev/14412.newfeature.rst new file mode 100644 index 00000000000..ba0cd8fb40d --- /dev/null +++ b/doc/changes/dev/14412.newfeature.rst @@ -0,0 +1 @@ +Add a toolbar (screenshot, show/hide controls, help) with keyboard shortcuts to :func:`mne.gui.coregistration`, and show ICP fitting progress with a button (or :kbd:`Esc`) to stop the fit early, by `Payam Sadeghi-Shabestari`_. From 6c34b189fbf6328b873bd81ad6504a9ccf978ad7 Mon Sep 17 00:00:00 2001 From: payam Date: Thu, 22 Oct 2026 13:46:53 +0200 Subject: [PATCH 3/5] better progress bar --- mne/gui/_coreg.py | 31 ++++++++++++++++++++++--------- mne/viz/backends/_qt.py | 7 +++++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/mne/gui/_coreg.py b/mne/gui/_coreg.py index 2a1646bb411..6c8ca27dd71 100644 --- a/mne/gui/_coreg.py +++ b/mne/gui/_coreg.py @@ -1547,7 +1547,10 @@ def _fit_icp_real(self, *, update_head): updates = ["hsp", "hpi", "sensors", "head_fids", "helmet"] if update_head: updates.insert(0, "head") - self._widgets["status_progress"].set_range([0, 0]) + n_iterations = self._icp_n_iterations + # set_range takes one [min, max] value, not one per widget name + self._widgets["status_progress"].set_range([0, n_iterations]) + self._forward_widget_command("status_progress", "set_value", 0) self._forward_widget_command( "status_progress", "show", value=None, input_value=False ) @@ -1558,6 +1561,9 @@ def callback(iteration, n_iterations): f"Fitting ICP - iteration {iteration + 1} (max {n_iterations}), " "Esc to stop" ) + self._forward_widget_command( + "status_progress", "set_value", iteration + 1 + ) self._update_plot(updates) self._current_icp_iterations += 1 self._update_distance_estimation() @@ -1570,7 +1576,7 @@ def callback(iteration, n_iterations): stopped = False try: self.coreg.fit_icp( - n_iterations=self._icp_n_iterations, + n_iterations=n_iterations, lpa_weight=self._lpa_weight, nasion_weight=self._nasion_weight, rpa_weight=self._rpa_weight, @@ -1581,16 +1587,23 @@ def callback(iteration, n_iterations): stopped = True finally: self._forward_widget_command("stop_icp", "set_enabled", False) + duration = time.time() - start + n_done = self._current_icp_iterations + its = f"{n_done} iteration{'s' if n_done != 1 else ''}" + # the bar stays until the next fit: full when ICP is done (it usually + # converges before the maximum), or where it was when stopped + if stopped: + msg = f"Fitting ICP stopped after {its}" + else: self._forward_widget_command( - "status_progress", "hide", value=None, input_value=False + "status_progress", "set_value", n_iterations ) - end = time.time() + if n_done < n_iterations: + msg = f"Fitting ICP converged in {its}" + else: + msg = f"Fitting ICP reached the maximum of {its}" self._display_message() - self._display_message( - f"Fitting ICP {'stopped' if stopped else 'finished'} in " - f"{end - start:.2f} seconds and " - f"{self._current_icp_iterations} iterations." - ) + self._display_message(f"{msg} ({duration:.2f} s).") del self._current_icp_iterations def _task_save_subject(self): diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 67351031257..4b1b3b01225 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -1468,15 +1468,18 @@ def mousePressEvent(event): on_click() widget.mousePressEvent = mousePressEvent - self._layout_add_widget(self._status_bar.layout(), widget, stretch) + self._status_bar.addWidget(widget, stretch) return _QtWidget(widget) def _status_bar_add_progress_bar(self, stretch=0): widget = QProgressBar() + widget.setFixedWidth(150) policy = widget.sizePolicy() policy.setRetainSizeWhenHidden(True) widget.setSizePolicy(policy) - self._layout_add_widget(self._status_bar.layout(), widget, stretch) + # QStatusBar's own API keeps widgets in its row; adding them to its layout() + # stacked them below that row, where they got clipped or dropped + self._status_bar.addPermanentWidget(widget, stretch) return _QtWidget(widget) def _status_bar_update(self): From d5265bee0ae8d420ab00aaa556483988b6b978eb Mon Sep 17 00:00:00 2001 From: payam Date: Thu, 22 Oct 2026 13:48:01 +0200 Subject: [PATCH 4/5] clear omitted points --- mne/coreg.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/coreg.py b/mne/coreg.py index 8363fb5fea8..3867f63179b 100644 --- a/mne/coreg.py +++ b/mne/coreg.py @@ -1573,6 +1573,7 @@ def set_info(self, info): """ _validate_type(info, (Info, None), "info") self._info = info + self._extra_points_filter = None self._setup_digs() return self From 8b25e98e2ec7a38fe552e063805feb9c8de8999e Mon Sep 17 00:00:00 2001 From: payam Date: Thu, 22 Oct 2026 13:48:26 +0200 Subject: [PATCH 5/5] tests --- mne/gui/tests/test_coreg.py | 5 ++--- mne/tests/test_coreg.py | 9 +++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/mne/gui/tests/test_coreg.py b/mne/gui/tests/test_coreg.py index 57245b4731e..c6f5bc0d927 100644 --- a/mne/gui/tests/test_coreg.py +++ b/mne/gui/tests/test_coreg.py @@ -336,10 +336,9 @@ def _press_stop(): monkeypatch.setattr(coreg._renderer, "_process_events", _press_stop) coreg._fit_icp() monkeypatch.setattr(coreg._renderer, "_process_events", process_events) - assert "stopped" in coreg._widgets["status_message"].get_value() - assert "1 iterations" in coreg._widgets["status_message"].get_value() + assert "stopped after 1 iteration " in coreg._widgets["status_message"].get_value() assert not coreg._widgets["stop_icp"].is_enabled() - assert not coreg._widgets["status_progress"].is_visible() + assert coreg._widgets["status_progress"].get_value() == 1 # where it stopped # toolbar (interface toggle, help) and the head shape point checkbox coreg._save_screenshot(tmp_path / "coreg.png") assert (tmp_path / "coreg.png").is_file() diff --git a/mne/tests/test_coreg.py b/mne/tests/test_coreg.py index 6dbc5bd7ba3..f2820691352 100644 --- a/mne/tests/test_coreg.py +++ b/mne/tests/test_coreg.py @@ -531,6 +531,15 @@ def test_coreg_fitting_invariants(): assert (kept <= distance).all() assert len(kept) <= prev_n_kept prev_n_kept = len(kept) + # a new info with a different number of points resets the mask (gh-14420) + info_fewer = info.copy() + extra = [d["kind"] == FIFF.FIFFV_POINT_EXTRA for d in info["dig"]] + with info_fewer._unlock(): + info_fewer["dig"].pop(np.where(extra)[0][-1]) + coreg.set_info(info_fewer) + assert coreg._extra_points_filter is None + assert len(coreg._filtered_extra_points) == sum(extra) - 1 + coreg.set_info(info) # fit_fiducials / fit_icp for _ in range(5):