fix(mri): warn on all-False mask; document 6-connectivity erosion caveat
Browse filesAddresses I1, I2, M1 from code review:
- Docstring step-2 now calls out 6-connectivity, iterations=1, and the
thin-feature erosion caveat (suggesting 26-connectivity for production).
- New paragraph documents the all-False WARNING behaviour.
- logger.warning emitted when cleaned.any() is False, reporting volume
min/max and effective threshold so silent feature-zeroing is visible
in production logs.
- .astype(bool) moved onto the binary_opening line; return is just cleaned.
- New regression test: constant-valued volume produces empty mask + WARNING.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
src/pipelines/mri_pipeline.py
CHANGED
|
@@ -63,8 +63,15 @@ def mask_brain(
|
|
| 63 |
`None`, use the volume's mean as a robust auto-threshold (works on
|
| 64 |
the synthetic fixture where brain ≫ background; for real data the
|
| 65 |
caller should pass an Otsu or BET-derived threshold explicitly).
|
| 66 |
-
2. Morphological opening (`scipy.ndimage.binary_opening`
|
| 67 |
-
isolated noise voxels and disconnected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
Args:
|
| 70 |
volume: 3-D numeric `np.ndarray` (must satisfy `is_valid_volume`).
|
|
@@ -77,5 +84,12 @@ def mask_brain(
|
|
| 77 |
intensity_threshold = float(volume.mean())
|
| 78 |
|
| 79 |
raw = volume > intensity_threshold
|
| 80 |
-
cleaned = scipy_ndimage.binary_opening(raw, iterations=1)
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
`None`, use the volume's mean as a robust auto-threshold (works on
|
| 64 |
the synthetic fixture where brain ≫ background; for real data the
|
| 65 |
caller should pass an Otsu or BET-derived threshold explicitly).
|
| 66 |
+
2. Morphological opening (`scipy.ndimage.binary_opening`, 6-connectivity,
|
| 67 |
+
iterations=1) to remove isolated noise voxels and disconnected
|
| 68 |
+
fragments. Note: thin features (< 3 voxels wide along any axis pair)
|
| 69 |
+
may be eroded entirely; for production data with cortical sheets or
|
| 70 |
+
sulcal bridges, prefer 26-connectivity or pass `iterations=0` upstream.
|
| 71 |
+
|
| 72 |
+
If the resulting mask is all-False (e.g. caller passed a threshold above
|
| 73 |
+
the volume's max intensity, or the volume is constant-valued), a WARNING
|
| 74 |
+
is emitted so silent feature-zeroing is visible in production logs.
|
| 75 |
|
| 76 |
Args:
|
| 77 |
volume: 3-D numeric `np.ndarray` (must satisfy `is_valid_volume`).
|
|
|
|
| 84 |
intensity_threshold = float(volume.mean())
|
| 85 |
|
| 86 |
raw = volume > intensity_threshold
|
| 87 |
+
cleaned = scipy_ndimage.binary_opening(raw, iterations=1).astype(bool)
|
| 88 |
+
if not cleaned.any():
|
| 89 |
+
logger.warning(
|
| 90 |
+
"mask_brain produced an all-False mask "
|
| 91 |
+
"(volume min=%.4f, max=%.4f, threshold=%.4f); "
|
| 92 |
+
"downstream features for this volume will be all-zero.",
|
| 93 |
+
float(volume.min()), float(volume.max()), intensity_threshold,
|
| 94 |
+
)
|
| 95 |
+
return cleaned
|
tests/pipelines/test_mri_pipeline.py
CHANGED
|
@@ -103,3 +103,28 @@ class TestMaskBrain:
|
|
| 103 |
# Without cleanup, the single voxel would survive. With morphological
|
| 104 |
# opening, it must be removed.
|
| 105 |
assert mask.sum() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
# Without cleanup, the single voxel would survive. With morphological
|
| 104 |
# opening, it must be removed.
|
| 105 |
assert mask.sum() == 0
|
| 106 |
+
|
| 107 |
+
def test_constant_volume_returns_all_false_mask_with_warning(self) -> None:
|
| 108 |
+
"""A constant-valued volume produces an empty mask AND logs a WARNING."""
|
| 109 |
+
import io
|
| 110 |
+
import logging
|
| 111 |
+
|
| 112 |
+
from src.core.logger import get_logger
|
| 113 |
+
from src.pipelines import mri_pipeline as mod
|
| 114 |
+
|
| 115 |
+
vol = np.full((8, 8, 8), 5.0, dtype=np.float64)
|
| 116 |
+
|
| 117 |
+
logger = get_logger(mod.__name__, level=logging.INFO)
|
| 118 |
+
handler = logger.handlers[0]
|
| 119 |
+
buf = io.StringIO()
|
| 120 |
+
original_stream = handler.stream
|
| 121 |
+
handler.stream = buf
|
| 122 |
+
try:
|
| 123 |
+
mask = mask_brain(vol)
|
| 124 |
+
finally:
|
| 125 |
+
handler.stream = original_stream
|
| 126 |
+
|
| 127 |
+
assert mask.sum() == 0
|
| 128 |
+
log_output = buf.getvalue()
|
| 129 |
+
assert "all-False mask" in log_output
|
| 130 |
+
assert "downstream features for this volume will be all-zero" in log_output
|