Skip to content

Conversation

@rob-luke
Copy link
Member

@rob-luke rob-luke commented Mar 18, 2021

Reference issue

Add montage for Artinis OctaMon and Brite23 Fixes #9093

What does this implement/fix?

  1. This adds support for fNIRS data to set_montage
  2. Add a new standard montage for the artinis brand device as used in @HanBnrd's lab

Additional information

This PR has had feature creep and now contains a suggestion for importing data as csv files, to support the reading of unstructured data this PR now contains new code to check the naming of channels, etc. I have suggested reverting this PR back to its original goal of adding fNIRS support to .set_montage() and providing two sample montage files.

@@ -0,0 +1,58 @@
MNI Coordinates
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we are sure the elc file is correct we can delete this. Must be done before merge.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For those playing along at home...

In MNE we use the terminology source (S) and detector (D). These artinis files use the terminology transmitter (Tx) and receiver (Rx).

@@ -0,0 +1,35 @@
MNI Coordinates
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we are sure the elc file is correct we can delete these. Must be done before merge.

@rob-luke
Copy link
Member Author

Hi @HanBnrd,

I cant make the sprint in your time zone today so I have tidied up our fork a little so I could share my thoughts with you. I have added a temporary change to one of the docs so we can discuss some questions I had. You can view the render of the doc change here: https://26156-1301584-gh.circle-artifacts.com/0/dev/auto_tutorials/io/plot_30_reading_fnirs_data.html#view-location-of-sensors-over-brain-surface

Now to my questions to you...

Is this the API/workflow that you were thinking about?

We have ended up with this workflow...

raw_intensity = load_some_data()
montage = make_standard_montage("artinis-octamon")
raw_intensity.set_montage(montage)
# ... processing as usual

will this work for you? If not, what's an alternative API we could use?

Locations look wrong

Currently the sources and detectors in 3d look like...
image
From my understanding the sources and detectors should be over the frontal cortex, and the arrangement should be in a dual cross configuration. So this looks close (its frontal and two nice distinct crosses), but its getting squished in to the brain.

I think we have a coordinate system mismatch going on somewhere, either in the raw files or in the loading, storage, or plotting.

Could you dig in to this a bit further? If we can get the sensor positions plotting correctly we can then go back and clean up our functions, make them more generic, add tests etc. @larsoner has helped me with coord system issues before, but he isn't on your timezone either. You might need to ask around in the available mentors room for someone who can help.

@HanBnrd
Copy link
Contributor

HanBnrd commented Mar 18, 2021

Hi @rob-luke,

Is this the API/workflow that you were thinking about?

Yes the API looks good to me :)

Locations look wrong

At the moment I'm having a look at info['dig'] to see if it may come from that, because at the moment we just modify the info['chs'][i]['loc'].
Having a look at _set_montage() it is updating in place 3 things as far as I understand:

  • info['chs'][i]['loc']
  • info['dig']
  • info['dev_head_t'] (which seems to be only useful for MEG so not our case)

@rob-luke
Copy link
Member Author

rob-luke commented Mar 18, 2021

For reference here is a picture of the location of optodes as provided by the vendor.
BF67E0A2-6D2F-4B9C-8F13-E3B4FFBA7D6A

@HanBnrd
Copy link
Contributor

HanBnrd commented Mar 18, 2021

It's probably not an issue related to info['dig'] but I guess it's better to update it as well.


fnirs_picks = _picks_to_idx(info, 'fnirs', allow_empty=True)
if len(fnirs_picks) > 0:
info = _set_montage_fnirs(info, montage)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid the pain of the next person errantly using montage instead of mnt_head, after conversion above we could del montage so that it's no longer in the namespace as a strong hint that the montage-in-head should be used by the subsequent code.

@HanBnrd
Copy link
Contributor

HanBnrd commented Mar 18, 2021

This is how it looks now with the fix thanks to @larsoner's help :)
artinis

@rob-luke
Copy link
Member Author

Nice! I had a feeling it was the coord frame 🤔 Now that things are correct we just need to make the code nice and add tests, docs etc.

But before that, can you try this on your local data and confirm it helps to load your labs files, that's why we are doing this :)

@HanBnrd
Copy link
Contributor

HanBnrd commented Mar 18, 2021

Now that things are correct we just need to make the code nice and add tests, docs etc.
But before that, can you try this on your local data and confirm it helps to load your labs files, that's why we are doing this :)

Yes, I'll do that tomorrow :)

@HanBnrd
Copy link
Contributor

HanBnrd commented Mar 18, 2021

@HanBnrd
Copy link
Contributor

HanBnrd commented Mar 19, 2021

Hi @rob-luke,

