mne-tools / mne-nirs

Process Near-Infrared Spectroscopy Data in MNE
https://mne.tools/mne-nirs/
BSD 3-Clause "New" or "Revised" License
79 stars 35 forks source link

Reading 3D coordinates from snirf produces different result than read_custom_montage() #545

Open kdarti opened 5 months ago

kdarti commented 5 months ago

Describe the bug

I'm not completely sure that this is a bug, and I'm not sure it's even expected that you'd get the same results when automatically reading coordinates from a snirf file as when reading the same coordinates using read_custom_montage().

Steps to reproduce

20240327-snirf3d-mne.zip

import mne mne.io.read_raw_snirf(r"2x12_nz-to-nasion.snirf").get_montage().plot() mne.channels.read_custom_montage(r"digitisation_2x12.elc").plot() mne.io.read_raw_snirf(r"2x12_nz-to-nasion.snirf").get_montage().get_positions() mne.channels.read_custom_montage(r"digitisation_2x12.elc").get_positions()

Expected results

I'd expect the resulting coordinates to be the same, given the coordinates in the .snirf and the .elc files are identical.

The difference seems to lie here:

https://github.com/mne-tools/mne-python/blob/eee8e6fe580034f4a3a4fb13bdca3bfc99240708/mne/channels/_standard_montage_utils.py#L274

If I comment out that line I get identical results.

Actual results

image image

I should say that the digitisation is bad and not realistic, but the result that I expect is what I get from read_custom_montage()

mne.io.read_raw_snirf(r"2x12_nz-to-nasion.snirf").get_montage().get_positions()
Loading ...\2x12_nz-to-nasion.snirf
Out[221]: 
{'ch_pos': OrderedDict([('S1', array([0.05263, 0.04361, 0.10604])),
              ('S2', array([-0.0598 ,  0.03692,  0.10477])),
              ('S3', array([-0.00454,  0.09951,  0.07741])),
              ('S4', array([0.05279, 0.11416, 0.00981])),
              ('S5', array([-0.06111,  0.11034,  0.00243])),
              ('S6', array([ 0.06122, -0.1047 ,  0.0347 ])),
              ('S7', array([-0.0399 , -0.10976,  0.04771])),
              ('S8', array([ 0.01618, -0.07903,  0.09699])),
              ('S9', array([ 0.07706, -0.03319,  0.09421])),
              ('S10', array([-0.04648, -0.03294,  0.11256])),
              ('D1', array([-0.00076,  0.04186,  0.119  ])),
              ('D2', array([0.05333, 0.08289, 0.07874])),
              ('D3', array([-0.06278,  0.08358,  0.07064])),
              ('D4', array([-0.0044 ,  0.12561,  0.01085])),
              ('D5', array([ 0.01436, -0.11273,  0.05478])),
              ('D6', array([ 0.07158, -0.08069,  0.06542])),
              ('D7', array([-0.03738, -0.07695,  0.09272])),
              ('D8', array([ 0.01718, -0.03431,  0.12018]))]),
 'coord_frame': 'unknown',
 'nasion': array([-0.001,  0.084, -0.043]),
 'lpa': array([-0.0825, -0.018 , -0.048 ]),
 'rpa': array([ 0.081, -0.019, -0.048]),
 'hsp': None,
 'hpi': None}

mne.channels.read_custom_montage(r"digitisation_2x12.elc").get_positions()
Out[222]: 
{'ch_pos': OrderedDict([('D1', array([-0.00057231,  0.03152226,  0.08961177])),
              ('D2', array([0.04015963, 0.06241949, 0.05929438])),
              ('D3', array([-0.04727586,  0.06293909,  0.05319475])),
              ('D4', array([-0.00331338,  0.09458937,  0.00817049])),
              ('D5', array([ 0.01081366, -0.08489021,  0.04125154])),
              ('D6', array([ 0.05390261, -0.06076281,  0.04926388])),
              ('D7', array([-0.02814864, -0.05794644,  0.06982188])),
              ('D8', array([ 0.01293723, -0.02583681,  0.09050036])),
              ('S1', array([0.0396325 , 0.03284008, 0.07985237])),
              ('S2', array([-0.0450318 ,  0.02780224,  0.07889601])),
              ('S3', array([-0.0034188 ,  0.07493502,  0.05829283])),
              ('S4', array([0.03975299, 0.08596706, 0.00738732])),
              ('S5', array([-0.04601828,  0.08309044,  0.00182989])),
              ('S6', array([ 0.04610111, -0.0788433 ,  0.02613049])),
              ('S7', array([-0.0300463 , -0.08265368,  0.03592754])),
              ('S8', array([ 0.01218419, -0.05951276,  0.07303736])),
              ('S9', array([ 0.05802927, -0.0249934 ,  0.07094391])),
              ('S10', array([-0.0350013 , -0.02480514,  0.08476219]))]),
 'coord_frame': 'unknown',
 'nasion': array([-0.00075304,  0.06325537, -0.03238072]),
 'lpa': array([-0.06212581, -0.01355472, -0.03614592]),
 'rpa': array([ 0.06099625, -0.01430776, -0.03614592]),
 'hsp': None,
 'hpi': None}

