Repository: mne-tools/mne-python

Issue:
Add option to import BrainVision files without marker types
BrainVision markers (stored in `.vmrk` files) consist of the following fields (the last field is optional):

```
Mk<number>=<Type>,<Description>,<Position>,<Size>,<Channel>,<Date>
```

For example:

```
Mk2=Comment,bad_segment,251,391,0
```

This is a marker as exported via `raw.export()` using `pybv` from an annotation with the description `bad_segment`. Since the `Type` field must be present, `pybv` uses `Comment` by default.

However, when importing the exported BrainVision file, annotations are created from markers, and the description will be pieced together as `<Type>/<Description>`, i.e. `Comment/bad_segment` in this case.

This makes the round-trip inconsistent, because I'd like to recover the original annotation descriptions from markers. This simply means that I'd need an option for `read_raw_brainvision()` to ignore the `Type` fields when constructing annotations. This would require a new parameter, e.g. `ignore_marker_types=False` (or something along these lines).

Would that be an addition that everyone is OK with (pinging @sappelhoff explicitly as one of the `pybv` maintainers)?

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d000d0a6c..5a83398ed 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,7 +1,7 @@
 repos:
   # Ruff mne
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.4.7
+    rev: v0.4.8
     hooks:
       - id: ruff
         name: ruff lint mne
diff --git a/doc/changes/devel/12652.newfeature.rst b/doc/changes/devel/12652.newfeature.rst
new file mode 100644
index 000000000..6273aa7b8
--- /dev/null
+++ b/doc/changes/devel/12652.newfeature.rst
@@ -0,0 +1,1 @@
+Add new parameter ``ignore_marker_types`` to :func:`~mne.io.read_raw_brainvision` to ignore marker types (and only use marker descriptions) when reading BrainVision files, by `Clemens Brunner`_.
\ No newline at end of file
diff --git a/doc/changes/devel/12655.newfeature.rst b/doc/changes/devel/12655.newfeature.rst
new file mode 100644
index 000000000..b2711d2f2
--- /dev/null
+++ b/doc/changes/devel/12655.newfeature.rst
@@ -0,0 +1,2 @@
+Added support for passing ``axes`` to :func:`mne.viz.plot_head_positions` when
+``mode='field'``, by `Eric Larson`_.
diff --git a/mne/annotations.py b/mne/annotations.py
index 2e3d01af6..81ee78b72 100644
--- a/mne/annotations.py
+++ b/mne/annotations.py
@@ -1143,7 +1143,7 @@ def _write_annotations_txt(fname, annot):
 
 @fill_doc
 def read_annotations(
-    fname, sfreq="auto", uint16_codec=None, encoding="utf8"
+    fname, sfreq="auto", uint16_codec=None, encoding="utf8", ignore_marker_types=False
 ) -> Annotations:
     r"""Read annotations from a file.
 
@@ -1174,6 +1174,9 @@ def read_annotations(
         arrays and can therefore help you solve this problem.
     %(encoding_edf)s
         Only used when reading EDF annotations.
+    ignore_marker_types : bool
+        If ``True``, ignore marker types in BrainVision files (and only use their
+        descriptions). Defaults to ``False``.
 
     Returns
     -------
@@ -1212,7 +1215,9 @@ def read_annotations(
         annotations = _read_annotations_txt(fname)
 
     elif name.endswith(("vmrk", "amrk")):
-        annotations = _read_annotations_brainvision(fname, sfreq=sfreq)
+        annotations = _read_annotations_brainvision(
+            fname, sfreq=sfreq, ignore_marker_types=ignore_marker_types
+        )
 
     elif name.endswith("csv"):
         annotations = _read_annotations_csv(fname)
diff --git a/mne/io/brainvision/brainvision.py b/mne/io/brainvision/brainvision.py
index 3a95f424f..a4b32d954 100644
--- a/mne/io/brainvision/brainvision.py
+++ b/mne/io/brainvision/brainvision.py
@@ -50,6 +50,11 @@ class RawBrainVision(BaseRaw):
     scale : float
         The scaling factor for EEG data. Unless specified otherwise by
         header file, units are in microvolts. Default scale factor is 1.
+    ignore_marker_types : bool
+        If ``True``, ignore marker types and only use marker descriptions. Default is
+        ``False``.
+
+        .. versionadded:: 1.8
     %(preload)s
     %(verbose)s
 
@@ -61,6 +66,15 @@ class RawBrainVision(BaseRaw):
     See Also
     --------
     mne.io.Raw : Documentation of attributes and methods.
+
+    Notes
+    -----
+    BrainVision markers consist of a type and a description (in addition to other fields
+    like onset and duration). In contrast, annotations in MNE only have a description.
+    Therefore, a BrainVision marker of type "Stimulus" and description "S  1" will be
+    converted to an annotation "Stimulus/S  1" by default. If you want to ignore the
+    type and instead only use the description, set ``ignore_marker_types=True``, which
+    will convert the same marker to an annotation "S  1".
     """
 
     _extra_attributes = ("impedances",)
