Skip to content

Commit 0ae9776

Browse files
authored
Log raw input image metadata at workflow start (#334)
1 parent e3fbd9e commit 0ae9776

5 files changed

Lines changed: 228 additions & 3 deletions

File tree

src/rbc/core/nifti.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
33
Provides :class:`Volume` for type-safe loading, deriving, and saving of NIfTI
44
images, plus lightweight metadata queries (:func:`nifti_num_volumes`,
5-
:func:`nifti_num_slices`) that avoid loading full image data.
5+
:func:`nifti_num_slices`, :func:`log_image_summary`) that avoid loading full
6+
image data.
67
"""
78

89
from __future__ import annotations
910

11+
import logging
1012
import warnings
1113
from enum import Enum, IntEnum
1214
from pathlib import Path
@@ -24,11 +26,14 @@
2426
"Space",
2527
"Units",
2628
"Volume",
29+
"log_image_summary",
2730
"nifti_num_slices",
2831
"nifti_num_volumes",
2932
"strip_afni_volatile_metadata",
3033
]
3134

35+
_logger = logging.getLogger(__name__)
36+
3237
# AFNI embeds a NIfTI extension (code 4) with an XML payload that contains
3338
# wall-clock timestamps and a random per-invocation UUID. Those poison
3439
# content-hash based caching for any tool downstream. Drop the extension
@@ -533,6 +538,101 @@ def nifti_num_slices(in_file: str | Path) -> int:
533538
return img.shape[2] if len(img.shape) >= 3 else 1
534539

535540

541+
def _space_label(code: int) -> str:
542+
"""Human-readable name for a NIfTI sform/qform code, or the raw int."""
543+
try:
544+
return Space(code).name
545+
except ValueError:
546+
return str(code)
547+
548+
549+
def _human_bytes(n: int) -> str:
550+
"""Format a byte count with a binary (B/KiB/MiB/GiB) suffix."""
551+
size = float(n)
552+
for unit in ("B", "KiB", "MiB"):
553+
if size < 1024:
554+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
555+
size /= 1024
556+
return f"{size:.1f} GiB"
557+
558+
559+
def log_image_summary(in_file: str | Path, *, label: str = "Raw input") -> None:
560+
"""Log array shape, dtype, and geometry of a raw NIfTI input.
561+
562+
Reads only the NIfTI header (no voxel data is loaded), then emits an
563+
INFO-level summary so the run log records exactly what entered the
564+
pipeline: array shape, on-disk dtype, data size (``shape`` x dtype
565+
itemsize), voxel size, axis orientation, sform/qform coordinate spaces,
566+
and (for 4D+ images) volume count, slice axis/count, slice acquisition
567+
order, and TR.
568+
569+
This is best-effort diagnostics only: a header that cannot be read
570+
produces a warning, not an exception, so the real failure surfaces
571+
later when processing actually touches the file.
572+
573+
Args:
574+
in_file: Path to a ``.nii``/``.nii.gz`` file.
575+
label: Short prefix identifying the input in the log (e.g.
576+
``"Anatomical T1w"``).
577+
"""
578+
path = Path(in_file)
579+
try:
580+
img = nib.nifti1.load(path)
581+
hdr = img.header
582+
shape = img.shape
583+
dtype = hdr.get_data_dtype()
584+
zooms = hdr.get_zooms()
585+
spatial_unit = hdr.get_xyzt_units()[0]
586+
587+
n_bytes = int(np.prod(shape, dtype=np.int64)) * dtype.itemsize
588+
voxel = " x ".join(f"{z:.3g}" for z in zooms[:3])
589+
voxel += f" {spatial_unit}" if spatial_unit != "unknown" else " (units unknown)"
590+
orientation = "".join(nib.aff2axcodes(img.affine))
591+
592+
_logger.info("%s: %s", label, path)
593+
_logger.info(
594+
"%s: shape=%s, dtype=%s, size=%s, voxel size=%s",
595+
label,
596+
shape,
597+
dtype,
598+
_human_bytes(n_bytes),
599+
voxel,
600+
)
601+
_logger.info(
602+
"%s: orientation=%s, sform=%s, qform=%s",
603+
label,
604+
orientation,
605+
_space_label(int(hdr["sform_code"])),
606+
_space_label(int(hdr["qform_code"])),
607+
)
608+
609+
if len(shape) > 3:
610+
raw_tr = float(hdr["pixdim"][4])
611+
tr = f"{raw_tr:.4g} s" if raw_tr > 0 else "unknown"
612+
# dim_info names the slice axis; BOLD usually omits it, so fall
613+
# back to the conventional third axis.
614+
slice_axis = hdr.get_dim_info()[2]
615+
if slice_axis is not None:
616+
n_slices, axis_desc = shape[slice_axis], f"axis {slice_axis}"
617+
else:
618+
n_slices, axis_desc = shape[2], "axis 2 (assumed; no dim_info)"
619+
slice_order = hdr.get_value_label("slice_code") # "unknown" if unset
620+
extra = f", extra dims={tuple(shape[4:])}" if len(shape) > 4 else ""
621+
_logger.info(
622+
"%s: volumes=%d, slices=%d along %s, slice order=%s%s, header TR=%s",
623+
label,
624+
shape[3],
625+
n_slices,
626+
axis_desc,
627+
slice_order,
628+
extra,
629+
tr,
630+
)
631+
except Exception as exc:
632+
# Diagnostics must never abort a run; the real failure surfaces later.
633+
_logger.warning("%s: could not read NIfTI header for %s (%s)", label, path, exc)
634+
635+
536636
def strip_afni_volatile_metadata(path: str | Path) -> None:
537637
"""Rewrite a NIfTI file with AFNI's non-deterministic extension removed.
538638

