Montage correction for misplaced cap

I have several hours of EEG recording where a mistake was made: the cap was shifted down such that Cz channel was instead located over CPz location. I would like to correct for this misplaced cap

The measurement has 128 channels and thus Iā€™d like to correct the montage and perhaps later use inverse methods on this corrected data rather than discarding it.

Is there any elegant solution for this? Such as shifting all electrodes locations in the montage by an angle?
Iā€™m not very comfortable with the underlying geometry and a bit scared to make mistakes in this regard

  • MNE-Python version: 0.23.0
  • os: windows

( @mmagnuski thanks for redirecting from github) :slight_smile:

I am not aware of a generic function for this, but in your case I would start with a schematic of the cap that you were using, for example this one:

And then Iā€™d try to carefully code a mapping from current channel to actual channel ({"Cz": "CPz", ...} and finally apply the map to the channels for renaming. Once the channels are renamed you can load a standard (template) montage again and the positions will correspond better to the actual positions.

Youā€™ll probably lose some channel positions, for example when shifting the Iz coordinate ā€œdown the neckā€, the 10-05 (5% system) doesnā€™t have a name for that position, and thus itā€™ll also not be present in the standard template montages. Rather than trying to compute these positions I think itā€™d be fair enough to just drop those electrodes.

As for the underlying geometry (assuming you are using the 10-20, 10-10, or 10-05 system), you can perhaps benefit from looking at Compute and plot standard EEG electrode positions ā€” eeg_positions 2.1.0.dev0 documentation ā†’ the associated GitHub repository contains the code to compute all standard electrode positions on a spherical head model.

2 Likes

That depends on what you intend to do with the data.
If you plan to perform channel-level analyses, then rotating the montage would not make a difference - when aggregating the single-subject data, the channels will be matched by their names irrespective of the corrected position (at least that should happen by default).
In such a case you could try changing channel names to account for the shift (as already suggested here by @sappelhoff), but you will have to drop some of the channels. In theory, you could also try to reconstruct the signal at the correct locations (via interpolation or projecting to source space and projecting back to other channel locations), but I would not be comfortable doing that. Personally, for the channel-level analyses, I would either leave this file as it is or exclude it from the analyses.
Bear in mind that if you use decoding or construct individual spatial filters to extract the signal of interest, the cap shift would not matter (you would only have to remember about it when interpreting the filters/patterns of the affected file).
For source space analyses - you will just need to rotate the channels during coregistration (that is when registering channels and the MRI to the same space).

3 Likes

Thank you very much @mmagnuski and @sappelhoff both for your detailed answers.
The personal webspace from @sappelhoff helps a lot defining the issue. You both evoked the two aspects of my issue: my goal is to perform two separate analysis:

  • Frequency analysis on ONE electrode (e.g. Fz), using spatial filters (current source density): I intend to keep the montage unchanged and instead pick FCz after preprocessing (making sure mislabeling does not interfere elsewhere)

  • I would like to compute connectivity between two brodmann areas using inverse solutions (e.g. dSPM). For this I believe it would be good to rotate channels coordinates 5% in the posterior direction (back). I assume what matters here are coordinates and not channel labels. However, I must confess my limitations concerning the trigonometry used for representing channel locations.

Do you know any very easy function that could perform the rotation for me from a montage ? I see two possibilities of development:

  1. a function that would shift the coordinates of a montage by either percentage or angle: new_montage = shift_montage(montage: DigMontage, shift_angle: Tuple[float], shift_percent: Tuple[float]). shift_angle would be a tuple with thetas (x, y, z). But perhaps you might want to work with different indicators such as sph_phi and sph_radius that are too complex for me. It would be called between montage=read_montage() and raw.set_montage(montage)
  2. A function that would relabel the channels provided a shift (in percents?) using intersection between channel coordinates before and after the shift. e.g.: ch_names = shift_labels(montage, shift_percent=(0,0,5), match_radius=2.4%, drop_unmached=False)

Yet writing those funtions could be a lot of efforts for correcting mistakes that are not supposed to happen in the first placeā€¦
In the end all I want to do is to rotate an axis by 5% :slight_smile:

Does that mean you have also digitized your electrode positions? That is, you have measured the XYZ position of each electrode and want to use that in your analysis, as opposed to just using a standard/template position set (like the one from the eeg_positions package, or the one inbuilt in MNE-Python)?

If not, then I think this ā€œrotation approachā€ is overkill, I suspect youā€™ll spend more time on it than recording a new participant :wink: I am also not comfortable enough with the math to present a solution for that.

1 Like

@sappelhoff Thanks g** no, I am using a montage provided by BrainProducts, and is very similar to the standard 05-10 montage.

The label rotation is overkill I admit, so I donā€™t believe I will do this, I will just change the name of the unique electrode I am interested in in the concerned participants.

But concerning the other analysis: rotating all montage coordinates 5% (Cz to CPz) cannot be that hard, right? The rest of the analysis after rotation would not implicate electrode labels anymore. What I was wondering is whether I would be ā€˜safe and correctā€™ if right after the Montage rotation I called mne.make_forward_solution(), mne.minimum_norm.make_inverse_operator() and mne.minimum_norm.apply_inverse_epochs() to compute spectral_connectivity on source estimates of brodmann areas.

Donā€™t worry @lokinou - for source space analyses rotating channels in the coregistration gui is easy, you donā€™t have to change your montage at all. :slight_smile: For this specific subject you will just create a separate channels-mri transformation, rotating channels in the GUI to their desired position.
Rotating the x, y, z positions yourself is also quite easy: once you have xyz channel position array you can use scipyā€™s Rotation.

Iā€™ll give it a try in the following days @mmagnuski and @sappelhoff . Thanks for the help iā€™ll let you know the solution I found.

1 Like

(edited after adding fiducials back when recreating the montage)

@sappelhoff , @mmagnuski

Here is the code I ended up making.
I ignored the Z axis of the electrode coordinates and hope for the best in this regard.
Here is a reproducable example of how I proceeded

import vg
import scipy
from scipy.spatial.transform import Rotation as R
import mne
import logging


import vg
import numpy as np
import scipy
from scipy.spatial.transform import Rotation as R
import mne
import logging


def rotate_montage_with_misplaced_channel(intended_channel_str: str, 
                                          actual_channel_str: str, 
                                          montage: mne.channels.DigMontage, 
                                          exclude=[]) -> mne.channels.DigMontage:
    '''
    Corrects a montage by artificially rotating all electrode coordinates
    '''
    log = logging.getLogger(__name__)

    
    # Save all the fiducials
    coord_frame = montage.get_positions()['coord_frame']
    nasion = montage.get_positions()['nasion']
    lpa = montage.get_positions()['lpa']
    rpa = montage.get_positions()['rpa']
    hsp = montage.get_positions()['hsp']
    hpi = montage.get_positions()['hpi']

    
    # retrieve the x,y,z position of the channels
    A_intended = montage.get_positions()['ch_pos'][intended_channel_str]
    B_actual = montage.get_positions()['ch_pos'][actual_channel_str]
    
    # get the x and y locations for the rotation, ignore Z
    angle_between_ch = [vg.signed_angle(A_intended, B_actual, look=vg.basis.x, units='rad'), 
                        vg.signed_angle(A_intended, B_actual, look=vg.basis.y, units='rad'), 
                        0]
    
    # Define the rotation on X and Y
    rot = R.from_rotvec(angle_between_ch)
    log.warning(f'Rotating coordinates using the angle between {intended_channel_str} to {actual_channel_str} as a reference {angle_between_ch} Rad')
    
    # create an empty matrix for storing new electrode positions
    new_pos = np.zeros((len(montage.ch_names), 3), np.float32)
    
    # Loop on all electrode locations
    for ch_name, ch_idx in zip(montage.ch_names, list(range(len(montage.ch_names)))):
        if ch_name not in exclude:
            # rotate them
            new_pos[ch_idx,:] = rot.apply(montage.dig[ch_idx]['r'])
            old = montage.dig[ch_idx]['r']
            new = rot.apply(montage.dig[ch_idx]['r'])
            #print(f'old:{old}, new:{new}')
            
            # keep the values
            #new_pos[ch_idx,:] = montage.dig[ch_idx]['r']
    
    # apply the values in a new montage
    montage_out = mne.channels.make_dig_montage(ch_pos=dict(zip(montage.ch_names, new_pos)), 
                                                coord_frame = coord_frame,
                                                nasion = nasion,
                                                lpa = lpa,
                                                rpa = rpa,
                                                hsp = hsp,
                                                hpi = hpi)
    
    # alters and return the montage
    return montage_out

Letā€™s apply an example in which the cap was badly positioned such that the electrode that was on FCz was placed on Cz instead

montage = mne.channels.make_standard_montage('standard_1005')

montage.plot()

mnt = rotate_montage_with_misplaced_channel(intended_channel_str='FCz', 
                                          actual_channel_str='Cz', 
                                          montage= montage)

mnt.plot()

Does it makes sense this way?

My goal after solving this is to apply this rotation to the faulty data hoping it helps Current Source Density algorithms and perhaps inverse methods better.
In this example, if I wanted to have any signal extracted from the ā€˜realā€™ FCz, I would then pick channel labeled ā€˜Czā€™ instead (but yet with the correct montage coordinates).

Did I overlook the Z axis?