initial commit
This commit is contained in:
550
mne/forward/_field_interpolation.py
Normal file
550
mne/forward/_field_interpolation.py
Normal file
@@ -0,0 +1,550 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
# The computations in this code were primarily derived from Matti Hämäläinen's
|
||||
# C code.
|
||||
|
||||
import inspect
|
||||
from copy import deepcopy
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import interp1d
|
||||
|
||||
from .._fiff.constants import FIFF
|
||||
from .._fiff.meas_info import _simplify_info
|
||||
from .._fiff.pick import pick_info, pick_types
|
||||
from .._fiff.proj import _has_eeg_average_ref_proj, make_projector
|
||||
from ..bem import _check_origin
|
||||
from ..cov import make_ad_hoc_cov
|
||||
from ..epochs import BaseEpochs, EpochsArray
|
||||
from ..evoked import Evoked, EvokedArray
|
||||
from ..fixes import _safe_svd
|
||||
from ..surface import get_head_surf, get_meg_helmet_surf
|
||||
from ..transforms import _find_trans, _get_trans, transform_surface_to
|
||||
from ..utils import _check_fname, _check_option, _pl, _reg_pinv, logger, verbose
|
||||
from ._lead_dots import (
|
||||
_do_cross_dots,
|
||||
_do_self_dots,
|
||||
_do_surface_dots,
|
||||
_get_legen_table,
|
||||
)
|
||||
from ._make_forward import _create_eeg_els, _create_meg_coils, _read_coil_defs
|
||||
|
||||
|
||||
def _setup_dots(mode, info, coils, ch_type):
|
||||
"""Set up dot products."""
|
||||
int_rad = 0.06
|
||||
noise = make_ad_hoc_cov(info, dict(mag=20e-15, grad=5e-13, eeg=1e-6))
|
||||
n_coeff, interp = (50, "nearest") if mode == "fast" else (100, "linear")
|
||||
lut, n_fact = _get_legen_table(ch_type, False, n_coeff, verbose=False)
|
||||
lut_fun = interp1d(np.linspace(-1, 1, lut.shape[0]), lut, interp, axis=0)
|
||||
return int_rad, noise, lut_fun, n_fact
|
||||
|
||||
|
||||
def _compute_mapping_matrix(fmd, info):
|
||||
"""Do the hairy computations."""
|
||||
logger.info(" Preparing the mapping matrix...")
|
||||
# assemble a projector and apply it to the data
|
||||
ch_names = fmd["ch_names"]
|
||||
projs = info.get("projs", list())
|
||||
proj_op = make_projector(projs, ch_names)[0]
|
||||
proj_dots = np.dot(proj_op.T, np.dot(fmd["self_dots"], proj_op))
|
||||
|
||||
noise_cov = fmd["noise"]
|
||||
# Whiten
|
||||
if not noise_cov["diag"]:
|
||||
raise NotImplementedError # this shouldn't happen
|
||||
whitener = np.diag(1.0 / np.sqrt(noise_cov["data"].ravel()))
|
||||
whitened_dots = np.dot(whitener.T, np.dot(proj_dots, whitener))
|
||||
|
||||
# SVD is numerically better than the eigenvalue composition even if
|
||||
# mat is supposed to be symmetric and positive definite
|
||||
if fmd.get("pinv_method", "tsvd") == "tsvd":
|
||||
inv, fmd["nest"] = _pinv_trunc(whitened_dots, fmd["miss"])
|
||||
else:
|
||||
assert fmd["pinv_method"] == "tikhonov", fmd["pinv_method"]
|
||||
inv, fmd["nest"] = _pinv_tikhonov(whitened_dots, fmd["miss"])
|
||||
|
||||
# Sandwich with the whitener
|
||||
inv_whitened = np.dot(whitener.T, np.dot(inv, whitener))
|
||||
|
||||
# Take into account that the lead fields used to compute
|
||||
# d->surface_dots were unprojected
|
||||
inv_whitened_proj = proj_op.T @ inv_whitened
|
||||
|
||||
# Finally sandwich in the selection matrix
|
||||
# This one picks up the correct lead field projection
|
||||
mapping_mat = np.dot(fmd["surface_dots"], inv_whitened_proj)
|
||||
|
||||
# Optionally apply the average electrode reference to the final field map
|
||||
if fmd["kind"] == "eeg" and _has_eeg_average_ref_proj(info):
|
||||
logger.info(
|
||||
" The map has an average electrode reference "
|
||||
f"({mapping_mat.shape[0]} channels)"
|
||||
)
|
||||
mapping_mat -= np.mean(mapping_mat, axis=0)
|
||||
return mapping_mat
|
||||
|
||||
|
||||
def _pinv_trunc(x, miss):
|
||||
"""Compute pseudoinverse, truncating at most "miss" fraction of varexp."""
|
||||
u, s, v = _safe_svd(x, full_matrices=False)
|
||||
|
||||
# Eigenvalue truncation
|
||||
varexp = np.cumsum(s)
|
||||
varexp /= varexp[-1]
|
||||
n = np.where(varexp >= (1.0 - miss))[0][0] + 1
|
||||
logger.info(
|
||||
" Truncating at %d/%d components to omit less than %g " "(%0.2g)",
|
||||
n,
|
||||
len(s),
|
||||
miss,
|
||||
1.0 - varexp[n - 1],
|
||||
)
|
||||
s = 1.0 / s[:n]
|
||||
inv = ((u[:, :n] * s) @ v[:n]).T
|
||||
return inv, n
|
||||
|
||||
|
||||
def _pinv_tikhonov(x, reg):
|
||||
# _reg_pinv requires square Hermitian, which we have here
|
||||
inv, _, n = _reg_pinv(x, reg=reg, rank=None)
|
||||
logger.info(
|
||||
f" Truncating at {n}/{len(x)} components and regularizing "
|
||||
f"with α={reg:0.1e}"
|
||||
)
|
||||
return inv, n
|
||||
|
||||
|
||||
def _map_meg_or_eeg_channels(info_from, info_to, mode, origin, miss=None):
|
||||
"""Find mapping from one set of channels to another.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
info_from : instance of Info
|
||||
The measurement data to interpolate from.
|
||||
info_to : instance of Info
|
||||
The measurement info to interpolate to.
|
||||
mode : str
|
||||
Either `'accurate'` or `'fast'`, determines the quality of the
|
||||
Legendre polynomial expansion used. `'fast'` should be sufficient
|
||||
for most applications.
|
||||
origin : array-like, shape (3,) | str
|
||||
Origin of the sphere in the head coordinate frame and in meters.
|
||||
Can be ``'auto'``, which means a head-digitization-based origin
|
||||
fit. Default is ``(0., 0., 0.04)``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
mapping : array, shape (n_to, n_from)
|
||||
A mapping matrix.
|
||||
"""
|
||||
# no need to apply trans because both from and to coils are in device
|
||||
# coordinates
|
||||
info_kinds = set(ch["kind"] for ch in info_to["chs"])
|
||||
info_kinds |= set(ch["kind"] for ch in info_from["chs"])
|
||||
if FIFF.FIFFV_REF_MEG_CH in info_kinds: # refs same as MEG
|
||||
info_kinds |= set([FIFF.FIFFV_MEG_CH])
|
||||
info_kinds -= set([FIFF.FIFFV_REF_MEG_CH])
|
||||
info_kinds = sorted(info_kinds)
|
||||
# This should be guaranteed by the callers
|
||||
assert len(info_kinds) == 1 and info_kinds[0] in (
|
||||
FIFF.FIFFV_MEG_CH,
|
||||
FIFF.FIFFV_EEG_CH,
|
||||
)
|
||||
kind = "eeg" if info_kinds[0] == FIFF.FIFFV_EEG_CH else "meg"
|
||||
|
||||
#
|
||||
# Step 1. Prepare the coil definitions
|
||||
#
|
||||
if kind == "meg":
|
||||
templates = _read_coil_defs(verbose=False)
|
||||
coils_from = _create_meg_coils(
|
||||
info_from["chs"], "normal", info_from["dev_head_t"], templates
|
||||
)
|
||||
coils_to = _create_meg_coils(
|
||||
info_to["chs"], "normal", info_to["dev_head_t"], templates
|
||||
)
|
||||
pinv_method = "tsvd"
|
||||
miss = 1e-4
|
||||
else:
|
||||
coils_from = _create_eeg_els(info_from["chs"])
|
||||
coils_to = _create_eeg_els(info_to["chs"])
|
||||
pinv_method = "tikhonov"
|
||||
miss = 1e-1
|
||||
if _has_eeg_average_ref_proj(info_from) and not _has_eeg_average_ref_proj(
|
||||
info_to
|
||||
):
|
||||
raise RuntimeError(
|
||||
"info_to must have an average EEG reference projector if "
|
||||
"info_from has one"
|
||||
)
|
||||
origin = _check_origin(origin, info_from)
|
||||
#
|
||||
# Step 2. Calculate the dot products
|
||||
#
|
||||
int_rad, noise, lut_fun, n_fact = _setup_dots(mode, info_from, coils_from, kind)
|
||||
logger.info(
|
||||
f" Computing dot products for {len(coils_from)} "
|
||||
f"{kind.upper()} channel{_pl(coils_from)}..."
|
||||
)
|
||||
self_dots = _do_self_dots(
|
||||
int_rad, False, coils_from, origin, kind, lut_fun, n_fact, n_jobs=None
|
||||
)
|
||||
logger.info(
|
||||
f" Computing cross products for {len(coils_from)} → "
|
||||
f"{len(coils_to)} {kind.upper()} channel{_pl(coils_to)}..."
|
||||
)
|
||||
cross_dots = _do_cross_dots(
|
||||
int_rad, False, coils_from, coils_to, origin, kind, lut_fun, n_fact
|
||||
).T
|
||||
|
||||
ch_names = [c["ch_name"] for c in info_from["chs"]]
|
||||
fmd = dict(
|
||||
kind=kind,
|
||||
ch_names=ch_names,
|
||||
origin=origin,
|
||||
noise=noise,
|
||||
self_dots=self_dots,
|
||||
surface_dots=cross_dots,
|
||||
int_rad=int_rad,
|
||||
miss=miss,
|
||||
pinv_method=pinv_method,
|
||||
)
|
||||
|
||||
#
|
||||
# Step 3. Compute the mapping matrix
|
||||
#
|
||||
mapping = _compute_mapping_matrix(fmd, info_from)
|
||||
return mapping
|
||||
|
||||
|
||||
def _as_meg_type_inst(inst, ch_type="grad", mode="fast"):
|
||||
"""Compute virtual evoked using interpolated fields in mag/grad channels.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inst : instance of mne.Evoked or mne.Epochs
|
||||
The evoked or epochs object.
|
||||
ch_type : str
|
||||
The destination channel type. It can be 'mag' or 'grad'.
|
||||
mode : str
|
||||
Either `'accurate'` or `'fast'`, determines the quality of the
|
||||
Legendre polynomial expansion used. `'fast'` should be sufficient
|
||||
for most applications.
|
||||
|
||||
Returns
|
||||
-------
|
||||
inst : instance of mne.EvokedArray or mne.EpochsArray
|
||||
The transformed evoked object containing only virtual channels.
|
||||
"""
|
||||
_check_option("ch_type", ch_type, ["mag", "grad"])
|
||||
|
||||
# pick the original and destination channels
|
||||
pick_from = pick_types(inst.info, meg=True, eeg=False, ref_meg=False)
|
||||
pick_to = pick_types(inst.info, meg=ch_type, eeg=False, ref_meg=False)
|
||||
|
||||
if len(pick_to) == 0:
|
||||
raise ValueError(
|
||||
"No channels matching the destination channel type"
|
||||
" found in info. Please pass an evoked containing"
|
||||
"both the original and destination channels. Only the"
|
||||
" locations of the destination channels will be used"
|
||||
" for interpolation."
|
||||
)
|
||||
|
||||
info_from = pick_info(inst.info, pick_from)
|
||||
info_to = pick_info(inst.info, pick_to)
|
||||
# XXX someday we should probably expose the origin
|
||||
mapping = _map_meg_or_eeg_channels(
|
||||
info_from, info_to, origin=(0.0, 0.0, 0.04), mode=mode
|
||||
)
|
||||
|
||||
# compute data by multiplying by the 'gain matrix' from
|
||||
# original sensors to virtual sensors
|
||||
if hasattr(inst, "get_data"):
|
||||
kwargs = dict()
|
||||
if "copy" in inspect.getfullargspec(inst.get_data).kwonlyargs:
|
||||
kwargs["copy"] = False
|
||||
data = inst.get_data(**kwargs)
|
||||
else:
|
||||
data = inst.data
|
||||
|
||||
ndim = data.ndim
|
||||
if ndim == 2:
|
||||
data = data[np.newaxis, :, :]
|
||||
|
||||
data_ = np.empty((data.shape[0], len(mapping), data.shape[2]), dtype=data.dtype)
|
||||
for d, d_ in zip(data, data_):
|
||||
d_[:] = np.dot(mapping, d[pick_from])
|
||||
|
||||
# keep only the destination channel types
|
||||
info = pick_info(inst.info, sel=pick_to, copy=True)
|
||||
|
||||
# change channel names to emphasize they contain interpolated data
|
||||
for ch in info["chs"]:
|
||||
ch["ch_name"] += "_v"
|
||||
info._update_redundant()
|
||||
info._check_consistency()
|
||||
if isinstance(inst, Evoked):
|
||||
assert ndim == 2
|
||||
data_ = data_[0] # undo new axis
|
||||
inst_ = EvokedArray(
|
||||
data_, info, tmin=inst.times[0], comment=inst.comment, nave=inst.nave
|
||||
)
|
||||
else:
|
||||
assert isinstance(inst, BaseEpochs)
|
||||
inst_ = EpochsArray(
|
||||
data_,
|
||||
info,
|
||||
tmin=inst.tmin,
|
||||
events=inst.events,
|
||||
event_id=inst.event_id,
|
||||
metadata=inst.metadata,
|
||||
)
|
||||
|
||||
return inst_
|
||||
|
||||
|
||||
@verbose
|
||||
def _make_surface_mapping(
|
||||
info,
|
||||
surf,
|
||||
ch_type="meg",
|
||||
trans=None,
|
||||
mode="fast",
|
||||
n_jobs=None,
|
||||
origin=(0.0, 0.0, 0.04),
|
||||
verbose=None,
|
||||
):
|
||||
"""Re-map M/EEG data to a surface.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
%(info_not_none)s
|
||||
surf : dict
|
||||
The surface to map the data to. The required fields are `'rr'`,
|
||||
`'nn'`, and `'coord_frame'`. Must be in head coordinates.
|
||||
ch_type : str
|
||||
Must be either `'meg'` or `'eeg'`, determines the type of field.
|
||||
trans : None | dict
|
||||
If None, no transformation applied. Should be a Head<->MRI
|
||||
transformation.
|
||||
mode : str
|
||||
Either `'accurate'` or `'fast'`, determines the quality of the
|
||||
Legendre polynomial expansion used. `'fast'` should be sufficient
|
||||
for most applications.
|
||||
%(n_jobs)s
|
||||
origin : array-like, shape (3,) | str
|
||||
Origin of the sphere in the head coordinate frame and in meters.
|
||||
The default is ``'auto'``, which means a head-digitization-based
|
||||
origin fit.
|
||||
%(verbose)s
|
||||
|
||||
Returns
|
||||
-------
|
||||
mapping : array
|
||||
A n_vertices x n_sensors array that remaps the MEG or EEG data,
|
||||
as `new_data = np.dot(mapping, data)`.
|
||||
"""
|
||||
if not all(key in surf for key in ["rr", "nn"]):
|
||||
raise KeyError('surf must have both "rr" and "nn"')
|
||||
if "coord_frame" not in surf:
|
||||
raise KeyError(
|
||||
'The surface coordinate frame must be specified in surf["coord_frame"]'
|
||||
)
|
||||
_check_option("mode", mode, ["accurate", "fast"])
|
||||
|
||||
# deal with coordinate frames here -- always go to "head" (easiest)
|
||||
orig_surf = surf
|
||||
surf = transform_surface_to(deepcopy(surf), "head", trans)
|
||||
origin = _check_origin(origin, info)
|
||||
|
||||
#
|
||||
# Step 1. Prepare the coil definitions
|
||||
# Do the dot products, assume surf in head coords
|
||||
#
|
||||
_check_option("ch_type", ch_type, ["meg", "eeg"])
|
||||
if ch_type == "meg":
|
||||
picks = pick_types(info, meg=True, eeg=False, ref_meg=False)
|
||||
logger.info("Prepare MEG mapping...")
|
||||
else:
|
||||
picks = pick_types(info, meg=False, eeg=True, ref_meg=False)
|
||||
logger.info("Prepare EEG mapping...")
|
||||
if len(picks) == 0:
|
||||
raise RuntimeError("cannot map, no channels found")
|
||||
# XXX this code does not do any checking for compensation channels,
|
||||
# but it seems like this must be intentional from the ref_meg=False
|
||||
# (presumably from the C code)
|
||||
dev_head_t = info["dev_head_t"]
|
||||
info = pick_info(_simplify_info(info), picks)
|
||||
info["dev_head_t"] = dev_head_t
|
||||
|
||||
# create coil defs in head coordinates
|
||||
if ch_type == "meg":
|
||||
# Put them in head coordinates
|
||||
coils = _create_meg_coils(info["chs"], "normal", info["dev_head_t"])
|
||||
type_str = "coils"
|
||||
miss = 1e-4 # Smoothing criterion for MEG
|
||||
else: # EEG
|
||||
coils = _create_eeg_els(info["chs"])
|
||||
type_str = "electrodes"
|
||||
miss = 1e-3 # Smoothing criterion for EEG
|
||||
|
||||
#
|
||||
# Step 2. Calculate the dot products
|
||||
#
|
||||
int_rad, noise, lut_fun, n_fact = _setup_dots(mode, info, coils, ch_type)
|
||||
logger.info("Computing dot products for %i %s...", len(coils), type_str)
|
||||
self_dots = _do_self_dots(
|
||||
int_rad, False, coils, origin, ch_type, lut_fun, n_fact, n_jobs
|
||||
)
|
||||
sel = np.arange(len(surf["rr"])) # eventually we should do sub-selection
|
||||
logger.info("Computing dot products for %i surface locations...", len(sel))
|
||||
surface_dots = _do_surface_dots(
|
||||
int_rad, False, coils, surf, sel, origin, ch_type, lut_fun, n_fact, n_jobs
|
||||
)
|
||||
|
||||
#
|
||||
# Step 4. Return the result
|
||||
#
|
||||
fmd = dict(
|
||||
kind=ch_type,
|
||||
surf=surf,
|
||||
ch_names=info["ch_names"],
|
||||
coils=coils,
|
||||
origin=origin,
|
||||
noise=noise,
|
||||
self_dots=self_dots,
|
||||
surface_dots=surface_dots,
|
||||
int_rad=int_rad,
|
||||
miss=miss,
|
||||
)
|
||||
logger.info("Field mapping data ready")
|
||||
|
||||
fmd["data"] = _compute_mapping_matrix(fmd, info)
|
||||
# bring the original back, whatever coord frame it was in
|
||||
fmd["surf"] = orig_surf
|
||||
|
||||
# Remove some unnecessary fields
|
||||
del fmd["self_dots"]
|
||||
del fmd["surface_dots"]
|
||||
del fmd["int_rad"]
|
||||
del fmd["miss"]
|
||||
return fmd
|
||||
|
||||
|
||||
@verbose
|
||||
def make_field_map(
|
||||
evoked,
|
||||
trans="auto",
|
||||
subject=None,
|
||||
subjects_dir=None,
|
||||
ch_type=None,
|
||||
mode="fast",
|
||||
meg_surf="helmet",
|
||||
origin=(0.0, 0.0, 0.04),
|
||||
n_jobs=None,
|
||||
*,
|
||||
head_source=("bem", "head"),
|
||||
verbose=None,
|
||||
):
|
||||
"""Compute surface maps used for field display in 3D.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
evoked : Evoked | Epochs | Raw
|
||||
The measurement file. Need to have info attribute.
|
||||
%(trans)s ``"auto"`` (default) will load trans from the FreeSurfer
|
||||
directory specified by ``subject`` and ``subjects_dir`` parameters.
|
||||
|
||||
.. versionchanged:: 0.19
|
||||
Support for ``'fsaverage'`` argument.
|
||||
subject : str | None
|
||||
The subject name corresponding to FreeSurfer environment
|
||||
variable SUBJECT. If None, map for EEG data will not be available.
|
||||
subjects_dir : path-like
|
||||
The path to the freesurfer subjects reconstructions.
|
||||
It corresponds to Freesurfer environment variable SUBJECTS_DIR.
|
||||
ch_type : None | ``'eeg'`` | ``'meg'``
|
||||
If None, a map for each available channel type will be returned.
|
||||
Else only the specified type will be used.
|
||||
mode : ``'accurate'`` | ``'fast'``
|
||||
Either ``'accurate'`` or ``'fast'``, determines the quality of the
|
||||
Legendre polynomial expansion used. ``'fast'`` should be sufficient
|
||||
for most applications.
|
||||
meg_surf : 'helmet' | 'head'
|
||||
Should be ``'helmet'`` or ``'head'`` to specify in which surface
|
||||
to compute the MEG field map. The default value is ``'helmet'``.
|
||||
origin : array-like, shape (3,) | 'auto'
|
||||
Origin of the sphere in the head coordinate frame and in meters.
|
||||
Can be ``'auto'``, which means a head-digitization-based origin
|
||||
fit. Default is ``(0., 0., 0.04)``.
|
||||
|
||||
.. versionadded:: 0.11
|
||||
%(n_jobs)s
|
||||
%(head_source)s
|
||||
|
||||
.. versionadded:: 1.1
|
||||
%(verbose)s
|
||||
|
||||
Returns
|
||||
-------
|
||||
surf_maps : list
|
||||
The surface maps to be used for field plots. The list contains
|
||||
separate ones for MEG and EEG (if both MEG and EEG are present).
|
||||
"""
|
||||
info = evoked.info
|
||||
|
||||
if ch_type is None:
|
||||
types = [t for t in ["eeg", "meg"] if t in evoked]
|
||||
else:
|
||||
_check_option("ch_type", ch_type, ["eeg", "meg"])
|
||||
types = [ch_type]
|
||||
|
||||
if subjects_dir is not None:
|
||||
subjects_dir = _check_fname(
|
||||
subjects_dir,
|
||||
overwrite="read",
|
||||
must_exist=True,
|
||||
name="subjects_dir",
|
||||
need_dir=True,
|
||||
)
|
||||
if isinstance(trans, str) and trans == "auto":
|
||||
# let's try to do this in MRI coordinates so they're easy to plot
|
||||
trans = _find_trans(subject, subjects_dir)
|
||||
trans, trans_type = _get_trans(trans, fro="head", to="mri")
|
||||
|
||||
if "eeg" in types and trans_type == "identity":
|
||||
logger.info("No trans file available. EEG data ignored.")
|
||||
types.remove("eeg")
|
||||
|
||||
if len(types) == 0:
|
||||
raise RuntimeError("No data available for mapping.")
|
||||
|
||||
_check_option("meg_surf", meg_surf, ["helmet", "head"])
|
||||
|
||||
surfs = []
|
||||
for this_type in types:
|
||||
if this_type == "meg" and meg_surf == "helmet":
|
||||
surf = get_meg_helmet_surf(info, trans)
|
||||
else:
|
||||
surf = get_head_surf(subject, source=head_source, subjects_dir=subjects_dir)
|
||||
surfs.append(surf)
|
||||
|
||||
surf_maps = list()
|
||||
|
||||
for this_type, this_surf in zip(types, surfs):
|
||||
this_map = _make_surface_mapping(
|
||||
evoked.info,
|
||||
this_surf,
|
||||
this_type,
|
||||
trans,
|
||||
n_jobs=n_jobs,
|
||||
origin=origin,
|
||||
mode=mode,
|
||||
)
|
||||
surf_maps.append(this_map)
|
||||
|
||||
return surf_maps
|
||||
Reference in New Issue
Block a user