diff --git a/doc/changes/dev/14270.newfeature.rst b/doc/changes/dev/14270.newfeature.rst new file mode 100644 index 00000000000..a2bd18b68c5 --- /dev/null +++ b/doc/changes/dev/14270.newfeature.rst @@ -0,0 +1 @@ +The :class:`mne.viz.Brain` GUI now gives label-mode traces the same as vertex traces, and autoscales the traces plot's y-axis whenever a new trace is picked, by `Payam Sadeghi-Shabestari`_. diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index b296fc0a446..dd594766ae1 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -583,6 +583,7 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True): self._peak_vertices = {} self._auto_peak_points = set() self._trace_meta = {} + self._label_trace_meta = {} self._mouse_no_mvt = -1 self._show_hover_info = False self._hover_caption = None @@ -1659,6 +1660,7 @@ def _remove_label_glyph(self, hemi, label_id): # subsequent removal (and clear_glyphs at annotation changes) fail too self._picked_patches[hemi].remove(label_id) line, label._line = label._line, None + self._label_trace_meta.pop(line, None) if line is not None: try: line.remove() @@ -1789,16 +1791,42 @@ def _trace_display_label(self, line): The vertex auto-picked at peak activation for each hemisphere gets a "Peak (LH) 1000"-style name; other picked vertices get a compact "LH 1000"-style name instead of the full MNI-coordinate string (still - available as the row's tooltip). RMS curves are returned unchanged. + available as the row's tooltip). A picked parcellation label gets its + display name (e.g. "Superiortemporal (LH)") instead of the raw + internal name (still available as the tooltip). RMS curves are + returned unchanged. """ meta = self._trace_meta.get(line) - if meta is None: - return line.get_label() - hemi, vertex_id, _ = meta - hemi_names = {"lh": "LH", "rh": "RH", "vol": "Vol"} - if self._peak_vertices.get(hemi) == vertex_id: - return f"Peak ({hemi_names[hemi]}) {vertex_id}" - return f"{hemi_names[hemi]} {vertex_id}" + if meta is not None: + hemi, vertex_id, _ = meta + hemi_names = {"lh": "LH", "rh": "RH", "vol": "Vol"} + if self._peak_vertices.get(hemi) == vertex_id: + return f"Peak ({hemi_names[hemi]}) {vertex_id}" + return f"{hemi_names[hemi]} {vertex_id}" + label_meta = self._label_trace_meta.get(line) + if label_meta is not None: + hemi, label_name, _, _ = label_meta + hemi_names = {"lh": "LH", "rh": "RH"} + display_name = label_name + for suffix in ("-lh", "-rh", "_lh", "_rh"): + if display_name.endswith(suffix): + display_name = display_name[: -len(suffix)] + break + display_name = display_name.replace("_", " ").replace("-", " ").title() + return f"{display_name} ({hemi_names.get(hemi, hemi)})" + return line.get_label() + + def _trace_display_subtitle(self, line): + """Return an optional small subtitle line for a trace-list row.""" + meta = self._trace_meta.get(line) + if meta is not None: + mni_str = meta[2] + return f"MNI: {mni_str}" if mni_str else None + label_meta = self._label_trace_meta.get(line) + if label_meta is not None: + _, _, mode, n_vertices = label_meta + return f"{n_vertices} vertices, mode: {mode}" + return None def clear_glyphs(self): """Clear the picking glyphs.""" @@ -1879,6 +1907,8 @@ def plot_time_course(self, hemi, vertex_id, color, update=True): ) self._trace_meta[line] = (hemi, vertex_id, mni_str) if update: + self.mpl_canvas.axes.relim() + self.mpl_canvas.axes.autoscale_view() self.mpl_canvas.update_plot() return line @@ -2671,8 +2701,17 @@ def add_label( tc = np.linalg.norm(tc, axis=0) color = next(self.color_cycle) line = self.mpl_canvas.plot( - self._data["time"], tc, label=label_name, color=color + self._data["time"], tc, label=label_name, color=color, update=False + ) + self._label_trace_meta[line] = ( + hemi, + label_name, + self.label_extract_mode, + len(label.vertices), ) + self.mpl_canvas.axes.relim() + self.mpl_canvas.axes.autoscale_view() + self.mpl_canvas.update_plot() else: line = None diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 23673f9ad8e..a5ec6de667d 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -1540,6 +1540,12 @@ def row_text(row): assert str(vertex_id) in line.get_label() assert row_text(row) == f"LH {vertex_id}" + # the y-axis must autoscale to fit a newly-picked trace, not leave it + # clipped outside whatever range the previous traces happened to set + ymin, ymax = canvas.axes.get_ylim() + y = line.get_ydata() + assert y.min() >= ymin and y.max() <= ymax + # toggling a row hides the trace and its 3D glyph together, without # rebuilding the row list (sync() must skip unchanged trace sets -- # the whole point of the native list was to stop rebuilding on every @@ -1840,9 +1846,29 @@ def test_brain_click_picking_label(renderer_interactive_pyvistaqt, brain_gc, qtb assert len(brain._picked_patches["lh"]) == 0 QTest.mouseClick(widget, Qt.LeftButton, Qt.NoModifier, point) assert len(brain._picked_patches["lh"]) == 1 + + # the picked label's trace-list row gets a friendly display name/subtitle + # instead of the raw internal label name (still available as the tooltip) + label_id = brain._picked_patches["lh"][0] + label = brain._annotation_labels["lh"][label_id] + line = label._line + assert line in brain._label_trace_meta + display_label = brain._trace_display_label(line) + assert display_label != line.get_label() + assert display_label.endswith("(LH)") + subtitle = brain._trace_display_subtitle(line) + assert str(brain.label_extract_mode) in subtitle + assert str(len(label.vertices)) in subtitle + + # the y-axis must autoscale to fit the newly-picked label's trace + ymin, ymax = brain.mpl_canvas.axes.get_ylim() + y = line.get_ydata() + assert y.min() >= ymin and y.max() <= ymax + # clicking the same label again removes it QTest.mouseClick(widget, Qt.LeftButton, Qt.NoModifier, point) assert len(brain._picked_patches["lh"]) == 0 + assert line not in brain._label_trace_meta # the clear-glyphs shortcut clears a picked label QTest.mouseClick(widget, Qt.LeftButton, Qt.NoModifier, point) assert len(brain._picked_patches["lh"]) == 1 diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index da433f0053e..b0da86e81f4 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -1501,10 +1501,9 @@ def __init__(self, canvas, line): text.setWordWrap(True) text_col.addWidget(text) - meta = brain._trace_meta.get(line) if brain else None - coords = meta[2] if meta is not None else None - if coords: - coord_label = QLabel(f"MNI: {coords}") + subtitle = brain._trace_display_subtitle(line) if brain else None + if subtitle: + coord_label = QLabel(subtitle) coord_label.setStyleSheet( "color: palette(placeholder-text); font-size: 8pt;" )