|
23 | 23 | """Unit tests exercising the dMRI data structure.""" |
24 | 24 |
|
25 | 25 | import re |
| 26 | +import warnings |
26 | 27 | from pathlib import Path |
27 | 28 |
|
28 | 29 | import attrs |
|
40 | 41 | GRADIENT_BVAL_BVEC_PRIORITY_WARN_MSG, |
41 | 42 | GRADIENT_DATA_MISSING_ERROR, |
42 | 43 | from_nii, |
| 44 | + to_nifti, |
43 | 45 | ) |
44 | 46 | from nifreeze.data.dmri.utils import ( |
45 | 47 | DEFAULT_LOWB_THRESHOLD, |
@@ -606,6 +608,164 @@ def test_load_gradients_missing(tmp_path, setup_random_dwi_data): |
606 | 608 | from_nii(dwi_fname) |
607 | 609 |
|
608 | 610 |
|
| 611 | +@pytest.mark.parametrize("vol_size", [(4, 4, 5)]) |
| 612 | +@pytest.mark.parametrize("b0_count", [0, 1]) |
| 613 | +@pytest.mark.parametrize("bval_min, bval_max", [(800.0, 1200.0)]) |
| 614 | +@pytest.mark.parametrize("provide_bzero", [False, True]) |
| 615 | +@pytest.mark.parametrize("insert_b0", [False, True]) |
| 616 | +@pytest.mark.parametrize("motion_affines", [None, 2 * np.eye(4)]) |
| 617 | +@pytest.mark.parametrize("bvals_dec_places, bvecs_dec_places", [(2, 6), (1, 4)]) |
| 618 | +@pytest.mark.parametrize("file_basename", [None, "dwi.nii.gz"]) |
| 619 | +def test_to_nifti( |
| 620 | + request, |
| 621 | + tmp_path, |
| 622 | + monkeypatch, |
| 623 | + vol_size, |
| 624 | + b0_count, |
| 625 | + bval_min, |
| 626 | + bval_max, |
| 627 | + provide_bzero, |
| 628 | + insert_b0, |
| 629 | + motion_affines, |
| 630 | + bvals_dec_places, |
| 631 | + bvecs_dec_places, |
| 632 | + file_basename, |
| 633 | +): |
| 634 | + rng = request.node.rng |
| 635 | + |
| 636 | + # Choose n_vols safely above the minimum DTI orientations |
| 637 | + n_vols = max(10, DTI_MIN_ORIENTATIONS + 2) |
| 638 | + |
| 639 | + # Build b-values array: first b0_count are zeros |
| 640 | + non_b0_count = n_vols - b0_count |
| 641 | + # Sample non-b0 bvals between min and max values |
| 642 | + rest_bvals = rng.uniform(bval_min, bval_max, size=non_b0_count) |
| 643 | + bvals = np.concatenate((np.zeros(b0_count), rest_bvals)).astype(int) |
| 644 | + |
| 645 | + # Create bvecs and assemble gradients |
| 646 | + bzeros = np.zeros((b0_count, 3)) |
| 647 | + bvecs = normalized_vector(rng.random((3, non_b0_count)), axis=0).T |
| 648 | + bvecs = np.vstack((bzeros, bvecs)) |
| 649 | + gradients = np.column_stack((bvecs, bvals)) |
| 650 | + |
| 651 | + # Create random dataobj with shape |
| 652 | + dataobj = rng.standard_normal((*vol_size, n_vols)).astype(float) |
| 653 | + |
| 654 | + # Optionally supply a bzero |
| 655 | + provided = None |
| 656 | + affine = np.eye(4) |
| 657 | + _motion_affines = ( |
| 658 | + np.stack([motion_affines] * non_b0_count) if motion_affines is not None else None |
| 659 | + ) |
| 660 | + if provide_bzero: |
| 661 | + # Use a constant map so it's easy to assert equality |
| 662 | + provided = np.full((*vol_size, max(1, b0_count)), 42.0, dtype=float).squeeze() |
| 663 | + dwi_obj = DWI( |
| 664 | + dataobj=dataobj, |
| 665 | + affine=affine, |
| 666 | + motion_affines=_motion_affines, |
| 667 | + gradients=gradients, |
| 668 | + bzero=provided, |
| 669 | + ) |
| 670 | + else: |
| 671 | + dwi_obj = DWI( |
| 672 | + dataobj=dataobj, affine=affine, motion_affines=_motion_affines, gradients=gradients |
| 673 | + ) |
| 674 | + |
| 675 | + _filename = tmp_path / file_basename if file_basename is not None else file_basename |
| 676 | + |
| 677 | + # Monkeypatch the to_nifti alias to only perform essential operations for |
| 678 | + # the purpose of this test |
| 679 | + def simple_to_nifti(_dataset, filename=None, write_hmxfms=None, order=None): |
| 680 | + _ = write_hmxfms |
| 681 | + _ = order |
| 682 | + _nii = nb.Nifti1Image(_dataset.dataobj, _dataset.affine) |
| 683 | + if filename is not None: |
| 684 | + _nii.to_filename(filename) |
| 685 | + return _nii |
| 686 | + |
| 687 | + monkeypatch.setattr("nifreeze.data.dmri.io._base_to_nifti", simple_to_nifti) |
| 688 | + |
| 689 | + with warnings.catch_warnings(record=True) as caught: |
| 690 | + nii = to_nifti( |
| 691 | + dwi_obj, |
| 692 | + _filename, |
| 693 | + write_hmxfms=False, |
| 694 | + order=3, |
| 695 | + insert_b0=insert_b0, |
| 696 | + bvals_dec_places=bvals_dec_places, |
| 697 | + bvecs_dec_places=bvecs_dec_places, |
| 698 | + ) |
| 699 | + |
| 700 | + no_bzero = dwi_obj.bzero is None or not insert_b0 |
| 701 | + |
| 702 | + # Check the warning |
| 703 | + if no_bzero: |
| 704 | + if insert_b0: |
| 705 | + assert ( |
| 706 | + str(caught[0].message) |
| 707 | + == "Ignoring ``insert_b0`` argument as the data object's bzero field is unset" |
| 708 | + ) |
| 709 | + |
| 710 | + bvecs_dwi = dwi_obj.bvecs |
| 711 | + bvals_dwi = dwi_obj.bvals |
| 712 | + # Transform bvecs if motion affines are present |
| 713 | + if dwi_obj.motion_affines is not None: |
| 714 | + rotated = [ |
| 715 | + transform_fsl_bvec(_bvec, _affine, dwi_obj.affine, invert=True) |
| 716 | + for _bvec, _affine in zip(bvecs_dwi, dwi_obj.motion_affines, strict=True) |
| 717 | + ] |
| 718 | + bvecs_dwi = np.asarray(rotated) |
| 719 | + |
| 720 | + # Check the primary NIfTI output |
| 721 | + _dataobj = dwi_obj.dataobj |
| 722 | + # Concatenate the b0 if the primary data has a b0 volume or if it was |
| 723 | + # requested to do so |
| 724 | + if not no_bzero: |
| 725 | + assert dwi_obj.bzero is not None |
| 726 | + # ToDo |
| 727 | + # The code will concatenate as many zeros as they exist to the data |
| 728 | + _dataobj = np.concatenate((dwi_obj.bzero[..., np.newaxis], dwi_obj.dataobj), axis=-1) |
| 729 | + # But when inserting b0 data to the gradients, it inserts a single b0. |
| 730 | + # Here I will insert as many values as b0 volumes to make the test fail |
| 731 | + dwi_b0_count = dwi_obj.bzero.shape[-1] if dwi_obj.bzero.ndim == 4 else 1 |
| 732 | + bvals_dwi = np.concatenate((np.zeros(dwi_b0_count), bvals_dwi)) |
| 733 | + bvecs_dwi = np.vstack((np.zeros((dwi_b0_count, bvecs_dwi.shape[1])), bvecs_dwi)) |
| 734 | + |
| 735 | + assert isinstance(nii, nb.Nifti1Image) |
| 736 | + assert np.allclose(nii.get_fdata(), _dataobj) |
| 737 | + assert np.allclose(nii.affine, dwi_obj.affine) |
| 738 | + |
| 739 | + # Check the written files, if any |
| 740 | + if _filename is None: |
| 741 | + assert not any(tmp_path.iterdir()), "Directory is not empty" |
| 742 | + else: |
| 743 | + # Check the written NIfTI file |
| 744 | + assert _filename.is_file() |
| 745 | + |
| 746 | + _nii_load = load_api(_filename, nb.Nifti1Image) |
| 747 | + |
| 748 | + # Build a NIfTI file with the data object that potentially contains |
| 749 | + # concatenated b0 data |
| 750 | + _nii_dataobj = nb.Nifti1Image(_dataobj, nii.affine, nii.header) |
| 751 | + |
| 752 | + np.allclose(_nii_dataobj.get_fdata(), _nii_load.get_fdata()) |
| 753 | + np.allclose(_nii_dataobj.affine, _nii_load.affine) |
| 754 | + |
| 755 | + # Check gradients |
| 756 | + bvecs_file = _filename.with_suffix("").with_suffix(".bvec") |
| 757 | + bvals_file = _filename.with_suffix("").with_suffix(".bval") |
| 758 | + assert bvals_file.is_file() |
| 759 | + assert bvecs_file.is_file() |
| 760 | + |
| 761 | + # Read the files |
| 762 | + bvals_from_file = np.loadtxt(bvals_file) |
| 763 | + bvecs_from_file = np.loadtxt(bvecs_file).T |
| 764 | + |
| 765 | + assert np.allclose(bvals_from_file, bvals_dwi, rtol=0, atol=10**-bvals_dec_places) |
| 766 | + assert np.allclose(bvecs_from_file, bvecs_dwi, rtol=0, atol=10**-bvecs_dec_places) |
| 767 | + |
| 768 | + |
609 | 769 | @pytest.mark.skip(reason="to_nifti takes absurdly long") |
610 | 770 | @pytest.mark.parametrize("insert_b0", (False, True)) |
611 | 771 | @pytest.mark.parametrize("rotate_bvecs", (False, True)) |
|
0 commit comments