bcaae5ec22
Rename the distribution to image-filter and imports to image_filter. Keep medical fixtures local and document their setup for fresh clones. Include composite pipelines, consistent examples, and class diagrams.
131 lines
6.1 KiB
Python
131 lines
6.1 KiB
Python
"""Example-only local medical I/O; the filter library remains NumPy-only."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import nibabel as nib
|
|
import numpy as np
|
|
|
|
SAMPLES = Path(__file__).resolve().parents[1] / 'data/samples'
|
|
CASE_NAMES = ('mri', 'pet', 'ct', 'spect', 'ultrasound', 'mr-dicom', 'ct-series')
|
|
|
|
|
|
@dataclass
|
|
class MedicalImage:
|
|
pixels: np.ndarray
|
|
modality: str
|
|
format: str
|
|
source: Path
|
|
orientation: str
|
|
files: int = 1
|
|
|
|
|
|
def bundled_cases() -> dict[str, tuple[Path, str]]:
|
|
cases = {name.lower(): (SAMPLES / f'{name}.nii.gz', name) for name in ['MRI', 'PET', 'CT', 'SPECT', 'Ultrasound']}
|
|
try:
|
|
import pydicom
|
|
root = Path(pydicom.__file__).parent / 'data/test_files'
|
|
cases['mr-dicom'] = (root / 'MR_small.dcm', 'MR')
|
|
cases['ct-series'] = (root / 'dicomdirtests/98892001/CT5N', 'CT')
|
|
except ImportError:
|
|
pass
|
|
return cases
|
|
|
|
|
|
def _validate(pixels: np.ndarray) -> np.ndarray:
|
|
# Only singleton extras are removed; time/channel dimensions are rejected.
|
|
pixels = np.squeeze(pixels)
|
|
if pixels.ndim not in (2, 3) or pixels.dtype.kind not in 'buif':
|
|
raise ValueError('Only scalar 2D/3D medical images are supported; select time/channel externally')
|
|
if not np.isfinite(pixels).all():
|
|
raise ValueError('Medical image contains nonfinite values')
|
|
return pixels
|
|
|
|
|
|
def load_medical(path: Path, modality: str = 'Unknown', series_uid: str | None = None) -> MedicalImage:
|
|
path = path.resolve()
|
|
if not path.exists():
|
|
raise FileNotFoundError(f'Local medical fixture unavailable: {path}; no download attempted')
|
|
if path.name.lower().endswith(('.nii', '.nii.gz')):
|
|
source = nib.as_closest_canonical(nib.load(path))
|
|
pixels = _validate(source.get_fdata())
|
|
return MedicalImage(pixels, modality, 'NIfTI', path, 'RAS voxel axes')
|
|
if path.is_dir() or path.suffix.lower() in ('.nrrd', '.nhdr'):
|
|
import SimpleITK as sitk
|
|
count = 1
|
|
if path.is_dir():
|
|
ids = sitk.ImageSeriesReader.GetGDCMSeriesIDs(str(path)) or ()
|
|
if not ids:
|
|
raise ValueError(f'No DICOM series in {path.name}')
|
|
if series_uid is None and len(ids) != 1:
|
|
raise ValueError('Multiple DICOM series found; select one with --series-uid')
|
|
uid = series_uid or ids[0]
|
|
if uid not in ids:
|
|
raise ValueError('--series-uid not found in directory')
|
|
files = sitk.ImageSeriesReader.GetGDCMSeriesFileNames(str(path), uid)
|
|
if len(files) < 2:
|
|
raise ValueError('DICOM series directory must contain at least two slices')
|
|
import pydicom
|
|
modality = str(pydicom.dcmread(files[0], stop_before_pixels=True).get('Modality', modality))
|
|
reader = sitk.ImageSeriesReader()
|
|
reader.SetFileNames(files)
|
|
source = reader.Execute()
|
|
count = len(files)
|
|
format_name = 'DICOM series'
|
|
else:
|
|
source = sitk.ReadImage(str(path))
|
|
format_name = 'NRRD'
|
|
if source.GetNumberOfComponentsPerPixel() != 1:
|
|
raise ValueError('Vector/color images are not supported in this medical demo')
|
|
if source.GetDimension() == 3:
|
|
source = sitk.DICOMOrient(source, 'RAS')
|
|
pixels = sitk.GetArrayFromImage(source)
|
|
# SimpleITK arrays are z,y,x; expose x,y,z like canonical nibabel.
|
|
pixels = pixels.transpose(tuple(reversed(range(pixels.ndim))))
|
|
return MedicalImage(_validate(pixels), modality, format_name, path, 'RAS voxel axes' if source.GetDimension() == 3 else 'native axes', count)
|
|
if path.suffix.lower() == '.dcm':
|
|
import pydicom
|
|
from pydicom.pixels import apply_modality_lut
|
|
source = pydicom.dcmread(path)
|
|
if int(source.get('SamplesPerPixel', 1)) != 1 or int(source.get('NumberOfFrames', 1)) != 1:
|
|
raise ValueError('Single DICOM accepts one monochrome frame; use a series directory for volumes')
|
|
pixels = apply_modality_lut(source.pixel_array, source).astype(np.float64)
|
|
if source.get('PhotometricInterpretation') == 'MONOCHROME1':
|
|
pixels = pixels.max() + pixels.min() - pixels
|
|
return MedicalImage(_validate(pixels), str(source.get('Modality', modality)), 'DICOM', path, 'native row/column')
|
|
raise ValueError('Expected .nii/.nii.gz, .nrrd/.nhdr, .dcm, or a DICOM series directory')
|
|
|
|
|
|
def representative_plane(case: MedicalImage, axis: int = 2, index: int | None = None) -> tuple[np.ndarray, dict]:
|
|
pixels = case.pixels
|
|
selected = None
|
|
if pixels.ndim == 3:
|
|
if index is not None and not 0 <= index < pixels.shape[axis]:
|
|
raise ValueError(f'Slice index outside axis length {pixels.shape[axis]}')
|
|
if index is None:
|
|
# Ignore border slices; maximize contrast among the central 80%.
|
|
lo = int(pixels.shape[axis] * .1)
|
|
hi = max(lo + 1, int(np.ceil(pixels.shape[axis] * .9)))
|
|
scores = np.std(pixels, axis=tuple(i for i in range(3) if i != axis))
|
|
selected = lo + int(np.argmax(scores[lo:hi]))
|
|
else:
|
|
selected = index
|
|
plane = np.rot90(np.take(pixels, selected, axis=axis))
|
|
else:
|
|
if index is not None:
|
|
raise ValueError('--slice-index requires a volume')
|
|
plane = pixels
|
|
low, high = np.percentile(plane, (1, 99))
|
|
if high <= low:
|
|
low, high = float(plane.min()), float(plane.max())
|
|
if high <= low:
|
|
raise ValueError('Selected slice has no intensity variation')
|
|
image = np.clip((plane - low) / (high - low), 0, 1).astype(np.float64)
|
|
info = {'modality': case.modality, 'format': case.format, 'source': case.source.name,
|
|
'volume_shape': list(pixels.shape), 'orientation': case.orientation,
|
|
'slice_axis': axis if selected is not None else None, 'slice_index': selected,
|
|
'window': [float(low), float(high)], 'files': case.files,
|
|
'input_shape': list(image.shape), 'processing': 'all filters independently on selected 2D plane'}
|
|
return image, info
|