So it works with data from the actual OctaMon. I used a file from an experiment where I exported data in real time from OxySoft (Artinis' software) into a CSV thanks to TCP.
See the experiment interface here.

import mne
import numpy as np

from mne.channels import make_standard_montage
from mne.channels.tests.test_standard_montage import _simulate_artinis_octamon


data = []
f = open(PATH + 'raw.csv', 'r', encoding='utf8')
f.readline()  # skip headers
for line in f:
    data.append(line.split('\n')[0].split(',')[1:])  # first col is timestamp
data = np.array(data, dtype='float64')
data = data * 1e-6  # uM to M
data = np.swapaxes(data, 0, 1)

ch_names = ['D1_S1 hbo', 'D1_S1 hbr', 'D1_S2 hbo', 'D1_S2 hbr',
            'D1_S3 hbo', 'D1_S3 hbr', 'D1_S4 hbo', 'D1_S4 hbr',
            'D2_S5 hbo', 'D2_S5 hbr', 'D2_S6 hbo', 'D2_S6 hbr',
            'D2_S7 hbo', 'D2_S7 hbr', 'D2_S8 hbo', 'D2_S8 hbr']
ch_types = ['hbo', 'hbr', 'hbo', 'hbr',
            'hbo', 'hbr', 'hbo', 'hbr',
            'hbo', 'hbr', 'hbo', 'hbr',
            'hbo', 'hbr', 'hbo', 'hbr']
sfreq = 10.  # Hz
info = mne.create_info(ch_names=ch_names, ch_types=ch_types, sfreq=sfreq)
raw = mne.io.RawArray(data, info, verbose=True)

raw.set_montage('artinis-octamon')

raw.plot_sensors()

subjects_dir = mne.datasets.sample.data_path() + '/subjects'
mne.datasets.fetch_fsaverage(subjects_dir=subjects_dir, verbose=True)

fig = mne.viz.create_3d_figure(size=(800, 600), bgcolor='white')
fig = mne.viz.plot_alignment(raw.info, show_axes=True,
                             subject='fsaverage', coord_frame='mri',
                             trans='fsaverage', surfaces=['brain', 'head'],
                             fnirs=['channels', 'pairs',
                                    'sources', 'detectors'],
                             dig=True, mri_fiducials=True,
                             subjects_dir=subjects_dir, fig=fig)
mne.viz.set_3d_view(figure=fig, azimuth=70, elevation=100, distance=0.4,
                    focalpoint=(0., -0.01, 0.02))

Unfortunately I cannot share the data used here with the above code.

Also, once there are loaders for oxy3 and oxy4 files (the proprietary format) it'll be more straight forward hopefully.
2d
3d

I'll try to see if I can make some artificial evoked topo like this.

@HanBnrd
Copy link
Contributor

HanBnrd commented Mar 19, 2021

epochs = mne.make_fixed_length_epochs(raw, duration=5.)
epochs.baseline = (None, 1)
times = np.arange(0., 4.9, 1.)
topomap_args = dict(extrapolate='local')
epochs.average(picks='hbo').plot_joint(times=times, topomap_args=topomap_args)

Evoked plot working fine :)
image

@HanBnrd
Copy link
Contributor

HanBnrd commented Mar 19, 2021

Just so we have this somewhere:
The vendor confirmed that the montages are for adult's head.

Copy link
Member Author

@rob-luke rob-luke left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are to demonstrate how to generate raw and info from unsuported data, should we somewhere put in a description of the mandatory formatting? I described the mandatory rules at d42951d

And I assume the issue with the missaligned brite23 montage is fixed?

And I cant see a render of the docs in the jobs list, is it missing or have I lost my mind?

_check_channels_ordered)
# Modify info['chs'][#]['loc'] in place
num_ficiduals = len(montage.dig) - len(montage.ch_names)
freqs = np.unique(_channel_frequencies(info))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
freqs = np.unique(_channel_frequencies(info))
# First we must validate that the fNIRS info is correctly formatted
freqs = np.unique(_channel_frequencies(info))


for name, use in zip(info_names, info_names_use):
_loc_view = info['chs'][info['ch_names'].index(name)]['loc']
# XXX info['chs'][#]['loc'] modified in place
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to a change we made?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not directly related but I wrote those comments to pinpoint all the lines where the in-place modifications were happening as it wasn't always obvious at first glance for example for this line 907.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I remove them or is it okay?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

such comments are useful, but it would be clearer if it didn't start with XXX as this is usually a signal for "something to be fixed later / eventually". Can you change them to say "next line modifies info['chs'][#]['loc'] in-place" or similar?

# in the old dig
if ref_dig_point in old_dig:
digpoints.append(ref_dig_point)
# XXX info['dig'] modified in place
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above

info = _set_montage_fnirs(info, mnt_head)

else: # None case
# XXX info['dig'] modified in place
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above

Comment on lines 75 to 76
by MNE and cannot be converted easily into SNIRF. However if it is possible to
store this data in a legacy csv or tsv format, we show here a way to load it
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
by MNE and cannot be converted easily into SNIRF. However if it is possible to
store this data in a legacy csv or tsv format, we show here a way to load it
by MNE and cannot be converted easily into SNIRF. However if it is possible to
This legacy data is often in csv or tsv format, we show here a way to load it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this suggestion leaves the previous sentence beginning dangling... be sure to remove the "however if it is possible to" part

