Skip to content

Commit fca0d57

Browse files
Fix set -u viz crash + extract DWI trace from 4D in contrast-matched cascade
visualization.sh: colors[$i] was unbound (4 colors vs 7+ thresholds) and misaligned after `sort -n`; use a fixed palette cycled by modulo. Fixes the pipeline-breaking `colors[$i]: unbound variable` at Step 7 in both generate_qc_visualizations and create_multi_threshold_overlays. registration.sh: the contrast-matched cascade now extracts the b>0 DWI trace (max-b via the .bval sidecar, else the last volume) from a multi-volume DWI and registers it, instead of discarding the modality. New extract_dwi_trace_from_4d helper, gated by CONTRAST_MATCHED_DWI_EXTRACT_TRACE (default on). The registered output keeps the _trace basename so cross-modal DWI discovery matches it. Non-DWI 4D inputs are still skipped. Adds 8 regression tests to test_contrast_matched_registration_unit.sh (39 total). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e3b7b27 commit fca0d57

4 files changed

Lines changed: 157 additions & 19 deletions

File tree

config/default_config.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,14 @@ export CONTRAST_MATCHED_INTENSITY_INTERP="Linear"
507507
# persisted composed forward/inverse transform lists, and a transform manifest.
508508
export CONTRAST_MATCHED_SUBDIR="contrast_matched"
509509

510+
# A DWI series often arrives as a small multi-volume stack (e.g. dcm2niix exports
511+
# it as [b0, trace]) rather than a pre-derived single-contrast trace. When true
512+
# (default) the cascade EXTRACTS the b>0 trace (max b-value volume via the .bval
513+
# sidecar, else the last volume) from such a 4D DWI and registers that, instead
514+
# of discarding the whole modality. Set false to revert to skipping multi-volume
515+
# DWI. Non-DWI 4D inputs are always skipped (anomalous, not a single contrast).
516+
export CONTRAST_MATCHED_DWI_EXTRACT_TRACE=true
517+
510518
# ===========================================================================
511519
# MULTI-MODALITY PROCESSING (SWI / DWI / T2 end-to-end + cross-modal analysis)
512520
# ===========================================================================

src/modules/registration.sh

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1697,6 +1697,71 @@ register_contrast_matched_modality() {
16971697
return 0
16981698
}
16991699

