|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import logging |
5 | 6 | from typing import TYPE_CHECKING |
6 | 7 |
|
7 | 8 | import nibabel as nib |
|
12 | 13 | Space, |
13 | 14 | Units, |
14 | 15 | Volume, |
| 16 | + log_image_summary, |
15 | 17 | nifti_num_slices, |
16 | 18 | nifti_num_volumes, |
17 | 19 | ) |
@@ -626,3 +628,123 @@ def test_nifti_num_volumes_3d(self, nifti_3d: Path) -> None: |
626 | 628 | def test_nifti_num_slices_3d(self, nifti_3d: Path) -> None: |
627 | 629 | """3D image reports correct slice count.""" |
628 | 630 | assert nifti_num_slices(nifti_3d) == 7 |
| 631 | + |
| 632 | + |
| 633 | +class TestLogImageSummary: |
| 634 | + """Tests for log_image_summary().""" |
| 635 | + |
| 636 | + def test_3d_summary(self, nifti_3d: Path, caplog: pytest.LogCaptureFixture) -> None: |
| 637 | + """3D input logs shape, dtype, size, voxel size, orientation, spaces.""" |
| 638 | + caplog.set_level(logging.INFO, logger="rbc.core.nifti") |
| 639 | + log_image_summary(nifti_3d, label="Anatomical T1w") |
| 640 | + text = "\n".join(caplog.messages) |
| 641 | + assert "Anatomical T1w" in text |
| 642 | + assert "shape=(5, 6, 7)" in text |
| 643 | + assert "dtype=float64" in text |
| 644 | + assert "size=1.6 KiB" in text # 5*6*7 * 8 = 1680 bytes |
| 645 | + assert "voxel size=1 x 1 x 1 mm" in text |
| 646 | + assert "orientation=RAS" in text |
| 647 | + assert "sform=MNI" in text |
| 648 | + assert "qform=MNI" in text |
| 649 | + |
| 650 | + def test_3d_summary_omits_4d_fields( |
| 651 | + self, nifti_3d: Path, caplog: pytest.LogCaptureFixture |
| 652 | + ) -> None: |
| 653 | + """3D input does not log volume/slice/TR fields.""" |
| 654 | + caplog.set_level(logging.INFO, logger="rbc.core.nifti") |
| 655 | + log_image_summary(nifti_3d) |
| 656 | + assert not any("volumes=" in m for m in caplog.messages) |
| 657 | + assert not any("TR=" in m for m in caplog.messages) |
| 658 | + |
| 659 | + def test_4d_summary_includes_volumes_and_tr( |
| 660 | + self, nifti_4d: Path, caplog: pytest.LogCaptureFixture |
| 661 | + ) -> None: |
| 662 | + """4D input logs volume count, slice axis/count/order, and header TR.""" |
| 663 | + caplog.set_level(logging.INFO, logger="rbc.core.nifti") |
| 664 | + log_image_summary(nifti_4d, label="Functional BOLD") |
| 665 | + text = "\n".join(caplog.messages) |
| 666 | + assert "shape=(5, 6, 7, 10)" in text |
| 667 | + assert "size=16.4 KiB" in text # 5*6*7*10 * 8 bytes |
| 668 | + assert "volumes=10" in text |
| 669 | + assert "slices=7 along axis 2 (assumed; no dim_info)" in text |
| 670 | + assert "slice order=unknown" in text |
| 671 | + assert "header TR=2 s" in text |
| 672 | + assert "extra dims" not in text |
| 673 | + |
| 674 | + def test_4d_slice_axis_and_order_from_header( |
| 675 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 676 | + ) -> None: |
| 677 | + """A header that sets dim_info / slice_code has them reported.""" |
| 678 | + rng = np.random.default_rng(0) |
| 679 | + img = nib.Nifti1Image(rng.standard_normal((4, 5, 6, 3)), np.eye(4)) |
| 680 | + hdr = img.header |
| 681 | + hdr.set_dim_info(slice=1) |
| 682 | + hdr["slice_code"] = 1 # sequential increasing |
| 683 | + pixdim = hdr["pixdim"].copy() |
| 684 | + pixdim[4] = 2.0 |
| 685 | + hdr["pixdim"] = pixdim |
| 686 | + path = tmp_path / "slices.nii.gz" |
| 687 | + img.to_filename(str(path)) |
| 688 | + |
| 689 | + caplog.set_level(logging.INFO, logger="rbc.core.nifti") |
| 690 | + log_image_summary(path) |
| 691 | + text = "\n".join(caplog.messages) |
| 692 | + assert "slices=5 along axis 1" in text |
| 693 | + assert "slice order=sequential increasing" in text |
| 694 | + |
| 695 | + def test_5d_reports_extra_dims( |
| 696 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 697 | + ) -> None: |
| 698 | + """5D input reports the trailing dims rather than mislabeling them.""" |
| 699 | + path = _make_nifti(tmp_path, "multi.nii.gz", (4, 5, 6, 7, 2)) |
| 700 | + caplog.set_level(logging.INFO, logger="rbc.core.nifti") |
| 701 | + log_image_summary(path) |
| 702 | + assert any("extra dims=(2,)" in m for m in caplog.messages) |
| 703 | + |
| 704 | + def test_dtype_and_size_reflect_on_disk_type( |
| 705 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 706 | + ) -> None: |
| 707 | + """Logged dtype/size use the on-disk dtype, not float64 get_fdata().""" |
| 708 | + path = _make_nifti(tmp_path, "int16.nii.gz", (4, 5, 6), dtype=np.int16) |
| 709 | + caplog.set_level(logging.INFO, logger="rbc.core.nifti") |
| 710 | + log_image_summary(path) |
| 711 | + text = "\n".join(caplog.messages) |
| 712 | + assert "dtype=int16" in text |
| 713 | + assert "size=240 B" in text # 4*5*6 * 2 bytes |
| 714 | + |
| 715 | + def test_size_uses_binary_units( |
| 716 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 717 | + ) -> None: |
| 718 | + """Data size scales to binary units.""" |
| 719 | + path = _make_nifti(tmp_path, "big.nii.gz", (64, 64, 64), dtype=np.int16) |
| 720 | + caplog.set_level(logging.INFO, logger="rbc.core.nifti") |
| 721 | + log_image_summary(path) |
| 722 | + assert any("size=512.0 KiB" in m for m in caplog.messages) |
| 723 | + |
| 724 | + def test_unknown_units_flagged( |
| 725 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 726 | + ) -> None: |
| 727 | + """Voxel size notes when spatial units are unset in the header.""" |
| 728 | + path = _make_nifti(tmp_path, "nounit.nii.gz", (4, 5, 6), xyzt_units=0) |
| 729 | + caplog.set_level(logging.INFO, logger="rbc.core.nifti") |
| 730 | + log_image_summary(path) |
| 731 | + assert any("voxel size=1 x 1 x 1 (units unknown)" in m for m in caplog.messages) |
| 732 | + |
| 733 | + def test_emitted_at_info_level( |
| 734 | + self, nifti_3d: Path, caplog: pytest.LogCaptureFixture |
| 735 | + ) -> None: |
| 736 | + """Summary is emitted at INFO level (suppressed by default).""" |
| 737 | + caplog.set_level(logging.WARNING, logger="rbc.core.nifti") |
| 738 | + log_image_summary(nifti_3d) |
| 739 | + assert caplog.messages == [] |
| 740 | + |
| 741 | + def test_unreadable_file_warns_without_raising( |
| 742 | + self, tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 743 | + ) -> None: |
| 744 | + """A missing/corrupt file logs a warning instead of aborting the run.""" |
| 745 | + caplog.set_level(logging.WARNING, logger="rbc.core.nifti") |
| 746 | + log_image_summary(tmp_path / "does_not_exist.nii.gz", label="Anatomical T1w") |
| 747 | + assert any( |
| 748 | + "could not read NIfTI header" in m and m.startswith("Anatomical T1w") |
| 749 | + for m in caplog.messages |
| 750 | + ) |
0 commit comments