| """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 |
|
|