Repository: nilearn/nilearn

Issue:
[BUG] `save_glm_to_bids` fails when the model is constructed with 'non-serializable' parameters
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Operating system

- [X] Linux
- [ ] Mac
- [ ] Windows

### Operating system version

For example one of the following:
- Linux Ubuntu 22.04


### Python version

- [ ] 3.12
- [X] 3.11
- [ ] 3.10
- [ ] 3.9
- [ ] 3.8

### nilearn version

dev

### Expected behavior

Originally reported in https://neurostars.org/t/save-glm-to-bids-troubleshoot-and-post-report-contrast-edits/28567

Would expect all the GLM parameters to be cast into serializable format.

### Current behavior & error messages

This is what I got:

```bash
/home/remi/github/nilearn/nilearn/nilearn/interfaces/bids/glm.py:52: UserWarning: Contrast name "effects of interest" changed to "effectsOfInterest"
  warnings.warn(
Traceback (most recent call last):
  File "/home/remi/github/nilearn/nilearn/tmp.py", line 41, in <module>
    save_glm_to_bids(
  File "/home/remi/github/nilearn/nilearn/nilearn/interfaces/bids/glm.py", line 325, in save_glm_to_bids
    _generate_model_metadata(metadata_file, model)
  File "/home/remi/github/nilearn/nilearn/nilearn/interfaces/bids/glm.py", line 124, in _generate_model_metadata
    json.dump(model_metadata, f_obj, indent=4, sort_keys=True)
  File "/home/remi/miniconda3/lib/python3.11/json/__init__.py", line 179, in dump
    for chunk in iterable:
  File "/home/remi/miniconda3/lib/python3.11/json/encoder.py", line 432, in _iterencode
    yield from _iterencode_dict(o, _current_indent_level)
  File "/home/remi/miniconda3/lib/python3.11/json/encoder.py", line 406, in _iterencode_dict
    yield from chunks
  File "/home/remi/miniconda3/lib/python3.11/json/encoder.py", line 406, in _iterencode_dict
    yield from chunks
  File "/home/remi/miniconda3/lib/python3.11/json/encoder.py", line 439, in _iterencode
    o = _default(o)
        ^^^^^^^^^^^
  File "/home/remi/miniconda3/lib/python3.11/json/encoder.py", line 180, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type ndarray is not JSON serializable
```

### Steps and code to reproduce bug

Passing a resampling affine to first level model constructor leads to an error when trying to save the GLM outputs to BIDS.

Numpy arrays passed as parameters to the constructor and would need to be cast into a serializable format before saving.

```python
import numpy as np
from pathlib import Path

from nilearn._utils.data_gen import (
    generate_fake_fmri_data_and_design,
)
from nilearn.glm.first_level import FirstLevelModel
from nilearn.interfaces.bids import (
    save_glm_to_bids,
)

shapes, rk = [(7, 8, 9, 15)], 3
mask, fmri_data, design_matrices = generate_fake_fmri_data_and_design(
    shapes,
    rk,
)

# To try different values
# target_affine = None
# target_affine = fmri_data[0].affine
target_affine = mask.affine

# Call with verbose (improve coverage)
single_run_model = FirstLevelModel(
    mask_img=None,
    minimize_memory=False,
    target_affine=target_affine
).fit(
    fmri_data[0],
    design_matrices=design_matrices[0],
)

contrasts = {
    "effects of interest": np.eye(rk),
}
contrast_types = {
    "effects of interest": "F",
}
save_glm_to_bids(
    model=single_run_model,
    contrasts=contrasts,
    contrast_types=contrast_types,
    out_dir=Path.cwd() / "tmp",
    prefix="sub-01_ses-01_task-nback",
)
```



Discussion:

Resolving patch:
diff --git a/nilearn/interfaces/bids/glm.py b/nilearn/interfaces/bids/glm.py
--- a/nilearn/interfaces/bids/glm.py
+++ b/nilearn/interfaces/bids/glm.py
@@ -114,6 +114,10 @@ def _generate_model_metadata(out_file, model):
         ATTRIBUTE_RENAMING.get(k, k): v for k, v in model_attributes.items()
     }
 
+    for key, value in model_attributes.items():
+        if isinstance(value, (np.ndarray)):
+            model_attributes[key] = value.tolist()
+
     model_metadata = {
         "Description": "A statistical map generated by Nilearn.",
         **data_attributes,


Test patch:
diff --git a/nilearn/interfaces/tests/test_bids.py b/nilearn/interfaces/tests/test_bids.py
--- a/nilearn/interfaces/tests/test_bids.py
+++ b/nilearn/interfaces/tests/test_bids.py
@@ -342,15 +342,11 @@ def test_save_glm_to_bids(tmp_path_factory):
     ]
 
     shapes, rk = [(7, 8, 9, 15)], 3
-    mask, fmri_data, design_matrices = generate_fake_fmri_data_and_design(
+    _, fmri_data, design_matrices = generate_fake_fmri_data_and_design(
         shapes,
         rk,
     )
 
-    masker = NiftiMasker(mask)
-    masker.fit()
-
-    # Call with verbose (improve coverage)
     single_run_model = FirstLevelModel(
         mask_img=None,
         minimize_memory=False,
@@ -378,6 +374,40 @@ def test_save_glm_to_bids(tmp_path_factory):
         assert os.path.isfile(full_filename)
 
 
+def test_save_glm_to_bids_serialize_affine(tmp_path):
+    """Test that affines are turned into a serializable type.
+
+    Regression test for https://github.com/nilearn/nilearn/issues/4324.
+    """
+    shapes, rk = [(7, 8, 9, 15)], 3
+    mask, fmri_data, design_matrices = generate_fake_fmri_data_and_design(
+        shapes,
+        rk,
+    )
+
+    target_affine = mask.affine
+
+    single_run_model = FirstLevelModel(
+        target_affine=target_affine,
+        minimize_memory=False,
+    ).fit(
+        fmri_data[0],
+        design_matrices=design_matrices[0],
+    )
+
+    save_glm_to_bids(
+        model=single_run_model,
+        contrasts={
+            "effects of interest": np.eye(rk),
+        },
+        contrast_types={
+            "effects of interest": "F",
+        },
+        out_dir=tmp_path,
+        prefix="sub-01_ses-01_task-nback",
+    )
+
+
 @pytest.fixture
 def nb_cols_design_matrix():
     return 3