"""ROI warp validation checks.
NOTE: Runs inside a Nipype Function node; the thesis logger is not available at
runtime. ``warnings.warn()`` is used for soft failures and ``PipelineError``
is raised for hard failures.
"""
from typing import List, Optional
[docs]
def validate_warped_rois_task(
roi_paths: List[str],
reference_image: str,
original_roi_paths: Optional[List[str]] = None,
min_voxels: int = 10,
singularity_threshold: float = 1e-6,
volume_ratio_min: float = 0.5,
volume_ratio_max: float = 1.5,
) -> List[str]:
"""Validate warped ROI masks for voxel count, centroid, and volume consistency.
Performs four checks per non-empty ROI path:
* **(a) Voxel count** — raises ``PipelineError`` if the mask contains fewer
than ``min_voxels`` non-zero voxels (hard failure, halts pipeline).
* **(a2) Affine singularity** — raises ``PipelineError`` if the absolute
determinant of the affine's 3×3 rotation/scale block is below
``singularity_threshold``. A near-zero determinant causes
``probtrackx2`` to crash with ``inv(): matrix is singular`` (hard
failure, halts pipeline).
* **(b) Centroid plausibility** — emits a ``UserWarning`` if the mask
centroid falls outside the subject brain mask (soft failure, continues).
* **(c) Volume consistency** — emits a ``UserWarning`` if the warped mask
volume ratio (warped / original) falls outside
``[volume_ratio_min, volume_ratio_max]`` (soft failure, continues).
Only performed when ``original_roi_paths`` is provided.
Empty-string entries in ``roi_paths`` are silently skipped (absent masks).
Args:
roi_paths: Paths to warped ROI masks in subject space. Empty strings
are treated as absent and skipped.
reference_image: Path to the subject-space brain mask used for centroid
plausibility check (check b).
original_roi_paths: Corresponding pre-warp ROI paths used for volume
comparison (check c). Must have the same length as ``roi_paths``
when provided. ``None`` or empty-string entries skip the check for
that mask.
min_voxels: Minimum number of non-zero voxels required in each warped
mask. Fewer voxels triggers the hard failure (check a).
singularity_threshold: Minimum absolute determinant of the affine
3×3 rotation/scale block. Below this the mask is rejected as
degenerate (check a2).
volume_ratio_min: Lower bound for warped-vs-original voxel count ratio.
Below this a warning is emitted (check c).
volume_ratio_max: Upper bound for warped-vs-original voxel count ratio.
Above this a warning is emitted (check c).
Returns:
``roi_paths`` unchanged (passthrough for Nipype graph chaining).
Raises:
PipelineError: If any warped mask is empty (fewer than ``min_voxels``
non-zero voxels) or has a degenerate (near-singular) affine matrix.
"""
import warnings
import nibabel as nib
import numpy as np
from thesis.core.exceptions import PipelineError
# Load brain mask once (shared across all ROI checks)
brain_mask_data = None
if reference_image:
try:
brain_mask_data = nib.load(reference_image).get_fdata() # type: ignore[attr-defined]
except Exception: # pragma: no cover — missing mask is handled downstream
pass
for idx, roi_path in enumerate(roi_paths):
if not roi_path:
continue
# --- Load warped mask ---
img = nib.load(roi_path)
data = img.get_fdata() # type: ignore[attr-defined]
# (a) Voxel count check — hard failure
n_voxels = int(np.sum(data > 0))
if n_voxels < min_voxels:
raise PipelineError(
f"Warped ROI mask is empty (or near-empty): '{roi_path}' has "
f"{n_voxels} non-zero voxel(s), minimum required is {min_voxels}. "
"Check that the warp field is correctly specified and points to the "
"right subject."
)
# (a2) Affine singularity check — hard failure
# probtrackx2 calls inv() on the affine internally; a near-zero determinant
# causes 'inv(): matrix is singular' and an immediate crash.
affine_det = abs(float(np.linalg.det(img.affine[:3, :3]))) # type: ignore[attr-defined]
if affine_det < singularity_threshold:
raise PipelineError(
f"Warped ROI mask has a degenerate (near-singular) affine matrix: "
f"'{roi_path}' (det={affine_det:.2e}). "
"probtrackx2 will crash with 'inv(): matrix is singular'. "
"Check that the warp field produces a valid output geometry for "
"this subject."
)
# (b) Centroid plausibility — soft failure
if brain_mask_data is not None:
coords = np.argwhere(data > 0)
centroid = tuple(int(round(c)) for c in coords.mean(axis=0))
# Clamp to valid array indices
clamped = tuple(max(0, min(c, s - 1)) for c, s in zip(centroid, brain_mask_data.shape))
if brain_mask_data[clamped] == 0:
warnings.warn(
f"centroid of warped ROI '{roi_path}' at voxel {centroid} is "
"outside the subject brain mask. The warp may be off-target.",
UserWarning,
stacklevel=2,
)
# (c) Volume ratio — soft failure (only when original provided)
if original_roi_paths is not None and idx < len(original_roi_paths):
orig_path = original_roi_paths[idx]
if orig_path:
orig_data = nib.load(orig_path).get_fdata() # type: ignore[attr-defined]
orig_voxels = int(np.sum(orig_data > 0))
if orig_voxels > 0:
ratio = n_voxels / orig_voxels
if ratio < volume_ratio_min or ratio > volume_ratio_max:
warnings.warn(
f"volume ratio of warped ROI '{roi_path}' vs original "
f"'{orig_path}' is {ratio:.2f} "
f"(expected {volume_ratio_min}–{volume_ratio_max}). "
"The warp may have significantly changed the ROI size.",
UserWarning,
stacklevel=2,
)
return roi_paths
[docs]
def validate_warped_rois_passthrough_task(
seed: str,
waypoints_file: str,
stop_mask: str,
avoid_mask: str,
target_mask: str,
reference_image: str,
min_voxels: int = 10,
singularity_threshold: float = 1e-6,
volume_ratio_min: float = 0.5,
volume_ratio_max: float = 1.5,
) -> tuple:
"""Validate all warped ROI masks and pass them through unchanged.
Nipype-compatible wrapper around :func:`validate_warped_rois_task`. Collects
individual NIfTI mask paths and expands the waypoints text file into its
constituent paths, then delegates validation. Returns all five inputs unchanged
so this node can be inserted transparently between the transformer and any
downstream node.
Runs inside a Nipype Function node — all imports are local; ``warnings.warn``
is used for soft failures (no module-level logger).
Args:
seed: Path to the transformed seed mask, or empty string if absent.
waypoints_file: Path to text file listing transformed waypoint NIfTI paths,
or empty string if absent.
stop_mask: Path to transformed stop mask, or empty string.
avoid_mask: Path to transformed avoid mask, or empty string.
target_mask: Path to transformed target mask, or empty string.
reference_image: Subject-space brain mask for centroid plausibility check.
min_voxels: Minimum non-zero voxel count per warped mask.
singularity_threshold: Minimum absolute determinant of affine 3x3 block.
volume_ratio_min: Lower bound for warped-vs-original voxel ratio.
volume_ratio_max: Upper bound for warped-vs-original voxel ratio.
Returns:
5-tuple ``(seed, waypoints_file, stop_mask, avoid_mask, target_mask)``
unchanged.
Raises:
PipelineError: If any transformed mask has fewer than ``min_voxels``
non-zero voxels or has a degenerate affine matrix.
"""
from pathlib import Path
from thesis.workflows.hcp.operations.validation import validate_warped_rois_task
roi_paths: list = []
for p in (seed, stop_mask, avoid_mask, target_mask):
if p:
roi_paths.append(p)
if waypoints_file and Path(waypoints_file).exists():
with open(waypoints_file, "r", encoding="utf-8") as fh:
for line in fh:
stripped = line.strip()
if stripped:
roi_paths.append(stripped)
validate_warped_rois_task(
roi_paths,
reference_image,
min_voxels=min_voxels,
singularity_threshold=singularity_threshold,
volume_ratio_min=volume_ratio_min,
volume_ratio_max=volume_ratio_max,
)
return (seed, waypoints_file, stop_mask, avoid_mask, target_mask)
[docs]
def verify_waypoint_avoid_overlap_task(
seed: str,
waypoints_file: str,
stop_mask: str,
avoid_mask: str,
target_mask: str,
max_overlap_fraction: float = 0.9,
) -> tuple:
"""Fail-fast guard against waypoint masks landing inside the avoid mask.
ProbTrackX2 must satisfy two mutually exclusive constraints when a waypoint
overlaps the avoid mask: streamlines must pass through the waypoint AND
must not enter avoid. If a waypoint is wholly contained in avoid, every
streamline is killed at step 0 and the run silently produces
``waytotal=0``.
This check runs after DWI-grid resampling — the last point before the
masks are handed to ``probtrackx2`` — and raises ``PipelineError`` when
any waypoint has more than ``max_overlap_fraction`` of its voxels inside
avoid. Pure passthrough on success.
Args:
seed: Path to seed mask (unused, for signature compatibility).
waypoints_file: Text file listing waypoint mask paths (one per line),
or empty string if absent.
stop_mask: Path to stop mask (unused, for signature compatibility).
avoid_mask: Path to avoid mask, or empty string. When absent, the
check is a no-op.
target_mask: Path to target mask (unused, for signature compatibility).
max_overlap_fraction: Maximum allowed fraction of waypoint voxels
inside the avoid mask before failing. Default 0.9 (90%). The
threshold is deliberately high — anatomically valid waypoints
often clip the avoid mask near boundaries (e.g. the cerebral
peduncle near the brainstem can show ~30-40% overlap on healthy
subjects). The bug this guard targets is the catastrophic case
(e.g. an IC waypoint warped into the wrong hemisphere where the
entire mask lands inside the contralateral-hemisphere avoid),
which produces ~100% overlap.
Returns:
5-tuple of the five inputs unchanged.
Raises:
PipelineError: If any waypoint has more than ``max_overlap_fraction``
of its voxels inside the avoid mask.
"""
from pathlib import Path
import nibabel as nib
import numpy as np
from thesis.core.exceptions import PipelineError
if not avoid_mask or not Path(avoid_mask).exists():
return (seed, waypoints_file, stop_mask, avoid_mask, target_mask)
if not waypoints_file or not Path(waypoints_file).exists():
return (seed, waypoints_file, stop_mask, avoid_mask, target_mask)
avoid_data = nib.load(avoid_mask).get_fdata() > 0 # type: ignore[attr-defined]
with open(waypoints_file, "r", encoding="utf-8") as handle:
waypoint_paths = [line.strip() for line in handle if line.strip()]
blocked: list[str] = []
for wp_path in waypoint_paths:
if not wp_path or not Path(wp_path).exists():
continue
wp_data = nib.load(wp_path).get_fdata() > 0 # type: ignore[attr-defined]
wp_voxels = int(np.sum(wp_data))
if wp_voxels == 0:
continue
# Both masks share the DWI grid at this point; element-wise AND.
if wp_data.shape != avoid_data.shape:
continue
overlap = int(np.sum(wp_data & avoid_data))
fraction = overlap / wp_voxels
if fraction > max_overlap_fraction:
blocked.append(
f" - {wp_path}: {overlap}/{wp_voxels} voxels "
f"({fraction:.1%}) inside avoid mask"
)
if blocked:
raise PipelineError(
"Waypoint mask(s) overlap the avoid mask beyond the allowed "
f"{max_overlap_fraction:.0%} threshold. ProbTrackX2 cannot produce "
"any streamline that simultaneously passes through such a waypoint "
"and avoids the avoid mask — the run would silently emit "
"waytotal=0. Investigate the upstream warp / hemisphere routing "
"for this subject.\n" + "\n".join(blocked)
)
return (seed, waypoints_file, stop_mask, avoid_mask, target_mask)