Skip to content

Commit 7071a0e

Browse files
larsonerautofix-ci[bot]sappelhoff
authored
ENH: Add round-trip channel name saving (#13003)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Stefan Appelhoff <[email protected]>
1 parent a1a05ae commit 7071a0e

File tree

7 files changed

+101
-34
lines changed

7 files changed

+101
-34
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added support for saving and loading channel names from FIF in :meth:`mne.channels.DigMontage.save` and :meth:`mne.channels.read_dig_fif` and added the convention that they should be saved as ``-dig.fif``, by `Eric Larson`_.

mne/_fiff/_digitization.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .constants import FIFF, _coord_frame_named
1212
from .tag import read_tag
1313
from .tree import dir_tree_find
14-
from .write import start_and_end_file, write_dig_points
14+
from .write import _safe_name_list, start_and_end_file, write_dig_points
1515

1616
_dig_kind_dict = {
1717
"cardinal": FIFF.FIFFV_POINT_CARDINAL,
@@ -162,10 +162,11 @@ def __eq__(self, other): # noqa: D105
162162
return np.allclose(self["r"], other["r"])
163163

164164

165-
def _read_dig_fif(fid, meas_info):
165+
def _read_dig_fif(fid, meas_info, *, return_ch_names=False):
166166
"""Read digitizer data from a FIFF file."""
167167
isotrak = dir_tree_find(meas_info, FIFF.FIFFB_ISOTRAK)
168168
dig = None
169+
ch_names = None
169170
if len(isotrak) == 0:
170171
logger.info("Isotrak not found")
171172
elif len(isotrak) > 1:
@@ -183,13 +184,21 @@ def _read_dig_fif(fid, meas_info):
183184
elif kind == FIFF.FIFF_MNE_COORD_FRAME:
184185
tag = read_tag(fid, pos)
185186
coord_frame = _coord_frame_named.get(int(tag.data.item()))
187+
elif kind == FIFF.FIFF_MNE_CH_NAME_LIST:
188+
tag = read_tag(fid, pos)
189+
ch_names = _safe_name_list(tag.data, "read", "ch_names")
186190
for d in dig:
187191
d["coord_frame"] = coord_frame
188-
return _format_dig_points(dig)
192+
out = _format_dig_points(dig)
193+
if return_ch_names:
194+
out = (out, ch_names)
195+
return out
189196

190197

191198
@verbose
192-
def write_dig(fname, pts, coord_frame=None, *, overwrite=False, verbose=None):
199+
def write_dig(
200+
fname, pts, coord_frame=None, *, ch_names=None, overwrite=False, verbose=None
201+
):
193202
"""Write digitization data to a FIF file.
194203
195204
Parameters
@@ -203,6 +212,10 @@ def write_dig(fname, pts, coord_frame=None, *, overwrite=False, verbose=None):
203212
If all the points have the same coordinate frame, specify the type
204213
here. Can be None (default) if the points could have varying
205214
coordinate frames.
215+
ch_names : list of str | None
216+
Channel names associated with the digitization points, if available.
217+
218+
.. versionadded:: 1.9
206219
%(overwrite)s
207220
208221
.. versionadded:: 1.0
@@ -222,9 +235,15 @@ def write_dig(fname, pts, coord_frame=None, *, overwrite=False, verbose=None):
222235
"Points have coord_frame entries that are incompatible with "
223236
f"coord_frame={coord_frame}: {tuple(bad_frames)}."
224237
)
238+
_validate_type(ch_names, (None, list, tuple), "ch_names")
239+
if ch_names is not None:
240+
for ci, ch_name in enumerate(ch_names):
241+
_validate_type(ch_name, str, f"ch_names[{ci}]")
225242

226243
with start_and_end_file(fname) as fid:
227-
write_dig_points(fid, pts, block=True, coord_frame=coord_frame)
244+
write_dig_points(
245+
fid, pts, block=True, coord_frame=coord_frame, ch_names=ch_names
246+
)
228247

229248

230249
_cardinal_ident_mapping = {

mne/_fiff/write.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ def write_ch_info(fid, ch):
389389
fid.write(b"\0" * (16 - len(ch_name)))
390390

391391

392-
def write_dig_points(fid, dig, block=False, coord_frame=None):
392+
def write_dig_points(fid, dig, block=False, coord_frame=None, *, ch_names=None):
393393
"""Write a set of digitizer data points into a fif file."""
394394
if dig is not None:
395395
data_size = 5 * 4
@@ -406,6 +406,10 @@ def write_dig_points(fid, dig, block=False, coord_frame=None):
406406
fid.write(np.array(d["kind"], ">i4").tobytes())
407407
fid.write(np.array(d["ident"], ">i4").tobytes())
408408
fid.write(np.array(d["r"][:3], ">f4").tobytes())
409+
if ch_names is not None:
410+
write_name_list_sanitized(
411+
fid, FIFF.FIFF_MNE_CH_NAME_LIST, ch_names, "ch_names"
412+
)
409413
if block:
410414
end_block(fid, FIFF.FIFFB_ISOTRAK)
411415

mne/channels/montage.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
_on_missing,
4747
_pl,
4848
_validate_type,
49+
check_fname,
4950
copy_function_doc_to_method_doc,
5051
fill_doc,
5152
verbose,
@@ -409,9 +410,22 @@ def save(self, fname, *, overwrite=False, verbose=None):
409410
The filename to use. Should end in .fif or .fif.gz.
410411
%(overwrite)s
411412
%(verbose)s
413+
414+
See Also
415+
--------
416+
mne.channels.read_dig_fif
417+
418+
Notes
419+
-----
420+
.. versionchanged:: 1.9
421+
Added support for saving the associated channel names.
412422
"""
423+
fname = _check_fname(fname, overwrite=overwrite)
424+
check_fname(fname, "montage", ("-dig.fif", "-dig.fif.gz"))
413425
coord_frame = _check_get_coord_frame(self.dig)
414-
write_dig(fname, self.dig, coord_frame, overwrite=overwrite)
426+
write_dig(
427+
fname, self.dig, coord_frame, overwrite=overwrite, ch_names=self.ch_names
428+
)
415429

416430
def __iadd__(self, other):
417431
"""Add two DigMontages in place.
@@ -808,17 +822,15 @@ def read_dig_dat(fname):
808822
return make_dig_montage(electrodes, nasion, lpa, rpa)
809823

810824

811-
def read_dig_fif(fname):
825+
@verbose
826+
def read_dig_fif(fname, *, verbose=None):
812827
r"""Read digitized points from a .fif file.
813828
814-
Note that electrode names are not present in the .fif file so
815-
they are here defined with the convention from VectorView
816-
systems (EEG001, EEG002, etc.)
817-
818829
Parameters
819830
----------
820831
fname : path-like
821832
FIF file from which to read digitization locations.
833+
%(verbose)s
822834
823835
Returns
824836
-------
@@ -835,17 +847,28 @@ def read_dig_fif(fname):
835847
read_dig_hpts
836848
read_dig_localite
837849
make_dig_montage
850+
851+
Notes
852+
-----
853+
.. versionchanged:: 1.9
854+
Added support for reading the associated channel names, if present.
855+
856+
In some files, electrode names are not present (e.g., in older files).
857+
For those files, the channel names are defined with the convention from
858+
VectorView systems (EEG001, EEG002, etc.).
838859
"""
839-
fname = _check_fname(fname, overwrite="read", must_exist=True)
860+
check_fname(fname, "montage", ("-dig.fif", "-dig.fif.gz"))
861+
fname = _check_fname(fname=fname, must_exist=True, overwrite="read")
840862
# Load the dig data
841863
f, tree = fiff_open(fname)[:2]
842864
with f as fid:
843-
dig = _read_dig_fif(fid, tree)
865+
dig, ch_names = _read_dig_fif(fid, tree, return_ch_names=True)
844866

845-
ch_names = []
846-
for d in dig:
847-
if d["kind"] == FIFF.FIFFV_POINT_EEG:
848-
ch_names.append(f"EEG{d['ident']:03d}")
867+
if ch_names is None: # backward compat from when we didn't save the names
868+
ch_names = []
869+
for d in dig:
870+
if d["kind"] == FIFF.FIFFV_POINT_EEG:
871+
ch_names.append(f"EEG{d['ident']:03d}")
849872

850873
montage = DigMontage(dig=dig, ch_names=ch_names)
851874
return montage
@@ -1572,6 +1595,7 @@ def read_custom_montage(
15721595
--------
15731596
make_dig_montage
15741597
make_standard_montage
1598+
read_dig_fif
15751599
15761600
Notes
15771601
-----

mne/channels/tests/test_montage.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
assert_equal,
2020
)
2121

22+
import mne.channels.montage
2223
from mne import (
2324
__file__ as _mne_file,
2425
)
@@ -56,6 +57,7 @@
5657
_BUILTIN_STANDARD_MONTAGES,
5758
_check_get_coord_frame,
5859
transform_to_head,
60+
write_dig,
5961
)
6062
from mne.coreg import get_mni_fiducials
6163
from mne.datasets import testing
@@ -138,7 +140,8 @@ def test_dig_montage_trans(tmp_path):
138140
_ensure_trans(trans)
139141
# ensure that we can save and load it, too
140142
fname = tmp_path / "temp-mon.fif"
141-
_check_roundtrip(montage, fname, "mri")
143+
with pytest.warns(RuntimeWarning, match="MNE naming conventions"):
144+
_check_roundtrip(montage, fname, "mri")
142145
# test applying a trans
143146
position1 = montage.get_positions()
144147
montage.apply_trans(trans)
@@ -1074,12 +1077,12 @@ def _ensure_fid_not_nan(info, ch_pos):
10741077

10751078

10761079
@testing.requires_testing_data
1077-
def test_fif_dig_montage(tmp_path):
1080+
def test_fif_dig_montage(tmp_path, monkeypatch):
10781081
"""Test FIF dig montage support."""
1079-
dig_montage = read_dig_fif(fif_dig_montage_fname)
1082+
dig_montage = read_dig_fif(fif_dig_montage_fname, verbose="error")
10801083

10811084
# test round-trip IO
1082-
fname_temp = tmp_path / "test.fif"
1085+
fname_temp = tmp_path / "test-dig.fif"
10831086
_check_roundtrip(dig_montage, fname_temp)
10841087

10851088
# Make a BrainVision file like the one the user would have had
@@ -1119,16 +1122,32 @@ def test_fif_dig_montage(tmp_path):
11191122
# Roundtrip of non-FIF start
11201123
montage = make_dig_montage(hsp=read_polhemus_fastscan(hsp), hpi=read_mrk(hpi))
11211124
elp_points = read_polhemus_fastscan(elp)
1122-
ch_pos = {f"EEG{k:03d}": pos for k, pos in enumerate(elp_points[8:], 1)}
1123-
montage += make_dig_montage(
1125+
ch_pos = {f"ECoG{k:03d}": pos for k, pos in enumerate(elp_points[3:], 1)}
1126+
assert len(elp_points) == 8 # there are only 8 but pretend the last are ECoG
1127+
other = make_dig_montage(
11241128
nasion=elp_points[0], lpa=elp_points[1], rpa=elp_points[2], ch_pos=ch_pos
11251129
)
1130+
assert other.ch_names[0].startswith("ECoG")
1131+
montage += other
1132+
assert montage.ch_names[0].startswith("ECoG")
11261133
_check_roundtrip(montage, fname_temp, "unknown")
11271134
montage = transform_to_head(montage)
11281135
_check_roundtrip(montage, fname_temp)
11291136
montage.dig[0]["coord_frame"] = FIFF.FIFFV_COORD_UNKNOWN
11301137
with pytest.raises(RuntimeError, match="Only a single coordinate"):
1131-
montage.save(fname_temp)
1138+
montage.save(fname_temp, overwrite=True)
1139+
montage.dig[0]["coord_frame"] = FIFF.FIFFV_COORD_HEAD
1140+
1141+
# Check that old-style files can be read, too, using EEG001 etc.
1142+
def write_dig_no_ch_names(*args, **kwargs):
1143+
kwargs["ch_names"] = None
1144+
return write_dig(*args, **kwargs)
1145+
1146+
monkeypatch.setattr(mne.channels.montage, "write_dig", write_dig_no_ch_names)
1147+
montage.save(fname_temp, overwrite=True)
1148+
montage_read = read_dig_fif(fname_temp)
1149+
default_ch_names = [f"EEG{ii:03d}" for ii in range(1, 6)]
1150+
assert montage_read.ch_names == default_ch_names
11321151

11331152

11341153
@testing.requires_testing_data
@@ -1175,8 +1194,8 @@ def test_egi_dig_montage(tmp_path):
11751194
atol=1e-4,
11761195
)
11771196

1178-
# test round-trip IO
1179-
fname_temp = tmp_path / "egi_test.fif"
1197+
# test round-trip IO (with GZ)
1198+
fname_temp = tmp_path / "egi_test-dig.fif.gz"
11801199
_check_roundtrip(dig_montage, fname_temp, "unknown")
11811200
_check_roundtrip(dig_montage_in_head, fname_temp)
11821201

@@ -1330,7 +1349,7 @@ def test_read_dig_captrak(tmp_path):
13301349
)
13311350

13321351
montage = transform_to_head(montage) # transform_to_head has to be tested
1333-
_check_roundtrip(montage=montage, fname=str(tmp_path / "bvct_test.fif"))
1352+
_check_roundtrip(montage=montage, fname=tmp_path / "bvct_test-dig.fif")
13341353

13351354
fid, _ = _get_fid_coords(montage.dig)
13361355
assert_allclose(
@@ -1495,15 +1514,15 @@ def test_montage_positions_similar(fname, montage, n_eeg, n_good, bads):
14951514
assert_array_less(0, ang) # but not equal
14961515

14971516

1498-
# XXX: this does not check ch_names + it cannot work because of write_dig
14991517
def _check_roundtrip(montage, fname, coord_frame="head"):
15001518
"""Check roundtrip writing."""
15011519
montage.save(fname, overwrite=True)
15021520
montage_read = read_dig_fif(fname=fname)
15031521

1504-
assert_equal(repr(montage), repr(montage_read))
1505-
assert_equal(_check_get_coord_frame(montage_read.dig), coord_frame)
1522+
assert repr(montage) == repr(montage_read)
1523+
assert _check_get_coord_frame(montage_read.dig) == coord_frame
15061524
assert_dig_allclose(montage, montage_read)
1525+
assert montage.ch_names == montage_read.ch_names
15071526

15081527

15091528
def test_digmontage_constructor_errors():
@@ -1910,7 +1929,7 @@ def test_get_montage():
19101929

19111930
# 4. read in BV test dataset and make sure montage
19121931
# fulfills roundtrip on non-standard montage
1913-
dig_montage = read_dig_fif(fif_dig_montage_fname)
1932+
dig_montage = read_dig_fif(fif_dig_montage_fname, verbose="error")
19141933

19151934
# Make a BrainVision file like the one the user would have had
19161935
# with testing dataset 'test.vhdr'

mne/viz/tests/test_montage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def test_plot_montage():
4848
assert "0 channels" in repr(montage)
4949
with pytest.raises(RuntimeError, match="No valid channel positions"):
5050
montage.plot()
51-
d = read_dig_fif(fname=fif_fname)
51+
d = read_dig_fif(fname=fif_fname, verbose="error")
5252
assert "61 channels" in repr(d)
5353
# XXX this is broken; dm.point_names is used. Sometimes we say this should
5454
# Just contain the HPI coils, other times that it's all channels (e.g.,

tutorials/forward/35_eeg_no_mri.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@
105105
# in the MRI coordinate frame, which can be used to compute the
106106
# MRI<->head transform ``trans``:
107107
fname_1020 = subjects_dir / subject / "montages" / "10-20-montage.fif"
108-
mon = mne.channels.read_dig_fif(fname_1020)
108+
mon = mne.channels.read_dig_fif(fname_1020, verbose="error") # should be named -dig.fif
109109
mon.rename_channels({f"EEG{ii:03d}": ch_name for ii, ch_name in enumerate(ch_names, 1)})
110110
trans = mne.channels.compute_native_head_t(mon)
111111
raw.set_montage(mon)

0 commit comments

Comments
 (0)