src/rbc/orchestration/anatomical.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from rbc.bids.anatomical import discover_anatomical, export_anatomical
1313
from rbc.bids.session import load_session
1414
from rbc.context import RunContext
15+
from rbc.core.nifti import log_image_summary
1516
from rbc.orchestration import Filters, RunnerConfig, init_runner
1617
from rbc.workflows.anatomical import AnatomicalOutputs, single_session_preprocess
1718
from rbc_resources import (
@@ -48,7 +49,7 @@ def process_session(
4849
"""
4950
outputs: AnatomicalOutputs | None = None
5051
for anat_run in discover_anatomical(session):
51-
_logger.info("Anatomical: %s", anat_run.path)
52+
log_image_summary(anat_run.path, label="Anatomical T1w")
5253
outputs = single_session_preprocess(
5354
in_t1w=anat_run.path,
5455
brain_extraction_templates=brain_extraction_templates,

src/rbc/orchestration/functional.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from rbc.bids.session import load_session
1919
from rbc.context import RunContext
20+
from rbc.core.nifti import log_image_summary
2021
from rbc.metadata import FunctionalMetadata
2122
from rbc.orchestration import Filters, RunnerConfig, init_runner
2223
from rbc.workflows.functional import single_session_preprocess
@@ -64,7 +65,7 @@ def process_session(
6465
"""
6566
results = []
6667
for func_run in discover_functional(session):
67-
_logger.info("Functional: %s", func_run.path)
68+
log_image_summary(func_run.path, label="Functional BOLD")
6869

6970
if anat_inputs is not None:
7071
resolved = anat_inputs

tests/unit/orchestration/test_functional.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def _patch_process_session() -> Generator[tuple[Mock, Mock, FunctionalRun], None
108108
"rbc.orchestration.functional.discover_functional",
109109
return_value=[func_run],
110110
),
111+
patch("rbc.orchestration.functional.log_image_summary"),
111112
patch(
112113
"rbc.orchestration.functional.resolve_functional",
113114
return_value=_ANAT_INPUTS,

tests/unit/test_nifti.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import logging
56
from typing import TYPE_CHECKING
67

78
import nibabel as nib
@@ -12,6 +13,7 @@
1213
Space,
1314
Units,
1415
Volume,
16+
log_image_summary,
1517
nifti_num_slices,
1618
nifti_num_volumes,
1719
)
@@ -626,3 +628,123 @@ def test_nifti_num_volumes_3d(self, nifti_3d: Path) -> None:
626628
def test_nifti_num_slices_3d(self, nifti_3d: Path) -> None:
627629
"""3D image reports correct slice count."""
628630
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

Comments
 (0)