standardisation of the file format (the naming and ordering of channels, the
type and scaling of data, and specification of sensor positions varies between
each vendor). You will likely have to adapt this depending on the system from
which your CSV originated.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sometimes its csv, other times CSV. which is correct?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All changed to capital letters as they are acronyms.

HanBnrd and others added 3 commits April 22, 2021 09:10
Co-authored-by: Robert Luke <748691+rob-luke@users.noreply.github.com>
@HanBnrd
Copy link
Contributor

HanBnrd commented Apr 22, 2021

Hi @rob-luke, thanks for reviewing.

If we are to demonstrate how to generate raw and info from unsuported data, should we somewhere put in a description of the mandatory formatting? I described the mandatory rules at d42951d

It was in an in-line comment but I just made it a warning now.

And I assume the issue with the missaligned brite23 montage is fixed?

Yes it is solved by not using trans='fsaverage' with Artinis montages (cf. this comment).

And I cant see a render of the docs in the jobs list, is it missing or have I lost my mind?

I think it was because of the checks timing out, hopefully this will work now. Will paste the link here when it's built.

@rob-luke
Copy link
Member Author

Ok your turn @larsoner 😃

@HanBnrd
Copy link
Contributor

HanBnrd commented Apr 22, 2021

And I cant see a render of the docs in the jobs list, is it missing or have I lost my mind?

I think it was because of the checks timing out, hopefully this will work now. Will paste the link here when it's built.

Actually CircleCI is not running it seems, I'm not sure where to find the built docs.

@HanBnrd
Copy link
Contributor

HanBnrd commented Apr 22, 2021

Hi @rob-luke, an important point related to #9141 (comment): I just realised that it is actually possible to read any elc file (see #9309 (comment)) and our PR will make it work for fNIRS too:

montage = read_custom_montage('path/to/artinis-brite23.elc', coord_frame='mri')
raw.set_montage(montage)

(This is probably what has been tried in #8149)

We just need to have coord_frame='mri' so that we can compute the head_t later for plot_alignment (because plot_alignment cannot use 'fsaverge' otherwise it's shifted):

trans = compute_native_head_t(montage)

Should we write something about this in the example?

I think you can add this in the PR description, and also that our PR will close #8149.

@drammock
Copy link
Member

I'm not sure why CircleCI isn't running here. The first thing I'd try is resovling the merge conflict on latest.inc and see if that triggers it to run.

@larsoner
Copy link
Member

This recently happened to @cbrnr , I think because he revoked some permissions for CircleCI on his user (?). You could look at your settings.

Also see if you can view https://app.circleci.com/pipelines/github/mne-tools/mne-python/7826/workflows/c965a22a-19cf-402a-b92f-633247c4b948/jobs/27243, I hope it asks you to log in or for permission, and then you can view it. This might fix things

@HanBnrd
Copy link
Contributor

HanBnrd commented Apr 22, 2021

Docs:

Copy link
Member

@drammock drammock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is looking very good @HanBnrd. Just 2 comments.



###############################################################################
# Storing of optode locations
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole section seems almost like a "note" that applies to all the other sections. Maybe this info belongs at the very top of the tutorial, before the NIRx section?

# We simulate 16 channels with 100 samples of data and save this to a file
# called fnirs.csv.

pd.DataFrame(np.random.normal(size=(16, 100))).to_csv("fnirs.csv")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it is really necessary to write out the CSV and then re-load it? Would it be just as good to say something like "here we'll make some fake data using pd.DataFrame; in practice you'd use pd.read_csv instead to load your file"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that some users won't know how to read a csv. And this will demonstrate it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair enough.

@larsoner
Copy link
Member

@HanBnrd please click "Ready for review" if this is good to go, and let me know

It sounds like @rob-luke and @drammock are happy so if it is good to go I'll give it one more look and hopefully merge!

@larsoner larsoner added this to the 0.23 milestone Apr 23, 2021
@rob-luke rob-luke marked this pull request as ready for review April 23, 2021 14:25
@HanBnrd
Copy link
Contributor

HanBnrd commented Apr 23, 2021

I just pushed one more commit implementing @drammock's suggestion and @rob-luke's comment.

Here is the new tutorial: https://27748-1301584-gh.circle-artifacts.com/0/dev/auto_tutorials/io/30_reading_fnirs_data.html

That's ready for review now @larsoner.

@larsoner larsoner merged commit e4a1571 into mne-tools:main Apr 23, 2021
@larsoner
Copy link
Member

Great, thanks @HanBnrd @rob-luke !

@HanBnrd
Copy link
Contributor

HanBnrd commented Apr 23, 2021

Thanks everybody! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ENH: Add montage for Artinis OctaMon and Brite23

4 participants