Repository: mne-tools/mne-bids

Issue:
MRG: Two small fixes for Inspector
These two issues bugged @dnacombo


Merge checklist
---------------

Maintainer, please confirm the following before merging:

- [ ] All comments resolved
- [ ] This is not your own PR
- [ ] All CIs are happy
- [ ] PR title starts with [MRG]
- [ ] [whats_new.rst](https://github.com/mne-tools/mne-bids/blob/main/doc/whats_new.rst) is updated
- [ ] PR description includes phrase "closes <#issue-number>"

Anatomical landmarks – defined set of keys?
According to the spec, [`AnatomicalLandmarkCoordinates `](https://bids-specification.readthedocs.io/en/stable/04-modality-specific-files/01-magnetic-resonance-imaging-data.html#anatomical-landmarks) doesn't seem to enforce any sort of naming convention for the keys (landmark names); however, MNE-BIDS claims that there "MUST" be ``LPA``, ``RPA``, ``NAS``.

I have a dataset where the nasion is labeled `Nasion` instead of ``NAS``; I haven't run the bids-validator but according to the spec, this should be alright.

I suppose we should add a keyword argument to MNE-BIDS functions that work with landmarks, allowing users to supply a custom mapping of sidecar landmark names to LPA, RPA, and nasion. Or we simply make our functions more lenient, e.g. case-agnostic;  and we could accept  both `NAS` and `Nasion` for the nasion


Discussion:
@sappelhoff merge if you're happy :)
thx @hoechenberger 
# [Codecov](https://codecov.io/gh/mne-tools/mne-bids/pull/769?src=pr&el=h1) Report
> Merging [#769](https://codecov.io/gh/mne-tools/mne-bids/pull/769?src=pr&el=desc) (c3cdc93) into [main](https://codecov.io/gh/mne-tools/mne-bids/commit/29f867bb824c84b03a18cd8583d7b711660f772e?el=desc) (29f867b) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/mne-tools/mne-bids/pull/769/graphs/tree.svg?width=650&height=150&src=pr&token=x8vQyTMM9Y)](https://codecov.io/gh/mne-tools/mne-bids/pull/769?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##             main     #769   +/-   ##
=======================================
  Coverage   94.01%   94.01%           
=======================================
  Files          23       23           
  Lines        2807     2807           
=======================================
  Hits         2639     2639           
  Misses        168      168           
```


| [Impacted Files](https://codecov.io/gh/mne-tools/mne-bids/pull/769?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [mne\_bids/inspect.py](https://codecov.io/gh/mne-tools/mne-bids/pull/769/diff?src=pr&el=tree#diff-bW5lX2JpZHMvaW5zcGVjdC5weQ==) | `98.74% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/mne-tools/mne-bids/pull/769?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/mne-tools/mne-bids/pull/769?src=pr&el=footer). Last update [29f867b...c3cdc93](https://codecov.io/gh/mne-tools/mne-bids/pull/769?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

Just to provide a concrete example from the code:

https://github.com/mne-tools/mne-bids/blob/29f867bb824c84b03a18cd8583d7b711660f772e/mne_bids/read.py#L644-L649
>  I haven't run the bids-validator but according to the spec, this should be alright.

you are absolutely right, and this is another spot where more standardization would have been beneficial. (NAS LPA and RPA are so common that it's really fair enough to tell everybody to also really name it like that)
> you are absolutely right, and this is another spot where more standardization would have been beneficial. (NAS LPA and RPA are so common that it's really fair enough to tell everybody to also really name it like that)

Would it make sense to propose a "MUST name LPA, RPA, nasion: ..." for the next spec revision?
I think we can only make that an explicit recommendation to not break backwards compatibility. but that would be worth it I think.
> explicit recommendation

As in, SHOULD? 😅
yeah :roll_eyes: I don't like it either, but we need to work with what we have.
and obviously next to having a "SHOULD" it would involve adding a brief paragraph like:

> if you have nasion lpa, rpa, you SHOULD name them NAS LPA RPA ....
Yep, all good.


Resolving patch:
diff --git a/mne_bids/read.py b/mne_bids/read.py
--- a/mne_bids/read.py
+++ b/mne_bids/read.py
@@ -637,16 +637,30 @@ def get_head_mri_trans(bids_path, extra_params=None):
     with open(t1w_json_path, 'r', encoding='utf-8-sig') as f:
         t1w_json = json.load(f)
     mri_coords_dict = t1w_json.get('AnatomicalLandmarkCoordinates', dict())
-    mri_landmarks = np.asarray((mri_coords_dict.get('LPA', np.nan),
-                                mri_coords_dict.get('NAS', np.nan),
-                                mri_coords_dict.get('RPA', np.nan)))
+
+    # landmarks array: rows: [LPA, NAS, RPA]; columns: [x, y, z]
+    mri_landmarks = np.full((3, 3), np.nan)
+    for landmark_name, coords in mri_coords_dict.items():
+        if landmark_name.upper() == 'LPA':
+            mri_landmarks[0, :] = coords
+        elif landmark_name.upper() == 'RPA':
+            mri_landmarks[2, :] = coords
+        elif (landmark_name.upper() == 'NAS' or
+              landmark_name.lower() == 'nasion'):
+            mri_landmarks[1, :] = coords
+        else:
+            continue
+
     if np.isnan(mri_landmarks).any():
-        raise RuntimeError('Could not parse T1w sidecar file: "{}"\n\n'
-                           'The sidecar file MUST contain a key '
-                           '"AnatomicalLandmarkCoordinates" pointing to a '
-                           'dict with keys "LPA", "NAS", "RPA". '
-                           'Yet, the following structure was found:\n\n"{}"'
-                           .format(t1w_json_path, t1w_json))
+        raise RuntimeError(
+            f'Could not extract fiducial points from T1w sidecar file: '
+            f'{t1w_json_path}\n\n'
+            f'The sidecar file SHOULD contain a key '
+            f'"AnatomicalLandmarkCoordinates" pointing to an '
+            f'object with the keys "LPA", "NAS", and "RPA". '
+            f'Yet, the following structure was found:\n\n'
+            f'{mri_coords_dict}'
+        )
 
     # The MRI landmarks are in "voxels". We need to convert the to the
     # neuromag RAS coordinate system in order to compare the with MEG landmarks
diff --git a/mne_bids/write.py b/mne_bids/write.py
--- a/mne_bids/write.py
+++ b/mne_bids/write.py
@@ -1401,6 +1401,11 @@ def write_anat(image, bids_path, raw=None, trans=None, landmarks=None,
     transformation matrix is supplied, this information will be stored in a
     sidecar JSON file.
 
+    .. note:: To generate the JSON sidecar with anatomical landmark
+              coordinates ("fiducials"), you need to pass the landmarks via
+              the ``landmarks`` parameter, or supply a raw file via ``raw``
+              and transformation matrix via the ``trans`` parameter.
+
     Parameters
     ----------
     image : str | pathlib.Path | NibabelImageObject
@@ -1418,8 +1423,8 @@ def write_anat(image, bids_path, raw=None, trans=None, landmarks=None,
     trans : mne.transforms.Transform | str | None
         The transformation matrix from head to MRI coordinates. Can
         also be a string pointing to a ``.trans`` file containing the
-        transformation matrix. If ``None``, no sidecar JSON file will be
-        created.
+        transformation matrix. If ``None`` and no ``landmarks`` parameter is
+        passed, no sidecar JSON file will be created.
     t1w : str | pathlib.Path | NibabelImageObject | None
         This parameter is useful if image written is not already a T1 image.
         If the image written is to have a sidecar or be defaced,
@@ -1438,11 +1443,12 @@ def write_anat(image, bids_path, raw=None, trans=None, landmarks=None,
         - `theta`: is the angle of the defacing shear in degrees relative
           to vertical (default 15).
 
-    landmarks: mne.channels.DigMontage | str | None
+    landmarks : mne.channels.DigMontage | str | None
         The DigMontage or filepath to a DigMontage with landmarks that can be
         passed to provide information for defacing. Landmarks can be determined
         from the head model using `mne coreg` GUI, or they can be determined
-        from the MRI using `freeview`.
+        from the MRI using `freeview`.  If ``None`` and no ``trans`` parameter
+        is passed, no sidecar JSON file will be created.
     overwrite : bool
         Whether to overwrite existing files or data in files.
         Defaults to False.
@@ -1468,12 +1474,12 @@ def write_anat(image, bids_path, raw=None, trans=None, landmarks=None,
     write_sidecar = trans is not None or landmarks is not None
 
     if not write_sidecar and raw is not None:
-        warn('Ignoring `raw` keyword argument, `trans`, `landmarks` '
+        warn('Ignoring `raw` keyword argument: `trans`, `landmarks`, '
              'or both (if landmarks are in head space) are needed '
              'to write the sidecar file')
 
     if deface and not write_sidecar:
-        raise ValueError('Either `raw` and `trans` must be provided '
+        raise ValueError('Either `raw` and `trans` must be provided, '
                          'or `landmarks` must be provided to deface '
                          'the image')
 
@@ -1512,7 +1518,7 @@ def write_anat(image, bids_path, raw=None, trans=None, landmarks=None,
     if write_sidecar:
         # Get landmarks and their coordinate frame
         if landmarks is not None and raw is not None:
-            raise ValueError('Please use either `landmarks` or `raw`, '
+            raise ValueError('Please use EITHER `landmarks` or `raw`, '
                              'which digitization to use is ambiguous.')
 
         if trans is not None:


Test patch:
diff --git a/mne_bids/tests/test_read.py b/mne_bids/tests/test_read.py
--- a/mne_bids/tests/test_read.py
+++ b/mne_bids/tests/test_read.py
@@ -214,8 +214,8 @@ def test_get_head_mri_trans(tmpdir):
     write_raw_bids(raw, bids_path, events_data=events, event_id=event_id,
                    overwrite=False)
 
-    # We cannot recover trans, if no MRI has yet been written
-    with pytest.raises(RuntimeError):
+    # We cannot recover trans if no MRI has yet been written
+    with pytest.raises(RuntimeError, match='Did not find any T1w.json'):
         estimated_trans = get_head_mri_trans(bids_path=bids_path)
 
     # Write some MRI data and supply a `trans` so that a sidecar gets written
@@ -238,17 +238,38 @@ def test_get_head_mri_trans(tmpdir):
     assert trans['from'] == estimated_trans['from']
     assert trans['to'] == estimated_trans['to']
     assert_almost_equal(trans['trans'], estimated_trans['trans'])
-    print(trans)
-    print(estimated_trans)
 
     # provoke an error by introducing NaNs into MEG coords
+    raw.info['dig'][0]['r'] = np.full(3, np.nan)
+    sh.rmtree(anat_dir)
+    bids_path = write_anat(t1w_mgh, bids_path=t1w_bidspath,
+                           raw=raw, trans=trans)
     with pytest.raises(RuntimeError, match='AnatomicalLandmarkCoordinates'):
-        raw.info['dig'][0]['r'] = np.ones(3) * np.nan
-        sh.rmtree(anat_dir)
-        bids_path = write_anat(t1w_mgh, bids_path=t1w_bidspath,
-                               raw=raw, trans=trans, verbose=True)
         estimated_trans = get_head_mri_trans(bids_path=bids_path)
 
+    # test we are permissive for different casings of landmark names in the
+    # sidecar, and also accept "nasion" instead of just "NAS"
+    raw = _read_raw_fif(raw_fname)
+    t1w_bidspath = write_anat(t1w_mgh, bids_path=t1w_bidspath,
+                              raw=raw, trans=trans, overwrite=True)
+
+    t1w_json_fpath = t1w_bidspath.copy().update(extension='.json').fpath
+    with t1w_json_fpath.open('r', encoding='utf-8') as f:
+        t1w_json = json.load(f)
+
+    coords = t1w_json['AnatomicalLandmarkCoordinates']
+    coords['lpa'] = coords['LPA']
+    coords['Rpa'] = coords['RPA']
+    coords['Nasion'] = coords['NAS']
+    del coords['LPA'], coords['RPA'], coords['NAS']
+
+    with t1w_json_fpath.open('w', encoding='utf-8') as f:
+        json.dump(t1w_json, f)
+
+    estimated_trans = get_head_mri_trans(bids_path=(_bids_path.copy()
+                                                    .update(root=tmpdir)))
+    assert_almost_equal(trans['trans'], estimated_trans['trans'])
+
 
 def test_handle_events_reading(tmpdir):
     """Test reading events from a BIDS events.tsv file."""
diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py
--- a/mne_bids/tests/test_write.py
+++ b/mne_bids/tests/test_write.py
@@ -1654,7 +1654,7 @@ def test_write_anat(_bids_validate, tmpdir):
 
         if not in_head:
             # crash for raw also
-            with pytest.raises(ValueError, match='use either `landmarks`'):
+            with pytest.raises(ValueError, match='use EITHER `landmarks`'):
                 write_anat(t1w_mgh, bids_path=bids_path, raw=raw,
                            trans=trans, deface=True, landmarks=landmarks,
                            verbose=True, overwrite=True)