Repository: mne-tools/mne-python

Issue:
ENH: improving high resolution CT iEEG electrode localization example
Hi,

@alexrockhill 
I have some questions and ideas for the high-res CT iEEG localization example.

First of all, since we already have head-MR and CT-MRI transforms (see the main iEEG localization example), can we create head-CT transform out of these instead of finding CT fiducials in freeview (as suggested in the hi-res CT example)? I tried to create head-CT transform by first inverting the CT-MRI affine to get MRI-CT transformation and then computing the dot product between head-MRI and MRI-CT:
```python
# subj_trans is the head->MRI tranform obtained as in the main iEEG example (transforming fsaverage fiducials to individual MRI)
# reg_affine is the CT->MRI transform
mri_ct_trans = np.linalg.inv(reg_affine)
head_ct = subj_trans['trans'] @ mri_ct_trans
# (I then turn the head_ct matrix to a Transform instance)
```
but for some reason, after marking a few contacts in GUI and using the `mne.transforms.apply_volume_registration_points` as in the high-res CT example, all channel positions are NaNed-out in the info. This happens irrespective of channels selection (selecting only the channels in the info that were marked in the GUI) or replacing any NaNs with zeros in `info['chs'][idx]['loc']`).

BTW - I discovered that `mne.transforms.apply_volume_registration_points` modifies info in-place. I don't think it should do that. 

One thing that would be great to clarify in the example would be the choice between using an adequate head-CT and an identity matrix. It is mentioned that one could fake the head-CT transform as identity matrix but the drawbacks are not dicsussed. I wasn't sure if not having the channel positions in head space would not cause problems at some point later so I didn't use the identity matrix. On the other hand creating the head-CT transform based on fiducials identified by hand sounds error-prone. 

I'll post other issues/ideas in a separate post here today.

PS. I am waiting for a green light to share the CT with micro-channels, but it should be possible. Both MRI and CT would be de-faced, but I could share a CT-MRI transformation along with these files.


Discussion:
We can definitely change the modifying the info in place, that's a quick PR and a good point.

That would be really helpful to have a shareable dataset, let me know.

There's not really any drawbacks to faking the head->CT transform, it's just a formality really.

The code looks pretty reasonable for the head->CT transform, it's a bit complicated though because the CT->MR is scanner RAS to scanner RAS. The end of the new tutorial wrangles all these transforms and we could wrap that but I think that is the only way to get the head->CT transform that you're asking for, unless I'm wrong and there's something more efficient. Can you put those things in a minimal example so that I can see what you're talking about a bit easier?
Hi @alexrockhill, sorry for the delay, I was on vacation. Unfortunatelly I also get all NaNs from `apply_volume_registration_points` when using identity transform. I'll try to create a reproducible example today. Could I share the data with you privately?
Ok, I debugged it and found what the problem was. It was actually in `compute_native_head_t` towards the end of `apply_volume_registration_points`. `compute_native_head_t` seems to assume that non-defined fiducials would be None (as returned by `mne.io._digitization._get_fid_coords`), while in my case they were NaN (seems to be the default now for "no position"). I didn't use CT scan fiducials, because I used identity matrix head->CT transform (this is easier than annotating fiducials for CT scans).
I've changed the `compute_native_head_t` to accept NaN as "no position" for fiducials and now it works fine.
I'll send a PR today.

Resolving patch:
diff --git a/mne/channels/montage.py b/mne/channels/montage.py
--- a/mne/channels/montage.py
+++ b/mne/channels/montage.py
@@ -1656,7 +1656,8 @@ def compute_native_head_t(montage, *, on_missing='warn', verbose=None):
     else:
         fid_keys = ('nasion', 'lpa', 'rpa')
         for key in fid_keys:
-            if fid_coords[key] is None:
+            this_coord = fid_coords[key]
+            if this_coord is None or np.any(np.isnan(this_coord)):
                 msg = (
                     f'Fiducial point {key} not found, assuming identity '
                     f'{_verbose_frames[coord_frame]} to head transformation')
diff --git a/mne/io/_digitization.py b/mne/io/_digitization.py
--- a/mne/io/_digitization.py
+++ b/mne/io/_digitization.py
@@ -254,7 +254,8 @@ def _ensure_fiducials_head(dig):
             if radius is None:
                 radius = [
                     np.linalg.norm(d['r']) for d in dig
-                    if d['coord_frame'] == FIFF.FIFFV_COORD_HEAD]
+                    if d['coord_frame'] == FIFF.FIFFV_COORD_HEAD
+                    and not np.isnan(d['r']).any()]
                 if not radius:
                     return  # can't complete, no head points
                 radius = np.mean(radius)


Test patch:
diff --git a/mne/channels/tests/test_montage.py b/mne/channels/tests/test_montage.py
--- a/mne/channels/tests/test_montage.py
+++ b/mne/channels/tests/test_montage.py
@@ -848,6 +848,52 @@ def test_set_dig_montage():
                              [6., 7., 8., 42., 42., 42.]])
 
 
+def test_set_dig_montage_with_nan_positions():
+    """Test that fiducials are not NaN.
+
+    Test that setting a montage with some NaN positions does not produce
+    NaN fiducials.
+    """
+    def _ensure_fid_not_nan(info, ch_pos):
+        montage_kwargs = dict(ch_pos=dict(), coord_frame='head')
+        for ch_idx, ch in enumerate(info.ch_names):
+            montage_kwargs['ch_pos'][ch] = ch_pos[ch_idx]
+
+        new_montage = make_dig_montage(**montage_kwargs)
+        info = info.copy()
+        info.set_montage(new_montage)
+
+        recovered_montage = info.get_montage()
+        fid_coords, coord_frame = _get_fid_coords(
+            recovered_montage.dig, raise_error=False)
+
+        for fid_coord in fid_coords.values():
+            if fid_coord is not None:
+                assert not np.isnan(fid_coord).any()
+
+        return fid_coords, coord_frame
+
+    channels = list('ABCDEF')
+    info = create_info(channels, 1000, ch_types='seeg')
+
+    # if all positions are NaN, the fiducials should not be NaN, but None
+    ch_pos = [info['chs'][ch_idx]['loc'][:3]
+              for ch_idx in range(len(channels))]
+    fid_coords, coord_frame = _ensure_fid_not_nan(info, ch_pos)
+    for fid_coord in fid_coords.values():
+        assert fid_coord is None
+    assert coord_frame is None
+
+    # if some positions are not NaN, the fiducials should be a non-NaN array
+    ch_pos[0] = np.array([1., 1.5, 1.])
+    ch_pos[1] = np.array([2., 1.5, 1.5])
+    ch_pos[2] = np.array([1.25, 1., 1.25])
+    fid_coords, coord_frame = _ensure_fid_not_nan(info, ch_pos)
+    for fid_coord in fid_coords.values():
+        assert isinstance(fid_coord, np.ndarray)
+    assert coord_frame == FIFF.FIFFV_COORD_HEAD
+
+
 @testing.requires_testing_data
 def test_fif_dig_montage(tmp_path):
     """Test FIF dig montage support."""