Additional information

Platform Windows-10-10.0.19045-SP0 Python 3.12.2 | packaged by conda-forge | (main, Feb 16 2024, 20:42:31) [MSC v.1937 64 bit (AMD64)] Executable C:\Users\kdahlslatt\Anaconda3\envs\mne\python.exe CPU Intel64 Family 6 Model 158 Stepping 10, GenuineIntel (12 cores) Memory 15.7 GB

Core ├☑ mne 1.6.1 (latest release) ├☑ numpy 1.26.4 (OpenBLAS 0.3.26 with 12 threads) ├☑ scipy 1.12.0 ├☑ matplotlib 3.8.3 (backend=Qt5Agg) ├☑ pooch 1.8.1 └☑ jinja2 3.1.3

Numerical (optional) ├☑ sklearn 1.4.1.post1 ├☑ numba 0.59.1 ├☑ nibabel 5.2.1 ├☑ nilearn 0.10.3 ├☑ dipy 1.9.0 ├☑ openmeeg 2.5.7 ├☑ pandas 2.2.1 └☐ unavailable cupy

Visualization (optional) ├☑ pyvista 0.43.4 (OpenGL 4.5.0 - Build 30.0.101.1404 via Intel(R) UHD Graphics 630) ├☑ pyvistaqt 0.11.0 ├☑ vtk 9.2.6 ├☑ qtpy 2.4.1 (PyQt5=5.15.8) ├☑ pyqtgraph 0.13.4 ├☑ mne-qt-browser 0.6.2 ├☑ ipywidgets 8.1.2 ├☑ trame_client 2.16.5 ├☑ trame_server 2.17.2 ├☑ trame_vtk 2.8.5 ├☑ trame_vuetify 2.4.3 └☐ unavailable ipympl

Ecosystem (optional) ├☑ mne-nirs 0.6.0 └☐ unavailable mne-bids, mne-features, mne-connectivity, mne-icalabel, mne-bids-pipeline

larsoner commented 5 months ago

If it's from that line then I think it's expected, and if you pass head_size=None it shouldn't modify it. Can you check?

rob-luke commented 5 months ago

Thanks for sharing @kdarti , and great detailed issue. For debugging purposes, how did you acquire these files? What device was the data collected on and how was the snirf file and elc file generated? (did the manafacturer device create the files?) I will download and examine the files myself ASAP

kdarti commented 4 months ago

Thanks for sharing @kdarti , and great detailed issue. For debugging purposes, how did you acquire these files? What device was the data collected on and how was the snirf file and elc file generated? (did the manafacturer device create the files?) I will download and examine the files myself ASAP

Hi Rob, it's Kristoffer from Artinis, we just implemented export of digitised 3d positions to snirf files in our main software, so that's the origin of the snirf file. I made the .elc manually, based on the coordinates in the snirf.

If it's from that line then I think it's expected, and if you pass head_size=None it shouldn't modify it. Can you check?

Yep, with that added I do get the same behavior with the .elc using read_custom_montage() as with the coordinates from the snirf file.

However, then the positions are not really what I expect. Below is a screenshot from our software (where the positions were digitised and from where the .snirf file was exported), showing the digitised positions in our 3d vis, along with a 3d vis of the positions using read_custom_montage() + .elc (not using head_size=None). Meaning, I do get the expected positions with read_customer_montage() + .elc, but not with .snirf file.

image

larsoner commented 4 months ago

So if things are okay with read_custom_montage(..., head_size=None) but not with read_raw_snirf, then adding a head_size=None default kwarg to read_raw_snirf should fix it in theory I think right?

kdarti commented 4 months ago

So if things are okay with read_custom_montage(..., head_size=None) but not with read_raw_snirf, then adding a head_size=None default kwarg to read_raw_snirf should fix it in theory I think right?

Maybe I was a bit unclear, if I use read_custom_montage(..., head_size=None) then the positions are identical to what I get from read_raw_snirf, but these positions are not what I'd expect given the positions in the software from which the positions were exported.read_custom_montage()` is what provides the positions I'd expect.

larsoner commented 4 months ago

Okay -- if read_custom_montage(fname) is fine, then that's the same as read_custom_montage(fname, head_size=0.095) (since head_size=0.095 is the default). So then adding a head_size=None to read_raw_snirf that you could set to head_size=0.095 should fix things?

kdarti commented 3 months ago

Okay -- if read_custom_montage(fname) is fine, then that's the same as read_custom_montage(fname, head_size=0.095) (since head_size=0.095 is the default). So then adding a head_size=None to read_raw_snirf that you could set to head_size=0.095 should fix things?

Sorry, forgot to reply to this. Having a head_size parameter for read_raw_snirf that functions the same way as the same parameter for read_custom_montage() would indeed help.

Tbh, I'm not even sure what I should expect when reading the coordinates from a .snirf file, all I know is that I got a mismatch when reading identical coordinates using two different functions.

I think @rob-luke would have to say what is actually the intended result when reading the coordinates from a .snirf file.