@@ -72,6 +86,7 @@ class RawBrainVision(BaseRaw):
         eog=("HEOGL", "HEOGR", "VEOGb"),
         misc="auto",
         scale=1.0,
+        ignore_marker_types=False,
         preload=False,
         verbose=None,
     ):  # noqa: D107
@@ -129,7 +144,9 @@ class RawBrainVision(BaseRaw):
         self.impedances = _parse_impedance(split_settings, self.info["meas_date"])
 
         # Get annotations from marker file
-        annots = read_annotations(mrk_fname, info["sfreq"])
+        annots = read_annotations(
+            mrk_fname, info["sfreq"], ignore_marker_types=ignore_marker_types
+        )
         self.set_annotations(annots)
 
         # Drop the fake ahdr channel if needed
@@ -207,13 +224,15 @@ def _read_segments_c(raw, data, idx, fi, start, stop, cals, mult):
     _mult_cal_one(data, block, idx, cals, mult)
 
 
-def _read_mrk(fname):
+def _read_mrk(fname, ignore_marker_types=False):
     """Read annotations from a vmrk/amrk file.
 
     Parameters
     ----------
     fname : str
         vmrk/amrk file to be read.
+    ignore_marker_types : bool
+        If True, ignore marker types and only use marker descriptions. Default is False.
 
     Returns
     -------
@@ -293,36 +312,41 @@ def _read_mrk(fname):
         this_duration = int(this_duration) if this_duration.isdigit() else 0
         duration.append(this_duration)
         onset.append(int(this_onset) - 1)  # BV is 1-indexed, not 0-indexed
-        description.append(mtype + "/" + mdesc)
+        if not ignore_marker_types:
+            description.append(mtype + "/" + mdesc)
+        else:
+            description.append(mdesc)
 
     return np.array(onset), np.array(duration), np.array(description), date_str
 
 
-def _read_annotations_brainvision(fname, sfreq="auto"):
+def _read_annotations_brainvision(fname, sfreq="auto", ignore_marker_types=False):
     """Create Annotations from BrainVision vmrk/amrk.
 
-    This function reads a .vmrk or .amrk file and makes an
-    :class:`mne.Annotations` object.
+    This function reads a .vmrk or .amrk file and creates an :class:`mne.Annotations`
+    object.
 
     Parameters
     ----------
     fname : str | object
         The path to the .vmrk/.amrk file.
     sfreq : float | 'auto'
