Skip to content

Commit 6b7c6f4

Browse files
larsonerwmvanvliet
andauthored
Include cross-talk and fine-cal when available (#13942)
Co-authored-by: Marijn van Vliet <w.m.vanvliet@gmail.com>
1 parent bc9419a commit 6b7c6f4

7 files changed

Lines changed: 165 additions & 57 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for automatic ``cross_talk`` and ``fine_calibration`` adjustments in :func:`mne.preprocessing.maxwell_filter` when these are stored in :class:`mne.Info` during acquisition (e.g., by newer MEGIN acquisition software), by `Eric Larson`_.

mne/preprocessing/maxwell.py

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Copyright the MNE-Python contributors.
44

55
from collections import Counter
6+
from copy import deepcopy
67
from functools import partial
78
from math import factorial
89
from os import path as op
@@ -498,6 +499,30 @@ def _prep_maxwell_filter(
498499
add_channels = (head_pos is not None) and (not st_only)
499500
head_pos = _check_pos(head_pos, coord_frame, raw, st_fixed)
500501
mc = _MoveComp(head_pos, coord_frame, raw, mc_interp, reconstruct)
502+
503+
# cross_talk=None (or True) means "use built-in ones if in info"
504+
_validate_type(cross_talk, (None, bool, dict, "path-like"))
505+
_validate_type(calibration, (None, bool, dict, "path-like"))
506+
if cross_talk is None:
507+
cross_talk = raw.info.get("cross_talk")
508+
elif cross_talk is True:
509+
if "cross_talk" not in raw.info:
510+
raise RuntimeError(f"{cross_talk=}, but info['cross_talk'] is None.")
511+
cross_talk = raw.info["cross_talk"]
512+
elif cross_talk is False:
513+
cross_talk = None
514+
# otherwise, it's path-like
515+
516+
if calibration is None:
517+
calibration = raw.info.get("fine_calibration")
518+
elif calibration is True:
519+
if "fine_calibration" not in raw.info:
520+
raise RuntimeError(f"{calibration=}, but info['fine_calibration'] is None.")
521+
calibration = raw.info["fine_calibration"]
522+
elif calibration is False:
523+
calibration = None
524+
# otherwise, it's path-like
525+
501526
_check_info(
502527
raw.info,
503528
sss=not st_only,
@@ -2174,6 +2199,31 @@ def _overlap_projector(data_int, data_res, corr):
21742199
return V_principal
21752200

21762201

2202+
def _reformat_fine_cal_dict(fine_cal):
2203+
# reformat a possible dict with "cal_chans", "cal_corrs" keys to more standard one
2204+
# with "ch_names", "locs", "imb_cals" keys
2205+
if "cal_chans" in fine_cal:
2206+
# Someday we might need to refactor this for other systems, but we should do
2207+
# that when we start building in fine cal to them during acq (probably never)
2208+
_GRAD_TYPES = tuple(
2209+
getattr(FIFF, f"FIFFV_COIL_VV_PLANAR_{t}")
2210+
for t in ("T1", "T2", "T3", "T4", "W")
2211+
)
2212+
ch_names = [f"MEG{ch_num:04d}" for ch_num in fine_cal["cal_chans"][:, 0]]
2213+
locs = fine_cal["cal_corrs"][:, -12:].astype(float)
2214+
coil_types = fine_cal["cal_chans"][:, 1]
2215+
is_grad = np.isin(coil_types, _GRAD_TYPES)
2216+
starts = np.where(is_grad, 1, 0)
2217+
stops = np.where(is_grad, fine_cal["cal_corrs"].shape[1] - 12, 1)
2218+
imb_cals = list(
2219+
fine_cal["cal_corrs"][ii, start:stop].astype(float)
2220+
for ii, (start, stop) in enumerate(zip(starts, stops))
2221+
)
2222+
return dict(ch_names=ch_names, locs=locs, imb_cals=imb_cals)
2223+
else:
2224+
return deepcopy(fine_cal)
2225+
2226+
21772227
def _prep_fine_cal(info, fine_cal, *, ignore_ref):
21782228
from ._fine_cal import read_fine_calibration
21792229

@@ -2183,6 +2233,7 @@ def _prep_fine_cal(info, fine_cal, *, ignore_ref):
21832233
fine_cal = read_fine_calibration(fine_cal)
21842234
else:
21852235
extra = "dict"
2236+
fine_cal = _reformat_fine_cal_dict(fine_cal)
21862237
logger.info(f" Using fine calibration {extra}")
21872238
ch_names = _clean_names(info["ch_names"], remove_whitespace=True)
21882239
info_to_cal = dict()
@@ -2230,12 +2281,12 @@ def _update_sensor_geometry(info, fine_cal, ignore_ref):
22302281
)
22312282

22322283
# Replace sensor locations (and track differences) for fine calibration
2233-
ang_shift = list()
22342284
used = np.zeros(len(info["chs"]), bool)
22352285
cal_corrs = list()
22362286
cal_chans = list()
22372287
adjust_logged = False
2238-
for oi, ci in info_to_cal.items():
2288+
ang_shift = np.zeros(len(info_to_cal))
2289+
for ii, (oi, ci) in enumerate(info_to_cal.items()):
22392290
assert not used[oi]
22402291
used[oi] = True
22412292
info_ch = info["chs"][oi]
@@ -2250,50 +2301,47 @@ def _update_sensor_geometry(info, fine_cal, ignore_ref):
22502301
# EX and EY are orthogonal to EZ. If not, we find the rotation between
22512302
# the original and fine-cal ez, and rotate EX and EY accordingly:
22522303
ch_coil_rot = _loc_to_coil_trans(info_ch["loc"])[:3, :3]
2304+
_normalize_vectors(ch_coil_rot.T) # column-wise
22532305
cal_loc = fine_cal["locs"][ci].copy()
22542306
cal_coil_rot = _loc_to_coil_trans(cal_loc)[:3, :3]
2255-
if (
2256-
np.max(
2257-
[
2258-
np.abs(np.dot(cal_coil_rot[:, ii], cal_coil_rot[:, 2]))
2259-
for ii in range(2)
2260-
]
2261-
)
2262-
> 1e-6
2263-
): # X or Y not orthogonal
2307+
_normalize_vectors(cal_coil_rot.T)
2308+
# X or Y not orthogonal to Z:
2309+
if np.max(np.abs(cal_coil_rot[:, 2] @ cal_coil_rot[:, :2])) > 1e-5:
22642310
if not adjust_logged:
22652311
logger.info(" Adjusting non-orthogonal EX and EY")
22662312
adjust_logged = True
22672313
# find the rotation matrix that goes from one to the other
2268-
this_trans = _find_vector_rotation(ch_coil_rot[:, 2], cal_coil_rot[:, 2])
2269-
cal_loc[3:] = np.dot(this_trans, ch_coil_rot).T.ravel()
2314+
R = _find_vector_rotation(ch_coil_rot[:, 2], cal_coil_rot[:, 2])
2315+
cal_coil_rot[:] = R @ ch_coil_rot
2316+
_normalize_vectors(cal_coil_rot.T)
2317+
cal_loc[3:9] = cal_coil_rot.T.ravel()[:6] # set just X and Y in output
22702318

22712319
# calculate shift angle
2272-
v1 = _loc_to_coil_trans(cal_loc)[:3, :3]
2273-
_normalize_vectors(v1)
2274-
v2 = _loc_to_coil_trans(info_ch["loc"])[:3, :3]
2275-
_normalize_vectors(v2)
2276-
ang_shift.append(np.sum(v1 * v2, axis=0))
2320+
v2 = _loc_to_coil_trans(info_ch["loc"])[:3, 2:]
2321+
_normalize_vectors(v2.T)
2322+
ang_shift[ii] = cal_coil_rot[:3, 2] @ v2[:, 0]
22772323
if oi in grad_picks:
22782324
extra = [1.0, fine_cal["imb_cals"][ci][0]]
22792325
else:
22802326
extra = [fine_cal["imb_cals"][ci][0], 0.0]
22812327
cal_corrs.append(np.concatenate([extra, cal_loc]))
22822328
# Adjust channel normal orientations with those from fine calibration
22832329
# Channel positions are not changed
2284-
info_ch["loc"][3:] = cal_loc[3:]
2330+
info_ch["loc"][3:] = cal_coil_rot.T.ravel()
22852331
assert info_ch["coord_frame"] == FIFF.FIFFV_COORD_DEVICE
22862332
meg_picks = pick_types(info, meg=True, exclude=(), ref_meg=not ignore_ref)
22872333
assert used[meg_picks].all()
22882334
assert not used[np.setdiff1d(np.arange(len(used)), meg_picks)].any()
22892335
# This gets written to the Info struct
2290-
sss_cal = dict(cal_corrs=np.array(cal_corrs), cal_chans=np.array(cal_chans))
2336+
sss_cal = dict(
2337+
cal_corrs=np.array(cal_corrs, float),
2338+
cal_chans=np.array(cal_chans, int),
2339+
)
22912340

22922341
# Log quantification of sensor changes
22932342
# Deal with numerical precision giving absolute vals slightly more than 1.
2294-
ang_shift = np.array(ang_shift)
2295-
np.clip(ang_shift, -1.0, 1.0, ang_shift)
2296-
np.rad2deg(np.arccos(ang_shift), ang_shift) # Convert to degrees
2343+
np.clip(ang_shift, -1.0, 1.0, out=ang_shift)
2344+
np.rad2deg(np.arccos(ang_shift), out=ang_shift) # Convert to degrees
22972345
logger.info(
22982346
" Adjusted coil orientations by (μ ± σ): "
22992347
f"{np.mean(ang_shift):0.1f}° ± {np.std(ang_shift):0.1f}° "
@@ -2899,8 +2947,13 @@ def find_bad_channels_maxwell(
28992947
def _read_cross_talk(cross_talk, ch_names):
29002948
sss_ctc = dict()
29012949
ctc = None
2902-
if cross_talk is not None:
2903-
sss_ctc = _read_ctc(cross_talk)
2950+
if cross_talk:
2951+
if not isinstance(cross_talk, dict):
2952+
sss_ctc = _read_ctc(cross_talk)
2953+
else:
2954+
sss_ctc = deepcopy(cross_talk)
2955+
# the way it is on disk
2956+
sss_ctc["decoupler"] = sss_ctc["decoupler"].T.tocsc()
29042957
ctc_chs = sss_ctc["ch_names"]
29052958
# checking for extra space ambiguity in channel names
29062959
# between old and new fif files

mne/preprocessing/tests/test_maxwell.py

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# License: BSD-3-Clause
33
# Copyright the MNE-Python contributors.
44

5-
import pathlib
65
import re
76
from contextlib import contextmanager
87
from functools import partial
@@ -35,6 +34,7 @@
3534
compute_maxwell_basis,
3635
find_bad_channels_maxwell,
3736
maxwell_filter_prepare_emptyroom,
37+
read_fine_calibration,
3838
)
3939
from mne.preprocessing import (
4040
maxwell_filter as _maxwell_filter_ola,
@@ -588,7 +588,12 @@ def test_basic():
588588
assert len(raw.info["projs"]) == 12 # 11 MEG projs + 1 AVG EEG
589589
with use_coil_def(elekta_def_fname):
590590
raw_sss = maxwell_filter(
591-
raw, origin=mf_head_origin, regularize=None, bad_condition="ignore"
591+
raw,
592+
origin=mf_head_origin,
593+
regularize=None,
594+
bad_condition="ignore",
595+
calibration=False, # just for completeness
596+
cross_talk=False,
592597
)
593598
assert len(raw_sss.info["projs"]) == 1 # avg EEG
594599
assert raw_sss.info["projs"][0]["desc"] == "Average EEG reference"
@@ -848,28 +853,49 @@ def test_fine_calibration():
848853
sss_fine_cal = read_crop(sss_fine_cal_fname)
849854

850855
# Test 1D SSS fine calibration
851-
with use_coil_def(elekta_def_fname):
852-
with catch_logging() as log:
853-
raw_sss = maxwell_filter(
854-
raw,
855-
calibration=fine_cal_fname,
856-
origin=mf_head_origin,
857-
regularize=None,
858-
bad_condition="ignore",
859-
verbose=True,
860-
)
856+
kwargs = dict(
857+
origin=mf_head_origin,
858+
regularize=None,
859+
bad_condition="ignore",
860+
verbose=True,
861+
)
862+
with use_coil_def(elekta_def_fname), catch_logging() as log:
863+
raw_sss = maxwell_filter(raw, calibration=fine_cal_fname, **kwargs)
861864
log = log.getvalue()
862865
assert "Using fine calibration" in log
866+
adj_msg = "Adjusting non-orthogonal EX and EY"
867+
assert adj_msg in log
868+
assert "(max: 1.4" in log
863869
assert fine_cal_fname.stem in log
864870
assert_meg_snr(raw_sss, sss_fine_cal, 1.3, 180) # similar to MaxFilter
865871
py_cal = raw_sss.info["proc_history"][0]["max_info"]["sss_cal"]
866-
assert py_cal is not None
867-
assert len(py_cal) > 0
872+
assert isinstance(py_cal, dict) and len(py_cal)
868873
mf_cal = sss_fine_cal.info["proc_history"][0]["max_info"]["sss_cal"]
874+
assert isinstance(mf_cal, dict) and len(mf_cal)
869875
# we identify these differently
870876
mf_cal["cal_chans"][mf_cal["cal_chans"][:, 1] == 3022, 1] = 3024
877+
fc = read_fine_calibration(fine_cal_fname)
878+
assert_allclose(fc["locs"][:, :3], mf_cal["cal_corrs"][:, -12:-9]) # pos
879+
assert_allclose(fc["locs"][:, :3], py_cal["cal_corrs"][:, -12:-9])
880+
assert_allclose(mf_cal["cal_corrs"][:, -3:], fc["locs"][:, -3:]) # ez
881+
assert_allclose(py_cal["cal_corrs"][:, -3:], fc["locs"][:, -3:])
882+
assert_allclose( # ex, ey get updated by rotation, which doesn't exactly match
883+
py_cal["cal_corrs"][:, -9:-3],
884+
mf_cal["cal_corrs"][:, -9:-3],
885+
atol=1e-3,
886+
)
871887
assert_allclose(py_cal["cal_chans"], mf_cal["cal_chans"])
872-
assert_allclose(py_cal["cal_corrs"], mf_cal["cal_corrs"], rtol=1e-3, atol=1e-3)
888+
with pytest.raises(RuntimeError, match=r"info\['fine_calibration'\] is None"):
889+
maxwell_filter(raw, calibration=True, **kwargs)
890+
with raw.info._unlock():
891+
raw.info["fine_calibration"] = py_cal
892+
with use_coil_def(elekta_def_fname), catch_logging() as log:
893+
raw_sss_builtin = maxwell_filter(raw, **kwargs)
894+
log = log.getvalue()
895+
assert adj_msg not in log
896+
assert_allclose(raw_sss_builtin[:][0], raw_sss[:][0])
897+
with raw.info._unlock():
898+
del raw.info["fine_calibration"]
873899
# with missing channels
874900
raw_missing = raw.copy().load_data()
875901
raw_missing.info["bads"] = ["MEG0111", "MEG0943"] # 1 mag, 1 grad
@@ -958,17 +984,28 @@ def test_cross_talk(tmp_path):
958984
raw = read_crop(raw_fname, (0.0, 1.0))
959985
raw.info["bads"] = bads
960986
sss_ctc = read_crop(sss_ctc_fname)
987+
kwargs = dict(
988+
origin=mf_head_origin,
989+
regularize=None,
990+
bad_condition="ignore",
991+
)
961992
with use_coil_def(elekta_def_fname):
962-
raw_sss = maxwell_filter(
963-
raw,
964-
cross_talk=pathlib.Path(ctc_fname),
965-
origin=mf_head_origin,
966-
regularize=None,
967-
bad_condition="ignore",
968-
)
993+
raw_sss = maxwell_filter(raw, cross_talk=ctc_fname, **kwargs)
969994
assert_meg_snr(raw_sss, sss_ctc, 275.0)
970995
py_ctc = raw_sss.info["proc_history"][0]["max_info"]["sss_ctc"]
971996
assert len(py_ctc) > 0
997+
998+
# using built-in cross-talk
999+
with pytest.raises(RuntimeError, match=r"info\['cross_talk'\] is None"):
1000+
maxwell_filter(raw, cross_talk=True, **kwargs)
1001+
with raw.info._unlock():
1002+
raw.info["cross_talk"] = py_ctc
1003+
with use_coil_def(elekta_def_fname):
1004+
raw_sss_builtin = maxwell_filter(raw, **kwargs)
1005+
assert len(raw_sss_builtin.info["proc_history"][0]["max_info"]["sss_ctc"]) > 0
1006+
assert_allclose(raw_sss[:][0], raw_sss_builtin[:][0])
1007+
del raw_sss_builtin, raw_sss
1008+
9721009
with pytest.raises(TypeError, match="path-like"):
9731010
maxwell_filter(raw, cross_talk=raw)
9741011
with pytest.raises(ValueError, match="Invalid cross-talk FIF"):
@@ -1236,7 +1273,7 @@ def test_shielding_factor(tmp_path):
12361273
coord_frame="meg",
12371274
regularize=None,
12381275
origin=mf_meg_origin,
1239-
calibration=pathlib.Path(fine_cal_fname),
1276+
calibration=fine_cal_fname,
12401277
)
12411278
_assert_shielding(raw_sss, erm_power, 12, 13) # 2.0)
12421279

@@ -1479,7 +1516,7 @@ def test_all():
14791516
coord_frames = ("head", "head", "meg", "head")
14801517
ctcs = (ctc_fname, ctc_fname, ctc_fname, ctc_mgh_fname)
14811518
mins = (3.5, 3.5, 1.2, 0.9)
1482-
meds = (10.8, 10.2, 3.2, 5.9)
1519+
meds = (10.7, 10.1, 3.2, 5.9)
14831520
st_durs = (1.0, 1.0, 1.0, None)
14841521
destinations = (None, sample_fname, None, None)
14851522
origins = (mf_head_origin, mf_head_origin, mf_meg_origin, mf_head_origin)
@@ -1496,7 +1533,7 @@ def test_all():
14961533
origin=origins[ii],
14971534
)
14981535
sss_mf = read_crop(sss_fnames[ii])
1499-
assert_meg_snr(sss_py, sss_mf, mins[ii], meds[ii], msg=rf)
1536+
assert_meg_snr(sss_py, sss_mf, mins[ii], meds[ii], msg=f"{ii=} {rf=!s}")
15001537

15011538

15021539
@pytest.mark.slowtest

mne/source_estimate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3672,7 +3672,7 @@ def _gen_extract_label_time_course(
36723672
_check_option("mode", mode, _get_default_label_modes())
36733673

36743674
if kind in ("surface", "mixed"):
3675-
if not isinstance(labels, list):
3675+
if not isinstance(labels, (list, tuple)):
36763676
labels = [labels]
36773677
use_sparse = False
36783678
else:

mne/transforms.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,8 @@ def _find_vector_rotation(a, b):
14641464
# Rodrigues' rotation formula:
14651465
# https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula
14661466
# http://math.stackexchange.com/a/476311
1467+
assert np.isclose(np.linalg.norm(a), 1.0), np.linalg.norm(a)
1468+
assert np.isclose(np.linalg.norm(b), 1.0), np.linalg.norm(b)
14671469
R = np.eye(3)
14681470
v = np.cross(a, b)
14691471
if np.allclose(v, 0.0): # identical
@@ -1472,6 +1474,7 @@ def _find_vector_rotation(a, b):
14721474
c = np.dot(a, b) # cosine of the angle between them
14731475
vx = _skew_symmetric_cross(v)
14741476
R += vx + np.dot(vx, vx) * (1 - c) / s
1477+
# Now we have: np.allclose(R @ a, b)
14751478
return R
14761479

14771480

0 commit comments

Comments
 (0)