diff --git a/doc/changes/dev/14055.newfeature.rst b/doc/changes/dev/14055.newfeature.rst new file mode 100644 index 00000000000..6d6ad2e9eec --- /dev/null +++ b/doc/changes/dev/14055.newfeature.rst @@ -0,0 +1 @@ +Added support for newer recording software in :func:`mne.io.read_raw_nirx`, by `Emma Bailey`_. \ No newline at end of file diff --git a/doc/changes/names.inc b/doc/changes/names.inc index 9c337b3d5c1..114cfb6999e 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -90,7 +90,7 @@ .. _Eberhard Eich: https://github.com/ebeich .. _Eduard Ort: https://github.com/eort .. _Emily Stephen: https://github.com/emilyps14 -.. _Emma Bailey: https://www.cbs.mpg.de/employees/bailey +.. _Emma Bailey: https://github.com/emma-bailey .. _Emma Zhang: https://portfolio-production-ed03.up.railway.app/ .. _Emmanuel Ferdman: https://github.com/emmanuel-ferdman .. _Emrecan Çelik: https://github.com/emrecncelik diff --git a/mne/io/nirx/nirx.py b/mne/io/nirx/nirx.py index 905d810e64f..08b0527451a 100644 --- a/mne/io/nirx/nirx.py +++ b/mne/io/nirx/nirx.py @@ -119,8 +119,6 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No "description.json", "wl1", "wl2", - "probeInfo.mat", - "tri", ) else: # NIRScout devices and NIRSport1 devices @@ -132,7 +130,6 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No "wl1", "wl2", "config.txt", - "probeInfo.mat", ) n_dat = len(glob.glob(f"{fname}/*{'dat'}")) if n_dat != 1: @@ -175,6 +172,21 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No nan_mask[key] = files[key][0 if noidx == 1 else 1] files[key] = files[key][fidx] + # Locate probeInfo file — either .mat (legacy) or .json (newer Aurora) + probe_mat = glob.glob(f"{fname}/*probeInfo.mat") + probe_json = glob.glob(f"{fname}/*probeInfo.json") + if len(probe_json) == 1: + files["probeInfo"] = probe_json[0] + probe_format = "json" + elif len(probe_mat) == 1: + files["probeInfo"] = probe_mat[0] + probe_format = "mat" + else: + raise RuntimeError( + f"Need one probeInfo.mat or probeInfo.json file, " + f"got {len(probe_mat)} .mat and {len(probe_json)} .json" + ) + # Read number of rows/samples of wavelength data with _open(files["wl1"]) as fid: last_sample = fid.read().count("\n") - 1 @@ -197,6 +209,7 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No "2021.4.0-34-ge9fdbbc8", "2021.9.0-5-g3eb32851", "2021.9.0-6-g14ef4a71", + "2025.2.0-17-geea6971f", ]: warn( "MNE has not been tested with Aurora version " @@ -361,12 +374,28 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No # Sources and detectors are both called optodes # Each source - detector pair produces a channel # Channels are defined as the midpoint between source and detector - mat_data = loadmat(files["probeInfo.mat"]) - probes = mat_data["probeInfo"]["probes"][0, 0] - requested_channels = probes["index_c"][0, 0] - src_locs = probes["coords_s3"][0, 0] / 100.0 - det_locs = probes["coords_d3"][0, 0] / 100.0 - ch_locs = probes["coords_c3"][0, 0] / 100.0 + if probe_format == "json": + with open(files["probeInfo"], encoding="utf-8") as _f: + _probe_json = json.load(_f) + probes = _probe_json["probeInfo"]["probes"] + requested_channels = np.array(probes["index_c"]) + src_locs = np.array(probes["coords_s3"]) / 1000.0 + det_locs = np.array(probes["coords_d3"]) / 1000.0 + ch_locs = np.array(probes["coords_c3"]) / 1000.0 + if _probe_json["probeInfo"]["head_circumference"] != 610.0: + warn( + "The head circumference in the probeInfo.json file is " + f"{_probe_json['probeInfo']['head_circumference']} mm, " + "as opposed to standard 610.0 mm. This may cause misalignment of " + "the optode locations with the subject's head in later steps." + ) + else: + mat_data = loadmat(files["probeInfo"]) + probes = mat_data["probeInfo"]["probes"][0, 0] + requested_channels = probes["index_c"][0, 0] + src_locs = probes["coords_s3"][0, 0] / 100.0 + det_locs = probes["coords_d3"][0, 0] / 100.0 + ch_locs = probes["coords_c3"][0, 0] / 100.0 # These are all in MNI coordinates, so let's transform them to # the Neuromag head coordinate frame @@ -425,11 +454,11 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No # The detector location is stored in the third 3 entries of loc. # NIRx NIRSite uses MNI coordinates. # Also encode the light frequency in the structure. - for ch_idx2 in range(requested_channels.shape[0]): + for ch_idx2, wl_idx in enumerate(req_ind): # Find source and store location - src = int(requested_channels[ch_idx2, 0]) - 1 + src = int(sources[wl_idx]) - 1 # Find detector and store location - det = int(requested_channels[ch_idx2, 1]) - 1 + det = int(detectors[wl_idx]) - 1 # Store channel location as midpoint between source and detector. midpoint = (src_locs[src, :] + det_locs[det, :]) / 2 for ii in range(2): @@ -508,6 +537,9 @@ def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=No # Read triggers from event file if not is_aurora: files["tri"] = files["hdr"][:-3] + "evt" + else: + tri_files = glob.glob(f"{fname}/*.tri") + files["tri"] = tri_files[0] if len(tri_files) == 1 else "" if op.isfile(files["tri"]): with _open(files["tri"]) as fid: t = [re.findall(r"(\d+)", line) for line in fid] diff --git a/mne/io/nirx/tests/test_nirx.py b/mne/io/nirx/tests/test_nirx.py index e9a5a117480..3ba2a925139 100644 --- a/mne/io/nirx/tests/test_nirx.py +++ b/mne/io/nirx/tests/test_nirx.py @@ -55,6 +55,7 @@ nirsport2 = testing_path / "NIRx" / "nirsport_v2" / "aurora_recording _w_short_and_acc" nirsport2_2021_9 = testing_path / "NIRx" / "nirsport_v2" / "aurora_2021_9" nirsport2_2021_9_6 = testing_path / "NIRx" / "nirsport_v2" / "aurora_2021_9_6" +nirsport2_2025_2 = testing_path / "NIRx" / "nirsport_v2" / "aurora_2025_2" def test_nirsport_v2_matches_snirf(nirx_snirf): @@ -551,6 +552,41 @@ def test_nirx_aurora_2021_9_6(): assert raw.annotations.description[2] == "3.0" +@requires_testing_data +@pytest.mark.filterwarnings("ignore:.*Extraction of measurement.*:") +def test_nirx_aurora_2025_2(): + """Test reading Aurora dataset with probeInfo.json.""" + raw = read_raw_nirx(nirsport2_2025_2, preload=True) + + # 50 channels × 2 wavelengths + assert raw._data.shape[0] == 100 + + # Test triggers + assert len(raw.annotations) == 10 + assert (raw.annotations.description == "1.0").sum() == 5 + assert (raw.annotations.description == "2.0").sum() == 5 + + # Test detector locations derived from probeInfo.json (MNI coords in mm) + allowed_dist_error = 0.0002 + locs = [ch["loc"][6:9] for ch in raw.info["chs"]] + head_mri_t, _ = _get_trans("fsaverage", "head", "mri") + mni_locs = apply_trans(head_mri_t, locs) + + assert raw.info["ch_names"][0][3:5] == "D1" + assert_allclose( + mni_locs[0], [-0.035971, 0.027644, 0.077821], atol=allowed_dist_error + ) + + # Test source locations + locs = [ch["loc"][3:6] for ch in raw.info["chs"]] + mni_locs = apply_trans(head_mri_t, locs) + + assert raw.info["ch_names"][0][:2] == "S1" + assert_allclose( + mni_locs[0], [-0.02948, 0.060438, 0.057351], atol=allowed_dist_error + ) + + @requires_testing_data def test_nirx_15_0(): """Test reading NIRX files."""