Skip to content

Commit aa864bc

Browse files
authored
ENH: Allow selecting projectors for reconstruction (#14220)
1 parent eff1e3f commit aa864bc

8 files changed

Lines changed: 444 additions & 92 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for applying selected SSP projectors and reconstructing projected data in sensor space on :class:`~mne.io.Raw`, :class:`~mne.Epochs`, and :class:`~mne.Evoked` objects via ``apply_proj(projs=...)`` and the new public ``reconstruct_proj(projs=...)`` method, by `Hamza Abdelhedi`_.

mne/_fiff/proj.py

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

55
import re
6+
import warnings
67
from copy import deepcopy
78
from itertools import count
89

@@ -274,11 +275,14 @@ def add_proj(self, projs, remove_existing=False, verbose=None):
274275
return self
275276

276277
@verbose
277-
def apply_proj(self, verbose=None):
278+
def apply_proj(self, *, projs=None, verbose=None):
278279
"""Apply the signal space projection (SSP) operators to the data.
279280
280281
Parameters
281282
----------
283+
projs : Projection | list of Projection | None
284+
The projectors to apply. All projectors must already be present in
285+
``self.info["projs"]``. If ``None``, all projectors are applied.
282286
%(verbose)s
283287
284288
Returns
@@ -288,9 +292,9 @@ def apply_proj(self, verbose=None):
288292
289293
Notes
290294
-----
291-
Once the projectors have been applied, they can no longer be
292-
removed. It is usually not recommended to apply the projectors at
293-
too early stages, as they are applied automatically later on
295+
Once a projector has been applied, it can no longer be removed. It is
296+
usually not recommended to apply the projectors at too early stages,
297+
as they are applied automatically later on
294298
(e.g. when computing inverse solutions).
295299
Hint: using the copy method individual projection vectors
296300
can be tested without affecting the original data.
@@ -310,43 +314,85 @@ def apply_proj(self, verbose=None):
310314
from ..evoked import Evoked
311315
from ..io import BaseRaw
312316

313-
if self.info["projs"] is None or len(self.info["projs"]) == 0:
314-
logger.info(
315-
"No projector specified for this dataset. "
316-
"Please consider the method self.add_proj."
317-
)
318-
return self
317+
restore = None
318+
if projs is not None:
319+
if isinstance(projs, Projection):
320+
projs = [projs]
321+
projs = _check_projs(projs, copy=False)
322+
if not projs:
323+
return self
324+
info = self.info.copy()
325+
selected_idx = _proj_indices(info["projs"], projs)
326+
to_apply = []
327+
for ii in selected_idx:
328+
proj = info["projs"][ii]
329+
if proj["active"]:
330+
continue
331+
# avoid emitting the warning twice
332+
with warnings.catch_warnings():
333+
warnings.simplefilter("ignore", RuntimeWarning)
334+
_, nproj, _ = make_projector([proj], info["ch_names"], info["bads"])
335+
if nproj:
336+
to_apply.append(ii)
337+
if not to_apply:
338+
return self
339+
with info._unlock():
340+
attached = info["projs"]
341+
keep = [
342+
proj["active"] or ii in to_apply for ii, proj in enumerate(attached)
343+
]
344+
omitted = iter(proj for proj, use in zip(attached, keep) if not use)
345+
info["projs"] = [proj for proj, use in zip(attached, keep) if use]
346+
restore = (keep, omitted)
347+
self.info = info
348+
349+
try:
350+
if self.info["projs"] is None or len(self.info["projs"]) == 0:
351+
logger.info(
352+
"No projector specified for this dataset. "
353+
"Please consider the method self.add_proj."
354+
)
355+
return self
319356

320-
# Exit delayed mode if you apply proj
321-
if isinstance(self, BaseEpochs) and self._do_delayed_proj:
322-
logger.info("Leaving delayed SSP mode.")
323-
self._do_delayed_proj = False
357+
# Exit delayed mode if you apply proj
358+
if isinstance(self, BaseEpochs) and self._do_delayed_proj:
359+
logger.info("Leaving delayed SSP mode.")
360+
self._do_delayed_proj = False
324361

325-
if all(p["active"] for p in self.info["projs"]):
326-
logger.info(
327-
"Projections have already been applied. Setting proj attribute to True."
328-
)
329-
return self
362+
if all(p["active"] for p in self.info["projs"]):
363+
logger.info(
364+
"Projections have already been applied. "
365+
"Setting proj attribute to True."
366+
)
367+
return self
330368

331-
_projector, info = setup_proj(
332-
deepcopy(self.info), add_eeg_ref=False, activate=True
333-
)
334-
# let's not raise a RuntimeError here, otherwise interactive plotting
335-
if _projector is None: # won't be fun.
336-
logger.info("The projections don't apply to these data. Doing nothing.")
369+
_projector, info = setup_proj(
370+
self.info.copy(), add_eeg_ref=False, activate=True
371+
)
372+
# let's not raise a RuntimeError here, otherwise interactive plotting
373+
if _projector is None: # won't be fun.
374+
logger.info("The projections don't apply to these data. Doing nothing.")
375+
return self
376+
self._projector, self.info = _projector, info
377+
if isinstance(self, BaseRaw | Evoked):
378+
if self.preload:
379+
self._data = np.dot(self._projector, self._data)
380+
else: # BaseEpochs
381+
if self.preload:
382+
for ii, e in enumerate(self._data):
383+
self._data[ii] = self._project_epoch(e)
384+
else:
385+
self.load_data() # will automatically apply
386+
logger.info("SSP projectors applied...")
337387
return self
338-
self._projector, self.info = _projector, info
339-
if isinstance(self, BaseRaw | Evoked):
340-
if self.preload:
341-
self._data = np.dot(self._projector, self._data)
342-
else: # BaseEpochs
343-
if self.preload:
344-
for ii, e in enumerate(self._data):
345-
self._data[ii] = self._project_epoch(e)
346-
else:
347-
self.load_data() # will automatically apply
348-
logger.info("SSP projectors applied...")
349-
return self
388+
finally:
389+
if restore is not None:
390+
keep, omitted = restore
391+
visible = iter(self.info["projs"])
392+
with self.info._unlock():
393+
self.info["projs"] = [
394+
next(visible) if use else next(omitted) for use in keep
395+
]
350396

351397
def del_proj(self, idx="all"):
352398
"""Remove SSP projection vector.
@@ -515,19 +561,63 @@ def plot_projs_topomap(
515561
)
516562
return fig
517563

518-
def _reconstruct_proj(self, mode="accurate", origin="auto"):
564+
def reconstruct_proj(self, *, projs=None, mode="accurate", origin="auto"):
565+
"""Apply SSP projectors and reconstruct the resulting signal in sensor space.
566+
567+
Operates in place.
568+
569+
Parameters
570+
----------
571+
projs : Projection | list of Projection | None
572+
The projector or projectors to apply before reconstruction. All
573+
projectors must already be present in ``self.info["projs"]``. If
574+
``None``, all projectors attached to the instance are used.
575+
mode : str
576+
Either ``'accurate'`` or ``'fast'``, determines the quality of the
577+
Legendre polynomial expansion used for reconstruction.
578+
origin : array-like, shape (3,) | str
579+
Origin of the sphere in the head coordinate frame and in meters.
580+
Can be ``'auto'`` (default), which means a head-digitization-based
581+
origin fit.
582+
583+
Returns
584+
-------
585+
self : same type as the input data
586+
The modified instance.
587+
"""
519588
from ..forward import _map_meg_or_eeg_channels
520589

521-
if len(self.info["projs"]) == 0:
522-
return self
523-
self.apply_proj()
590+
if projs is None:
591+
if len(self.info["projs"]) == 0:
592+
return self
593+
self.apply_proj()
594+
mapping_info = self.info
595+
selected_projs = None
596+
else:
597+
self.apply_proj(projs=projs)
598+
selected_projs = [projs] if isinstance(projs, Projection) else projs
599+
if len(selected_projs) == 0:
600+
return self
601+
mapping_info = self.info.copy()
602+
with mapping_info._unlock():
603+
mapping_info["projs"] = [
604+
proj for proj in mapping_info["projs"] if proj["active"]
605+
]
524606
for kind in ("meg", "eeg"):
525607
kwargs = dict(meg=False)
526608
kwargs[kind] = True
527609
picks = pick_types(self.info, **kwargs)
528610
if len(picks) == 0:
529611
continue
530-
info_from = pick_info(self.info, picks)
612+
info_from = pick_info(mapping_info, picks)
613+
if selected_projs is not None:
614+
with warnings.catch_warnings():
615+
warnings.simplefilter("ignore", RuntimeWarning)
616+
_, nproj, _ = make_projector(
617+
selected_projs, info_from["ch_names"], info_from["bads"]
618+
)
619+
if nproj == 0:
620+
continue
531621
info_to = info_from.copy()
532622
with info_to._unlock():
533623
info_to["projs"] = []
@@ -1164,6 +1254,27 @@ def setup_proj(
11641254
return projector, info
11651255

11661256

1257+
def _proj_indices(attached, projs):
1258+
"""Find the indices of projectors in an attached projector list."""
1259+
selected = []
1260+
for pi, proj in enumerate(projs):
1261+
matches = [
1262+
ii
1263+
for ii, attached_proj in enumerate(attached)
1264+
if _proj_equal(proj, attached_proj, check_active=False)
1265+
]
1266+
if len(matches) == 0:
1267+
raise ValueError(
1268+
f"projs[{pi}] does not match any projector in self.info['projs']"
1269+
)
1270+
if len(matches) > 1:
1271+
raise ValueError(
1272+
f"projs[{pi}] matches multiple projectors in self.info['projs']"
1273+
)
1274+
selected.append(matches[0])
1275+
return list(dict.fromkeys(selected))
1276+
1277+
11671278
def _uniquify_projs(projs, check_active=True, sort=True):
11681279
"""Make unique projs."""
11691280
final_projs = []

0 commit comments

Comments
 (0)