Repository: nilearn/nilearn

Issue:
math_img loses header information
In nilearn 0.7.0, using math_img to get the first 180 dynamics of an image. 

```
img = math_img("img[:,:,:,0:180]",img=img)
``` 
Then I save the image and look at the header with fslhd.

new header has pixdim4 as 1:

```
size of header	348
data_type	FLOAT64
dim0		4
dim1		144
dim2		144
dim3		41
dim4		180
dim5		1
dim6		1
dim7		1
vox_units	Unknown
time_units	Unknown
datatype	64
nbyper		8
bitpix		64
pixdim0		-1.000000
pixdim1		1.647059
pixdim2		1.647059
pixdim3		3.000000
pixdim4		1.000000
```

Original header here has pixdim4 as 2:

```
size of header	348
data_type	INT16
dim0		4
dim1		144
dim2		144
dim3		41
dim4		360
dim5		1
dim6		1
dim7		1
vox_units	mm
time_units	s
datatype	4
nbyper		2
bitpix		16
pixdim0		-1.000000
pixdim1		1.647059
pixdim2		1.647059
pixdim3		3.000000
pixdim4		2.000001
```


I'm using nibabel to do this now, but this might need fixing. 




Discussion:
Indeed, thx. Do you want to open a PR to address this ?

What would be the expected behavior when two images with different header information are used? The easiest example I can come up with would be adding two 4D images with different TRs. Would we want to raise an exception in that case?
I would expect the same behavior as if the affine or any other parameter was different.
Looking at this, I could reproduce the issue in pure-python: 

```python
from nilearn import image
from nilearn import datasets
from pathlib import Path

# Get example data
haxby_data = datasets.fetch_haxby()
data_root = Path(datasets.get_data_dirs()[0])
haxby_data_dir = data_root / "haxby2001" / "subj2"

# Load functional data
img = image.load_img(haxby_data_dir / "bold.nii.gz")
# slice func image using math_img
sliced_img = image.math_img("img[..., 0:180]", img=img)

print(img.header["pixdim"])
# returns array([1.  , 3.5 , 3.75, 3.75, 2.5 , 0.  , 0.  , 0.  ], dtype=float32)

print(sliced_img.header["pixdim"])
# returns array([-1.  ,  3.5 ,  3.75,  3.75,  1.  ,  1.  ,  1.  ,  1.  ], dtype=float32)
```