-        The sampling frequency in the file. It's necessary
-        as Annotations are expressed in seconds and vmrk/amrk
-        files are in samples. If set to 'auto' then
-        the sfreq is taken from the .vhdr/.ahdr file that
-        has the same name (without file extension). So
-        data.vmrk/amrk looks for sfreq in data.vhdr or,
-        if it does not exist, in data.ahdr.
+        The sampling frequency in the file. This is necessary because Annotations are
+        expressed in seconds and vmrk/amrk files are in samples. If set to 'auto' then
+        the sfreq is taken from the .vhdr/.ahdr file with the same name (without file
+        extension). So data.vmrk/amrk looks for sfreq in data.vhdr or, if it does not
+        exist, in data.ahdr.
+    ignore_marker_types : bool
+        If True, ignore marker types and only use marker descriptions. Default is False.
 
     Returns
     -------
     annotations : instance of Annotations
         The annotations present in the file.
     """
-    onset, duration, description, date_str = _read_mrk(fname)
+    onset, duration, description, date_str = _read_mrk(
+        fname, ignore_marker_types=ignore_marker_types
+    )
     orig_time = _str_to_meas_date(date_str)
 
     if sfreq == "auto":
@@ -919,6 +943,7 @@ def read_raw_brainvision(
     eog=("HEOGL", "HEOGR", "VEOGb"),
     misc="auto",
     scale=1.0,
+    ignore_marker_types=False,
     preload=False,
     verbose=None,
 ) -> RawBrainVision:
@@ -940,6 +965,9 @@ def read_raw_brainvision(
     scale : float
         The scaling factor for EEG data. Unless specified otherwise by
         header file, units are in microvolts. Default scale factor is 1.
+    ignore_marker_types : bool
+        If ``True``, ignore marker types and only use marker descriptions. Default is
+        ``False``.
     %(preload)s
     %(verbose)s
 
@@ -952,12 +980,22 @@ def read_raw_brainvision(
     See Also
     --------
     mne.io.Raw : Documentation of attributes and methods of RawBrainVision.
+
+    Notes
+    -----
+    BrainVision markers consist of a type and a description (in addition to other fields
+    like onset and duration). In contrast, annotations in MNE only have a description.
+    Therefore, a BrainVision marker of type "Stimulus" and description "S  1" will be
+    converted to an annotation "Stimulus/S  1" by default. If you want to ignore the
+    type and instead only use the description, set ``ignore_marker_types=True``, which
+    will convert the same marker to an annotation "S  1".
     """
     return RawBrainVision(
         vhdr_fname=vhdr_fname,
         eog=eog,
         misc=misc,
         scale=scale,
+        ignore_marker_types=ignore_marker_types,
         preload=preload,
         verbose=verbose,
     )
diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py
index d2097b219..0d389645f 100644
--- a/mne/viz/_3d.py
+++ b/mne/viz/_3d.py
@@ -130,6 +130,7 @@ def plot_head_positions(
     mode="traces",
     cmap="viridis",
     direction="z",
+    *,
     show=True,
     destination=None,
     info=None,
@@ -169,9 +170,11 @@ def plot_head_positions(
 
         .. versionadded:: 0.16
     axes : array-like, shape (3, 2)
-        The matplotlib axes to use. Only used for ``mode == 'traces'``.
+        The matplotlib axes to use.
 
         .. versionadded:: 0.16
+        .. versionchanged:: 1.8
+           Added support for making use of this argument when ``mode="field"``.
 
     Returns
     -------
@@ -193,7 +196,9 @@ def plot_head_positions(
 
     if not isinstance(pos, (list, tuple)):
         pos = [pos]
+    pos = list(pos)  # make our own mutable copy
     for ii, p in enumerate(pos):
+        _validate_type(p, np.ndarray, f"pos[{ii}]")
         p = np.array(p, float)
         if p.ndim != 2 or p.shape[1] != 10:
             raise ValueError(
@@ -315,9 +320,15 @@ def plot_head_positions(
         from mpl_toolkits.mplot3d import Axes3D  # noqa: F401, analysis:ignore
         from mpl_toolkits.mplot3d.art3d import Line3DCollection
 
-        fig, ax = plt.subplots(
-            1, subplot_kw=dict(projection="3d"), layout="constrained"
-        )
+        _validate_type(axes, (Axes3D, None), "ax", extra="when mode='field'")
+        if axes is None:
+            _, ax = plt.subplots(
+                1, subplot_kw=dict(projection="3d"), layout="constrained"
+            )
+        else:
+            ax = axes
+        fig = ax.get_figure()
+        del axes
 
         # First plot the trajectory as a colormap:
         # http://matplotlib.org/examples/pylab_examples/multicolored_line.html
diff --git a/tools/install_pre_requirements.sh b/tools/install_pre_requirements.sh
index 2f532b732..a5a11dba3 100755
--- a/tools/install_pre_requirements.sh
+++ b/tools/install_pre_requirements.sh
@@ -46,7 +46,7 @@ python -m pip install $STD_ARGS vtk
 python -c "import vtk"
 
 echo "PyVista"
-python -m pip install $STD_ARGS "git+https://github.com/adeak/pyvista.git@fix_numpy_2"  # pyvista/pyvista
+python -m pip install $STD_ARGS "git+https://github.com/pyvista/pyvista"
 
 echo "picard"
 python -m pip install $STD_ARGS git+https://github.com/pierreablin/picard


Test patch:
diff --git a/mne/io/brainvision/tests/test_brainvision.py b/mne/io/brainvision/tests/test_brainvision.py
index 51e63fa08..f580f05b5 100644
--- a/mne/io/brainvision/tests/test_brainvision.py
+++ b/mne/io/brainvision/tests/test_brainvision.py
@@ -661,6 +661,49 @@ def test_read_vmrk_annotations(tmp_path):
     read_annotations(fname, sfreq=sfreq)
 
 
+def test_ignore_marker_types():
+    """Test ignore marker types."""
+    # default behavior (do not ignore marker types)
+    raw = read_raw_brainvision(vhdr_path)
+    expected_descriptions = [
+        "New Segment/",
+        "Stimulus/S253",
+        "Stimulus/S255",
+        "Event/254",
+        "Stimulus/S255",
+        "Event/254",
+        "Stimulus/S255",
+        "Stimulus/S253",
+        "Stimulus/S255",
+        "Response/R255",
+        "Event/254",
+        "Stimulus/S255",
+        "SyncStatus/Sync On",
+        "Optic/O  1",
+    ]
+    assert_array_equal(raw.annotations.description, expected_descriptions)
+
+    # ignore marker types
+    raw = read_raw_brainvision(vhdr_path, ignore_marker_types=True)
+    expected_descriptions = [
+        "",
+        "S253",
+        "S255",
+        "254",
+        "S255",
+        "254",
+        "S255",
+        "S253",
+        "S255",
+        "R255",
+        "254",
+        "S255",
+        "Sync On",
+        "O  1",
+    ]
+    assert_array_equal(raw.annotations.description, expected_descriptions)
+
+
 @testing.requires_testing_data
 def test_read_vhdr_annotations_and_events(tmp_path):
     """Test load brainvision annotations and parse them to events."""
diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py
index 5109becb6..584e70432 100644
--- a/mne/viz/tests/test_3d.py
+++ b/mne/viz/tests/test_3d.py
@@ -104,13 +104,20 @@ def test_plot_head_positions():
     pos = np.random.RandomState(0).randn(4, 10)
     pos[:, 0] = np.arange(len(pos))
     destination = (0.0, 0.0, 0.04)
-    with _record_warnings():  # old MPL will cause a warning
-        plot_head_positions(pos)
-        plot_head_positions(pos, mode="field", info=info, destination=destination)
-        plot_head_positions([pos, pos])  # list support
-        pytest.raises(ValueError, plot_head_positions, ["pos"])
-        pytest.raises(ValueError, plot_head_positions, pos[:, :9])
-    pytest.raises(ValueError, plot_head_positions, pos, "foo")
+    plot_head_positions(pos)
+    plot_head_positions(pos, mode="field", info=info, destination=destination)
+    plot_head_positions([pos, pos])  # list support
+    fig, ax = plt.subplots()
+    with pytest.raises(TypeError, match="instance of Axes3D"):
+        plot_head_positions(pos, mode="field", info=info, axes=ax)
+    fig, ax = plt.subplots(subplot_kw=dict(projection="3d"))
+    plot_head_positions(pos, mode="field", info=info, axes=ax)
+    with pytest.raises(TypeError, match="must be an instance of ndarray"):
+        plot_head_positions(["foo"])
+    with pytest.raises(ValueError, match="must be dim"):
+        plot_head_positions(pos[:, :9])
+    with pytest.raises(ValueError, match="Allowed values"):
+        plot_head_positions(pos, "foo")
     with pytest.raises(ValueError, match="shape"):
         plot_head_positions(pos, axes=1.0)