csai / code /a1_pipeline /spatial.py
Mohith202's picture
Add core ROI masks, visualization script, and model profiles
8c5a642
Raw
History Blame Contribute Delete
6.66 kB
"""Spatial harmonization utilities for A1 baseline bootstrap."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import nibabel as nib
from nibabel.processing import resample_from_to
import numpy as np
import pandas as pd
@dataclass(frozen=True)
class RunMaskQC:
"""Per-run QC record used when building the analysis mask."""
subject: str
run: int
derivatives_bold_path: str
run_mask_method: str
needs_resample_to_reference: bool
run_mask_voxels: int
def _make_reference_3d_from_manifest(manifest_df: pd.DataFrame) -> nib.Nifti1Image:
if manifest_df.empty:
raise ValueError("Manifest is empty; cannot create spatial reference")
first_path = Path(str(manifest_df.iloc[0]["derivatives_bold_path"]))
first_img = nib.load(str(first_path))
if len(first_img.shape) != 4:
raise ValueError(f"Expected 4D BOLD image but got shape={first_img.shape} for {first_path}")
shape_3d = first_img.shape[:3]
return nib.Nifti1Image(np.zeros(shape_3d, dtype=np.uint8), first_img.affine)
def _same_grid(img_a: nib.Nifti1Image, img_b: nib.Nifti1Image, atol: float = 1e-5) -> bool:
return img_a.shape == img_b.shape and np.allclose(img_a.affine, img_b.affine, atol=atol)
def _extract_run_mask_from_bold(
bold_img: nib.Nifti1Image,
method: str,
epsilon: float,
) -> nib.Nifti1Image:
shape = bold_img.shape
if len(shape) != 4:
raise ValueError(f"Expected 4D BOLD image, got shape={shape}")
if method == "first_volume_nonzero":
volume = np.asanyarray(bold_img.dataobj[..., 0])
run_mask = np.abs(volume) > epsilon
elif method == "temporal_any_nonzero":
run_mask = np.zeros(shape[:3], dtype=bool)
for time_index in range(shape[3]):
volume = np.asanyarray(bold_img.dataobj[..., time_index])
run_mask |= np.abs(volume) > epsilon
else:
raise ValueError(f"Unsupported run mask method: {method}")
return nib.Nifti1Image(run_mask.astype(np.uint8), bold_img.affine)
def _resample_binary_mask_to_reference(
binary_mask_img: nib.Nifti1Image,
reference_3d_img: nib.Nifti1Image,
) -> nib.Nifti1Image:
resampled = resample_from_to(
binary_mask_img,
(reference_3d_img.shape, reference_3d_img.affine),
order=0,
)
binary_data = (resampled.get_fdata() > 0.5).astype(np.uint8)
return nib.Nifti1Image(binary_data, reference_3d_img.affine)
def build_symmetric_analysis_mask(
manifest_df: pd.DataFrame,
run_mask_method: str = "first_volume_nonzero",
epsilon: float = 1e-6,
) -> tuple[nib.Nifti1Image, dict[tuple[str, int], np.ndarray], pd.DataFrame, dict[str, Any]]:
"""Build analysis mask in a canonical grid with left-right symmetry enforced.
Returns:
analysis_mask_img: symmetric boolean mask image in reference grid
run_mask_map: (subject, run) -> boolean 3D run mask in reference grid
run_mask_qc_df: per-run mask stats including whether resampling was needed
mask_qc: summary dictionary
"""
if manifest_df.empty:
raise ValueError("Manifest is empty; cannot build analysis mask")
required_columns = {"subject", "run", "derivatives_bold_path"}
missing = required_columns.difference(manifest_df.columns)
if missing:
raise ValueError(f"Manifest missing required columns: {sorted(missing)}")
reference_3d_img = _make_reference_3d_from_manifest(manifest_df)
reference_affine = reference_3d_img.affine
run_mask_map: dict[tuple[str, int], np.ndarray] = {}
run_qc_rows: list[RunMaskQC] = []
intersection_mask: np.ndarray | None = None
n_resampled = 0
for row in manifest_df.itertuples(index=False):
subject = str(getattr(row, "subject"))
run = int(getattr(row, "run"))
bold_path = Path(str(getattr(row, "derivatives_bold_path")))
bold_img = nib.load(str(bold_path))
run_mask_img = _extract_run_mask_from_bold(
bold_img=bold_img,
method=run_mask_method,
epsilon=epsilon,
)
needs_resample = not _same_grid(run_mask_img, reference_3d_img)
if needs_resample:
n_resampled += 1
run_mask_img = _resample_binary_mask_to_reference(run_mask_img, reference_3d_img)
run_mask_bool = run_mask_img.get_fdata() > 0.5
run_key = (subject, run)
run_mask_map[run_key] = run_mask_bool
if intersection_mask is None:
intersection_mask = run_mask_bool.copy()
else:
intersection_mask &= run_mask_bool
run_qc_rows.append(
RunMaskQC(
subject=subject,
run=run,
derivatives_bold_path=str(bold_path),
run_mask_method=run_mask_method,
needs_resample_to_reference=needs_resample,
run_mask_voxels=int(run_mask_bool.sum()),
)
)
if intersection_mask is None:
raise RuntimeError("Could not compute intersection mask from manifest entries")
swapped_mask = np.flip(intersection_mask, axis=0)
symmetric_mask = intersection_mask & swapped_mask
analysis_mask_img = nib.Nifti1Image(symmetric_mask.astype(np.uint8), reference_affine)
shape_x = symmetric_mask.shape[0]
mid_x = shape_x // 2
left_voxels = int(symmetric_mask[:mid_x, :, :].sum())
right_voxels = int(symmetric_mask[-mid_x:, :, :].sum()) if mid_x > 0 else 0
run_mask_qc_df = pd.DataFrame([asdict(row) for row in run_qc_rows])
if not run_mask_qc_df.empty:
run_mask_qc_df = run_mask_qc_df.sort_values(["subject", "run"]).reset_index(drop=True)
orientation_codes = "".join(nib.aff2axcodes(reference_affine))
mask_qc: dict[str, Any] = {
"run_mask_method": run_mask_method,
"n_manifest_rows": int(len(manifest_df)),
"n_run_masks": int(len(run_mask_map)),
"n_resampled_run_masks": int(n_resampled),
"reference_shape": [int(dim) for dim in analysis_mask_img.shape],
"reference_orientation": orientation_codes,
"intersection_voxels": int(intersection_mask.sum()),
"symmetric_mask_voxels": int(symmetric_mask.sum()),
"left_hemisphere_voxels": left_voxels,
"right_hemisphere_voxels": right_voxels,
}
if not run_mask_qc_df.empty:
mask_qc["run_mask_voxels_min"] = int(run_mask_qc_df["run_mask_voxels"].min())
mask_qc["run_mask_voxels_max"] = int(run_mask_qc_df["run_mask_voxels"].max())
return analysis_mask_img, run_mask_map, run_mask_qc_df, mask_qc