I think the issue lies in [`nilearn.image.math_img`#L1071](https://github.com/nilearn/nilearn/blob/a950195e83d6f9af64fa3166369427b8a58d1066/nilearn/image/image.py#L1071) as the default for `copy_header` is `False` in `new_img_like`, and we could simply set it to `True`.

We would also need to check for another use case where one could do math_img over two images with the same affine but different TRs. As discussed above, we want to throw a similar error as when the affines are different.

However, when I wanted to test that I found that **the same issue exists with resampling functions as they don't copy the header and the TR is not carried over**. Could be a separate issue if doesn't already exist.




Resolving patch:
diff --git a/nilearn/image/image.py b/nilearn/image/image.py
--- a/nilearn/image/image.py
+++ b/nilearn/image/image.py
@@ -989,7 +989,7 @@ def threshold_img(
     return thresholded_img
 
 
-def math_img(formula, **imgs):
+def math_img(formula, copy_header_from=None, **imgs):
     """Interpret a numpy based string formula using niimg in named parameters.
 
     .. versionadded:: 0.2.3
@@ -1000,6 +1000,15 @@ def math_img(formula, **imgs):
         The mathematical formula to apply to image internal data. It can use
         numpy imported as 'np'.
 
+    copy_header_from : :obj:`str`, default=None
+        Takes the variable name of one of the images in the formula.
+        The header of this image will be copied to the result of the formula.
+        Note that the result image and the image to copy the header from,
+        should have the same number of dimensions. If None, the default
+        :class:`~nibabel.nifti1.Nifti1Header` is used.
+
+        .. versionadded:: 0.10.4.dev
+
     imgs : images (:class:`~nibabel.nifti1.Nifti1Image` or file names)
         Keyword arguments corresponding to the variables in the formula as
         Nifti images. All input images should have the same geometry (shape,
@@ -1033,6 +1042,19 @@ def math_img(formula, **imgs):
      >>> result_img = math_img("img1 + img2",
      ...                       img1=anatomical_image, img2=log_img)
 
+    The result image will have the same shape and affine as the input images;
+    but might have different header information, specifically the TR value,
+    see :gh:`2645`.
+
+    .. versionadded:: 0.10.4.dev
+
+    We can also copy the header from one of the input images using
+    ``copy_header_from``::
+
+     >>> result_img_with_header = math_img("img1 + img2",
+     ...                                   img1=anatomical_image, img2=log_img,
+     ...                                   copy_header_from="img1")
+
     Notes
     -----
     This function is the Python equivalent of ImCal in SPM or fslmaths
@@ -1068,7 +1090,20 @@ def math_img(formula, **imgs):
         ) + exc.args
         raise
 
-    return new_img_like(niimg, result, niimg.affine)
+    # check whether to copy header from one of the input images
+    if copy_header_from is not None:
+        niimg = check_niimg(imgs[copy_header_from])
+        # only copy the header if the result and the input image to copy the
+        # header from have the same shape
+        if result.ndim != niimg.ndim:
+            raise ValueError(
+                "Cannot copy the header. "
+                "The result of the formula has a different number of "
+                "dimensions than the image to copy the header from."
+            )
+        return new_img_like(niimg, result, niimg.affine, copy_header=True)
+    else:
+        return new_img_like(niimg, result, niimg.affine)
 
 
 def binarize_img(img, threshold=0, mask_img=None, two_sided=True):


Test patch:
diff --git a/nilearn/image/tests/conftest.py b/nilearn/image/tests/conftest.py
new file mode 100644
--- /dev/null
+++ b/nilearn/image/tests/conftest.py
@@ -0,0 +1,55 @@
+"""Pytest fixtures for testing copied headers in nilearn.image functions."""
+
+import pytest
+from nibabel.nifti1 import Nifti1Image
+
+from nilearn import image
+
+
+@pytest.fixture
+def img_4d_ones_eye_default_header(img_4d_ones_eye):
+    """Return a 4D Nifti1Image with default header.
+
+    The header is created by new_img_like and is not modified. The image is
+    filled with ones and has an identity affine.
+    """
+    img = image.new_img_like(
+        img_4d_ones_eye,
+        data=img_4d_ones_eye.get_fdata(),
+        copy_header=False,
+    )
+    return img
+
+
+@pytest.fixture
+def img_4d_ones_eye_tr2(img_4d_ones_eye):
+    """Return a 4D Nifti1Image with otherwise default header, except TR 2.0.
+
+    The header is the default one created by new_img_like, but the TR is
+    changed to 2.0. The image is filled with ones and has an identity affine.
+    """
+    img = image.new_img_like(
+        img_4d_ones_eye,
+        data=img_4d_ones_eye.get_fdata(),
+        copy_header=True,
+    )
+    # Change the TR
+    header = img.header.copy()
+    header["pixdim"][4] = 2.0
+    return Nifti1Image(img.get_fdata(), img.affine, header=header)
+
+
+@pytest.fixture
+def img_4d_mni_tr2(img_4d_mni):
+    """Return a 4D Nifti1Image with MNI affine and header, and TR 2.0.
+
+    The header has the MNI affine, and the TR is changed to 2.0. The image is
+    filled with random numbers.
+    """
+    img = image.new_img_like(
+        img_4d_mni, data=img_4d_mni.get_fdata(), copy_header=True
+    )
+    # Change the TR
+    header = img.header.copy()
+    header["pixdim"][4] = 2.0
+    return Nifti1Image(img.get_fdata(), img.affine, header=header)
diff --git a/nilearn/image/tests/test_image.py b/nilearn/image/tests/test_image.py
--- a/nilearn/image/tests/test_image.py
+++ b/nilearn/image/tests/test_image.py
@@ -818,6 +818,18 @@ def test_math_img_exceptions(affine_eye, img_4d_ones_eye):
     ):
         math_img(bad_formula, img1=img1, img3=img3)
 
+    # Test copy_header_from parameter
+    # Copying header from 4d image to a result that is 3d should raise a
+    # ValueError
+    formula = "np.mean(img1, axis=-1) - np.mean(img3, axis=-1)"
+    with pytest.raises(ValueError, match="Cannot copy the header."):
+        math_img(formula, img1=img1, img3=img3, copy_header_from="img1")
+
+    # Passing an 'img*' variable (to copy_header_from) that is not in the
+    # formula or an img* argument should raise a KeyError exception.
+    with pytest.raises(KeyError):
+        math_img(formula, img1=img1, img3=img3, copy_header_from="img2")
+
 
 def test_math_img(
     affine_eye, img_4d_ones_eye, img_4d_zeros_eye, shape_3d_default, tmp_path
@@ -837,6 +849,59 @@ def test_math_img(
         assert result.shape == expected_result.shape
 
 
+def test_math_img_copy_default_header(
+    img_4d_ones_eye_default_header, img_4d_ones_eye_tr2
+):
+    # case where data values are not changed and header values are not copied
+    # the result should have default header values
+    formula_no_change = "img * 1"
+    # using img_4d_ones_eye_tr2 with edited header in the formula
+    result = math_img(
+        formula_no_change, img=img_4d_ones_eye_tr2, copy_header_from=None
+    )
+    # header values should instead match default header values
+    assert result.header == img_4d_ones_eye_default_header.header
+
+
+def test_math_img_copied_header_from_img(img_4d_mni_tr2):
+    # case where data values are not changed but header values are copied
+    # the result should have the same header values as the given image
+    formula_no_change = "img * 1"
+    result = math_img(
+        formula_no_change, img=img_4d_mni_tr2, copy_header_from="img"
+    )
+    # all header values should be the same
+    assert result.header == img_4d_mni_tr2.header
+
+
+def test_math_img_copied_header_data_values_changed(
+    img_4d_ones_eye_default_header, img_4d_ones_eye_tr2
+):
+    # case where data values are changed and header values are copied from one
+    # of the input images
+    # the result should have the same header values as img_4d_ones_eye_tr2,
+    # except for cal_max and cal_min that should be different
+    formula_change_min_max = "img1 - img2"
+    result = math_img(
+        formula_change_min_max,
+        img1=img_4d_ones_eye_default_header,
+        img2=img_4d_ones_eye_tr2,
+        copy_header_from="img2",
+    )
+    for key in img_4d_ones_eye_tr2.header.keys():
+        # cal_max and cal_min should be different in result
+        if key in ["cal_max", "cal_min"]:
+            assert result.header[key] != img_4d_ones_eye_tr2.header[key]
+        # other header values should be the same
+        else:
+            if isinstance(result.header[key], np.ndarray):
+                assert_array_equal(
+                    result.header[key], img_4d_ones_eye_tr2.header[key]
+                )
+            else:
+                assert result.header[key] == img_4d_ones_eye_tr2.header[key]
+
+
 def test_binarize_img(img_4d_rand_eye):
     # Test that all output values are 1.
     img1 = binarize_img(img_4d_rand_eye)