1700+
# ---------------------------------------------------------------------------
1701+
# extract_dwi_trace_from_4d <4d_image> <out_dir>
1702+
#
1703+
# A DWI series frequently arrives from dcm2niix as a small multi-volume stack
1704+
# (commonly [b0, trace]) rather than a pre-derived single-contrast trace.
1705+
# Registering the whole stack as "one modality" is meaningless and
1706+
# antsApplyTransforms -d 3 aborts on it — but the diagnostically useful b>0
1707+
# TRACE is a perfectly good single-contrast 3D image. This extracts that trace
1708+
# so the contrast-matched cascade can bring the DWI all the way through instead
1709+
# of discarding it ("use the 4D to get the best 3D").
1710+
#
1711+
# Volume selection:
1712+
# * If a .bval sidecar sits next to the image, extract the volume with the
1713+
# maximum b-value (the trace / strongest diffusion weighting).
1714+
# * Otherwise extract the LAST volume — dcm2niix writes volumes in ascending
1715+
# b-value order, so [b0, trace] -> trace is the last index (matches the
1716+
# existing precedent in analyze_multimodal_brainstem.sh).
1717+
#
1718+
# NOTE: for a true multi-direction acquisition the proper trace is the mean of
1719+
# the high-b directions; a precomputed trace is preferred there. For the common
1720+
# 2-volume [b0, trace] export this picks the trace exactly.
1721+
#
1722+
# Echoes the path to the extracted 3D trace on stdout on success (log lines go
1723+
# to stderr, so the capture is clean); echoes nothing and returns non-zero on
1724+
# failure.
1725+
# ---------------------------------------------------------------------------
1726+
extract_dwi_trace_from_4d() {
1727+
local image="$1"
1728+
local out_dir="$2"
1729+
1730+
local nvol
1731+
nvol="$(fslval "$image" dim4 2>/dev/null | tr -d ' ')"
1732+
[ -n "$nvol" ] || nvol=1
1733+
1734+
# Locate a .bval sidecar (dcm2niix writes it next to the .nii.gz).
1735+
local base bval="" cand idx=""
1736+
base="${image%.nii.gz}"; base="${base%.nii}"
1737+
for cand in "${base}.bval" "${base}.bvals"; do
1738+
if [ -f "$cand" ]; then bval="$cand"; break; fi
1739+
done
1740+
1741+
if [ -n "$bval" ]; then
1742+
# 0-based index of the maximum b-value across the whole sidecar.
1743+
idx="$(awk '{ for (i=1;i<=NF;i++) { v=$i+0; if (n==0 || v>max) { max=v; mi=n } n++ } }
1744+
END { if (n>0) print mi }' "$bval" 2>/dev/null)"
1745+
fi
1746+
1747+
# Fallback: highest-index volume (ascending-b ordering => trace is last).
1748+
if ! [ "${idx:-x}" -ge 0 ] 2>/dev/null; then
1749+
idx=$(( nvol - 1 ))
1750+
fi
1751+
[ "$idx" -ge 0 ] 2>/dev/null || idx=0
1752+
1753+
mkdir -p "$out_dir"
1754+
local out="${out_dir}/$(basename "$base")_trace.nii.gz"
1755+
if fslroi "$image" "$out" "$idx" 1 2>/dev/null && [ -f "$out" ]; then
1756+
log_message "Extracted DWI trace (volume ${idx} of ${nvol}) from 4D stack -> $(basename "$out")"
1757+
echo "$out"
1758+
return 0
1759+
fi
1760+
1761+
log_formatted "WARNING" "Failed to extract a 3D trace volume from $(basename "$image")"
1762+
return 1
1763+
}
1764+
17001765
# Orchestrate contrast-matched registration for all present T2-weighted modalities.
17011766
#
17021767
# Usage:
@@ -1729,7 +1794,7 @@ register_contrast_matched_cascade() {
17291794

17301795
local overall_status=0
17311796
local processed=0
1732-
local spec name path anchor nvol
1797+
local spec name path anchor nvol trace_path
17331798
for spec in "${specs[@]}"; do
17341799
name="${spec%%=*}"
17351800
path="${spec#*=}"
@@ -1745,14 +1810,28 @@ register_contrast_matched_cascade() {
17451810
continue
17461811
fi
17471812

1748-
# Refuse 4D / multi-volume inputs (raw DWI stack: b0+trace, trace+ADC,
1749-
# multi-b). These are not single-contrast 3D images, so registering them
1750-
# as one modality is meaningless and antsApplyTransforms -d 3 aborts. The
1751-
# derived 3D trace/ADC, if present, come through as their own specs.
1813+
# Multi-volume inputs are not single-contrast 3D images: registering the
1814+
# whole stack as one modality is meaningless and antsApplyTransforms -d 3
1815+
# aborts on it. For DWI this is the COMMON case (dcm2niix exports the
1816+
# series as e.g. [b0, trace]); rather than discard it, extract the b>0
1817+
# TRACE — a perfectly good single-contrast 3D image — and register that
1818+
# (gated by CONTRAST_MATCHED_DWI_EXTRACT_TRACE, default on). For any
1819+
# other modality a 4D image is anomalous, so we still skip it.
17521820
nvol="$(fslval "$path" dim4 2>/dev/null | tr -d ' ')"
17531821
if [ "${nvol:-1}" -gt 1 ] 2>/dev/null; then
1754-
log_formatted "WARNING" "Skipping ${name}: 4D/multi-volume image (${nvol} vols) is not a single-contrast 3D modality"
1755-
continue
1822+
if [ "${name^^}" = "DWI" ] && [ "${CONTRAST_MATCHED_DWI_EXTRACT_TRACE:-true}" = "true" ]; then
1823+
trace_path="$(extract_dwi_trace_from_4d "$path" "$output_dir")"
1824+
if [ -n "$trace_path" ] && [ -f "$trace_path" ]; then
1825+
log_message "Using extracted DWI trace for the cascade: $(basename "$trace_path")"
1826+
path="$trace_path"
1827+
else
1828+
log_formatted "WARNING" "Skipping ${name}: could not extract a 3D trace from the ${nvol}-volume stack"
1829+
continue
1830+
fi
1831+
else
1832+
log_formatted "WARNING" "Skipping ${name}: 4D/multi-volume image (${nvol} vols) is not a single-contrast 3D modality"
1833+
continue
1834+
fi
17561835
fi
17571836

17581837
processed=$((processed + 1))
@@ -1780,6 +1859,7 @@ export -f register_multiple_to_reference
17801859
export -f register_all_modalities
17811860
export -f resolve_contrast_anchor
17821861
export -f register_contrast_matched_modality
1862+
export -f extract_dwi_trace_from_4d
17831863
export -f register_contrast_matched_cascade
17841864

17851865
# Helper function to prepare white matter segmentation

src/modules/visualization.sh

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,11 @@ generate_qc_visualizations() {
253253
# Include the configured threshold alongside defaults
254254
local threshold_multiplier="${THRESHOLD_WM_SD_MULTIPLIER:-1.25}"
255255
local thresholds=(1.2 1.25 1.3 1.5 2.0 2.5 3.0)
256-
local colors=("red" "orange" "yellow" "green")
257-
256+
# Color palette is cycled by index (modulo) in the loop below, so it
257+
# never has to match the threshold count one-to-one — avoids the
258+
# `colors[$i]: unbound variable` crash under `set -u`.
259+
local colors=("red" "orange" "yellow" "green" "cyan" "blue" "magenta")
260+
258261
# Add configured threshold if not already present
259262
local configured_included=false
260263
for thresh in "${thresholds[@]}"; do
@@ -263,11 +266,10 @@ generate_qc_visualizations() {
263266
break
264267
fi
265268
done
266-
269+
267270
if [ "$configured_included" = false ]; then
268271
thresholds+=("$threshold_multiplier")
269-
colors+=("cyan") # Add color for the configured threshold
270-
# Sort the thresholds (keeping colors aligned)
272+
# Sort the thresholds (colors are assigned by position in the loop)
271273
IFS=$'\n' thresholds=($(sort -n <<<"${thresholds[*]}"))
272274
unset IFS
273275
fi
@@ -277,7 +279,7 @@ generate_qc_visualizations() {
277279

278280
for i in "${!thresholds[@]}"; do
279281
local mult="${thresholds[$i]}"
280-
local color="${colors[$i]}"
282+
local color="${colors[$(( i % ${#colors[@]} ))]}"
281283
# Use intensity version for proper heat colormap visualization
282284
local hyper_intensity="${subject_dir}/hyperintensities/${subject_id}_pons_thresh${mult}_intensity.nii.gz"
283285
local hyper="${subject_dir}/hyperintensities/${subject_id}_pons_thresh${mult}.nii.gz"
@@ -353,8 +355,11 @@ create_multi_threshold_overlays() {
353355
# Define thresholds and colors - include configured threshold
354356
local threshold_multiplier="${THRESHOLD_WM_SD_MULTIPLIER:-1.25}"
355357
local thresholds=(1.2 1.25 1.3 1.5 2.0 2.5 3.0)
356-
local colors=("red" "orange" "yellow" "green")
357-
358+
# Color palette is cycled by index (modulo) in the loop below, so it
359+
# never has to match the threshold count one-to-one — avoids the
360+
# `colors[$i]: unbound variable` crash under `set -u`.
361+
local colors=("red" "orange" "yellow" "green" "cyan" "blue" "magenta")
362+
358363
# Add configured threshold if not already present
359364
local configured_included=false
360365
for thresh in "${thresholds[@]}"; do
@@ -363,11 +368,10 @@ create_multi_threshold_overlays() {
363368
break
364369
fi
365370
done
366-
371+
367372
if [ "$configured_included" = false ]; then
368373
thresholds+=("$threshold_multiplier")
369-
colors+=("cyan") # Add color for the configured threshold
370-
# Sort the thresholds (keeping colors aligned)
374+
# Sort the thresholds (colors are assigned by position in the loop)
371375
IFS=$'\n' thresholds=($(sort -n <<<"${thresholds[*]}"))
372376
unset IFS
373377
fi
@@ -377,7 +381,7 @@ create_multi_threshold_overlays() {
377381

378382
for i in "${!thresholds[@]}"; do
379383
local mult="${thresholds[$i]}"
380-
local color="${colors[$i]}"
384+
local color="${colors[$(( i % ${#colors[@]} ))]}"
381385
# Use intensity version for proper heat colormap visualization
382386
local hyper_intensity="${subject_dir}/hyperintensities/${subject_id}_brainstem_thresh${mult}_intensity.nii.gz"
383387
local hyper="${subject_dir}/hyperintensities/${subject_id}_brainstem_thresh${mult}.nii.gz"

tests/test_contrast_matched_registration_unit.sh

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,52 @@ register_contrast_matched_cascade "$t1" "$flair" "$f2t1" "$empty_out"
205205
assert_equals "0" "$?" "cascade with no specs returns 0"
206206
assert_equals "0" "$(_count_composed "$empty_out")" "cascade with no specs registers nothing"
207207

208+
# ══════════════════════════════════════════════════════════════════════════════
209+
# 6. Multi-volume DWI -> trace extraction (use the 4D to get the best 3D)
210+
# ══════════════════════════════════════════════════════════════════════════════
211+
begin_test_group "6. multi-volume DWI trace extraction"
212+
213+
assert_function_exists "extract_dwi_trace_from_4d" "extract_dwi_trace_from_4d defined"
214+
215+
casc6="$TEMP_TEST_DIR/casc6"
216+
mkdir -p "$casc6"
217+
raw_dwi="$casc6/DWI_b0trace.nii.gz"; : > "$raw_dwi" # raw 4D [b0, trace] stack
218+
219+
# Mock FSL: the raw stack reports dim4=2; everything else (incl. the extracted
220+
# 3D trace) reports 1. fslroi records the extracted volume index and creates the
221+
# output so the rest of the cascade proceeds.
222+
FSLROI_IDX_FILE="$casc6/fslroi_idx"
223+
fslval() { if [ "$1" = "$raw_dwi" ]; then echo 2; else echo 1; fi; }
224+
fslroi() { echo "$3" > "$FSLROI_IDX_FILE"; : > "$2"; return 0; }
225+
226+
# 6a. Direct: with a .bval sidecar the MAX b-value volume (idx 1) is chosen.
227+
printf '0 1000\n' > "$casc6/DWI_b0trace.bval"
228+
trace_out="$(extract_dwi_trace_from_4d "$raw_dwi" "$casc6/traceout")"
229+
assert_equals "1" "$(cat "$FSLROI_IDX_FILE" 2>/dev/null)" "extract picks max-b volume (idx 1) from .bval"
230+
assert_equals "$casc6/traceout/DWI_b0trace_trace.nii.gz" "$trace_out" "extract echoes a clean trace path (no log leakage)"
231+
232+
# 6b. Cascade: a multi-volume DWI is now EXTRACTED + registered, not skipped.
233+
casc6_run="$casc6/run_on"
234+
mkdir -p "$casc6_run"
235+
run_dwi="$casc6_run/DWI_b0trace.nii.gz"; : > "$run_dwi"
236+
fslval() { if [ "$1" = "$run_dwi" ]; then echo 2; else echo 1; fi; }
237+
CONTRAST_MATCHED_DWI_EXTRACT_TRACE=true \
238+
register_contrast_matched_cascade "$t1" "$flair" "$f2t1" "$casc6_run" "DWI=$run_dwi"
239+
assert_equals "0" "$?" "cascade with multi-volume DWI returns 0 (trace extracted)"
240+
assert_equals "1" "$(_count_composed "$casc6_run")" "multi-volume DWI is registered via its extracted trace (not skipped)"
241+
assert_file_exists "$casc6_run/DWI_b0trace_trace_to_t1_composedWarped.nii.gz" \
242+
"registered output carries the _trace basename (cross-modal DWI discovery matches it)"
243+
244+
# 6c. Toggle OFF => revert to skipping the multi-volume DWI.
245+
casc6_off="$casc6/run_off"
246+
mkdir -p "$casc6_off"
247+
off_dwi="$casc6_off/DWI_b0trace.nii.gz"; : > "$off_dwi"
248+
fslval() { if [ "$1" = "$off_dwi" ]; then echo 2; else echo 1; fi; }
249+
CONTRAST_MATCHED_DWI_EXTRACT_TRACE=false \
250+
register_contrast_matched_cascade "$t1" "$flair" "$f2t1" "$casc6_off" "DWI=$off_dwi"
251+
assert_equals "0" "$?" "cascade returns 0 even when the 4D DWI is skipped"
252+
assert_equals "0" "$(_count_composed "$casc6_off")" "toggle off => multi-volume DWI is skipped"
253+
208254
# ── Summary ───────────────────────────────────────────────────────────────────
209255
cleanup_test_environment
210256
print_test_summary

0 commit comments

Comments
 (0)