From 38e843761f7c3ba0c814c14e9ffe5776fbf7d56b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 14:51:08 +0000 Subject: [PATCH] fix(pipeline): survive heterogeneous or empty DICOM series in preprocessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_volume stacked all slice arrays with np.stack, which raises ValueError when a series mixes matrix sizes (embedded localizers, mixed acquisitions) or is empty — aborting the entire study with a raw numpy traceback. Now: deviating slices are dropped in favor of the series' dominant shape (with a warning), empty series raise a descriptive error, and PreprocessStep degrades per-series instead of crashing, recording skipped series in the report's limitations. Fixes #138 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W4svt5QTs4WUMSy4HwiVt9 --- src/medcheck/pipeline/preprocess.py | 34 ++++++++++++++- tests/unit/test_pipeline/test_preprocess.py | 46 +++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/medcheck/pipeline/preprocess.py b/src/medcheck/pipeline/preprocess.py index 478c249..7a00ebb 100644 --- a/src/medcheck/pipeline/preprocess.py +++ b/src/medcheck/pipeline/preprocess.py @@ -1,13 +1,17 @@ from __future__ import annotations import re +from collections import Counter from typing import Any import numpy as np +from rich.console import Console from medcheck.core.context import DicomSeries, PipelineContext from medcheck.core.step import PipelineStep +console = Console() + # --------------------------------------------------------------------------- # Keyword tables # --------------------------------------------------------------------------- @@ -96,9 +100,28 @@ def _extract_pixel_array(ds: Any) -> np.ndarray[Any, np.dtype[Any]]: def _build_volume(series: DicomSeries) -> np.ndarray: - """Sort slices, extract pixel arrays, stack, and normalise to [0, 1].""" + """Sort slices, extract pixel arrays, stack, and normalise to [0, 1]. + + Slices whose dimensions deviate from the series' dominant shape (mixed + matrix sizes, embedded localizers) are dropped with a warning instead of + letting ``np.stack`` fail; an empty series raises a descriptive error. + """ sorted_slices = sorted(series.slices, key=_sort_key) + if not sorted_slices: + raise ValueError("series contains no slices") arrays = [_extract_pixel_array(ds) for ds in sorted_slices] + + shape_counts = Counter(arr.shape for arr in arrays) + if len(shape_counts) > 1: + dominant_shape, _count = shape_counts.most_common(1)[0] + kept = [arr for arr in arrays if arr.shape == dominant_shape] + dropped = len(arrays) - len(kept) + console.print( + f"[yellow]Series '{series.description}': dropped {dropped} slice(s) " + f"with deviating dimensions (kept {len(kept)} at {dominant_shape}).[/yellow]" + ) + arrays = kept + volume = np.stack(arrays, axis=0) # shape: (N, H, W) v_min = volume.min() @@ -118,7 +141,14 @@ class PreprocessStep(PipelineStep): def run(self, context: PipelineContext) -> PipelineContext: for series in context.dicom_series: - context.volumes[series.description] = _build_volume(series) + try: + context.volumes[series.description] = _build_volume(series) + except Exception as exc: + # One malformed series must not abort the whole study; surface + # the gap in the report's limitations instead. + message = f"Series '{series.description}' skipped during preprocessing: {exc}" + console.print(f"[yellow]{message}[/yellow]") + context.limitations.append(message) # Detect anatomy from the first series description if context.dicom_series: diff --git a/tests/unit/test_pipeline/test_preprocess.py b/tests/unit/test_pipeline/test_preprocess.py index 9e16b4c..63a170b 100644 --- a/tests/unit/test_pipeline/test_preprocess.py +++ b/tests/unit/test_pipeline/test_preprocess.py @@ -91,3 +91,49 @@ def test_detect_plane(): def test_preprocess_step_name(): assert PreprocessStep().name == "preprocess" + + +def test_preprocess_heterogeneous_slice_shapes_keeps_dominant_group(): + slices = [_make_slice(instance_num=i, slice_loc=float(i)) for i in range(4)] + slices.append(_make_slice(rows=32, cols=32, instance_num=5, slice_loc=5.0)) # localizer-sized outlier + series = DicomSeries(description="mixed", series_number=1, modality="MR", slices=slices) + ctx = PipelineContext(dicom_series=[series]) + + result = PreprocessStep().run(ctx) + + assert result.volumes["mixed"].shape == (4, 64, 64) + + +def test_preprocess_empty_series_is_skipped_with_limitation(): + empty = DicomSeries(description="empty", series_number=1, modality="MR", slices=[]) + good = DicomSeries( + description="good", + series_number=2, + modality="MR", + slices=[_make_slice(instance_num=i, slice_loc=float(i)) for i in range(3)], + ) + ctx = PipelineContext(dicom_series=[empty, good]) + + result = PreprocessStep().run(ctx) + + assert "empty" not in result.volumes + assert "good" in result.volumes + assert any("empty" in note for note in result.limitations) + + +def test_preprocess_bad_series_does_not_abort_study(): + bad_slice = Dataset() # no Rows/Columns/PixelData at all + bad = DicomSeries(description="broken", series_number=1, modality="MR", slices=[bad_slice]) + good = DicomSeries( + description="good", + series_number=2, + modality="MR", + slices=[_make_slice(instance_num=i, slice_loc=float(i)) for i in range(3)], + ) + ctx = PipelineContext(dicom_series=[bad, good]) + + result = PreprocessStep().run(ctx) + + assert "broken" not in result.volumes + assert "good" in result.volumes + assert any("broken" in note for note in result.limitations)