feat: initialize standalone image-filter
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.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""One-filter pipeline."""
|
||||
from image_filter import GaussianFilter, ImageFilter, Pipeline
|
||||
from example_support import run_example
|
||||
|
||||
|
||||
def build_operation() -> ImageFilter:
|
||||
return Pipeline([GaussianFilter(sigma=1.0)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_example(build_operation(), name="01-single-filter")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Mean → wavelet → mean: each step consumes the previous output."""
|
||||
from image_filter import ImageFilter, MeanFilter, Pipeline, WaveletFilter
|
||||
from example_support import run_example
|
||||
|
||||
|
||||
def build_operation() -> ImageFilter:
|
||||
return Pipeline([
|
||||
MeanFilter(size=3),
|
||||
WaveletFilter(configuration="LH", wavelet="db2"),
|
||||
MeanFilter(size=4, origin=-1),
|
||||
])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_example(build_operation(), name="02-sequential-filters")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Reuse preprocessing as a child of another pipeline."""
|
||||
from image_filter import GaussianFilter, ImageFilter, MedianFilter, Pipeline, SobelFilter
|
||||
from example_support import run_example
|
||||
|
||||
|
||||
def build_operation() -> ImageFilter:
|
||||
preprocessing = Pipeline([MedianFilter(size=3), GaussianFilter(sigma=0.8)])
|
||||
return Pipeline([preprocessing, SobelFilter()])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_example(build_operation(), name="03-nested-pipeline")
|
||||
@@ -0,0 +1,11 @@
|
||||
"""A direct leaf uses exactly the same runner as a composite."""
|
||||
from image_filter import GaussianFilter, ImageFilter
|
||||
from example_support import run_example
|
||||
|
||||
|
||||
def build_operation() -> ImageFilter:
|
||||
return GaussianFilter(sigma=1.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_example(build_operation(), name="04-component-api")
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Gaussian → Canny; use --mode slice-wise for every volume slice."""
|
||||
from image_filter import CannyFilter, GaussianFilter, ImageFilter, Pipeline
|
||||
from example_support import run_example
|
||||
|
||||
|
||||
def build_operation() -> ImageFilter:
|
||||
return Pipeline([
|
||||
GaussianFilter(sigma=1.0),
|
||||
CannyFilter(low_threshold=0.1, high_threshold=0.3, use_quantiles=True),
|
||||
])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_example(build_operation(), name="05-slicewise-pipeline")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""One execution path for every example; I/O stays outside the library."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from medical_io import CASE_NAMES, bundled_cases, load_medical, representative_plane
|
||||
from image_filter import ImageFilter, SliceWise
|
||||
|
||||
|
||||
def run_example(operation: ImageFilter, name: str, argv: list[str] | None = None) -> None:
|
||||
"""Load → adapt explicitly → apply → save, for leaves and pipelines alike."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
source = parser.add_mutually_exclusive_group()
|
||||
source.add_argument("--case", choices=CASE_NAMES, default="mri")
|
||||
source.add_argument("--input", type=Path, help="NIfTI, NRRD, DICOM file or series directory")
|
||||
parser.add_argument("--series-uid", help="Select among multiple DICOM series")
|
||||
parser.add_argument("--modality", default="Unknown")
|
||||
parser.add_argument("--mode", choices=("plane", "volume", "slice-wise"), default="plane")
|
||||
parser.add_argument("--axis", type=int, choices=(0, 1, 2), default=2)
|
||||
parser.add_argument("--output-dir", type=Path,
|
||||
default=Path(__file__).resolve().parents[1] / "example-output" / "pipelines")
|
||||
args = parser.parse_args(argv)
|
||||
if args.input is not None:
|
||||
path, modality = args.input, args.modality
|
||||
else:
|
||||
catalog = bundled_cases()
|
||||
if args.case not in catalog:
|
||||
parser.error(f"Local case unavailable: {args.case}")
|
||||
path, modality = catalog[args.case]
|
||||
case = load_medical(path, modality, args.series_uid)
|
||||
if args.mode == "plane":
|
||||
image, metadata = representative_plane(case, axis=args.axis)
|
||||
metadata["processing"] = "sequential processing on selected 2D plane"
|
||||
else:
|
||||
image = case.pixels
|
||||
metadata = {"modality": case.modality, "format": case.format,
|
||||
"spatial_dims": image.ndim, "processing": args.mode}
|
||||
if args.mode == "slice-wise" and image.ndim == 3:
|
||||
operation = SliceWise(operation, axis=args.axis)
|
||||
# Native 2D inputs need no adapter. Volume mode never silently slices.
|
||||
result = operation.apply(image, metadata)
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = args.output_dir / f"{name}.npy"
|
||||
np.save(output, result.image)
|
||||
output.with_suffix(".json").write_text(json.dumps(dict(result.metadata), indent=2) + "\n")
|
||||
history = [entry["filter"] for entry in result.metadata["processing_history"]]
|
||||
print(f"{' -> '.join(history)} | shape={result.image.shape} | {output.resolve()}")
|
||||
@@ -0,0 +1,130 @@
|
||||
"""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
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Offline medical-image filter comparisons from local volumes and DICOM."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import json
|
||||
import re
|
||||
import textwrap
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from medical_io import CASE_NAMES, bundled_cases, load_medical, representative_plane
|
||||
|
||||
from image_filter import (
|
||||
CannyFilter, GaussianFilter, MedianFilter, SobelFilter, LaplacianFilter,
|
||||
GaborFilter, LawsFilter, LoGFilter, MeanFilter, WaveletFilter, Pipeline,
|
||||
)
|
||||
|
||||
DEFAULT_DIR = Path(__file__).resolve().parents[1] / "example-output"
|
||||
STYLE = {"font.family": "DejaVu Sans", "font.size": 11,
|
||||
"text.color": "#18283b", "axes.titlecolor": "#18283b",
|
||||
"figure.facecolor": "#f4f6f9", "savefig.facecolor": "#f4f6f9"}
|
||||
|
||||
|
||||
def render_panel(name: str, image: np.ndarray, info: dict, output: Path) -> None:
|
||||
operations = [
|
||||
("Mean 5×5", MeanFilter(5)),
|
||||
("Gaussian σ=1.5", GaussianFilter(1.5)),
|
||||
("Median 5×5", MedianFilter(5)),
|
||||
("Sobel magnitude", SobelFilter()),
|
||||
("Laplacian", LaplacianFilter()),
|
||||
("Canny σ=1.2 (quantiles .1/.3)", CannyFilter(1.2, .1, .3, True)),
|
||||
("Laws L5E5 energy r=3", LawsFilter("L5E5", energy_radius=3)),
|
||||
("LoG σ=1.5", LoGFilter(1.5)),
|
||||
("Gabor magnitude", GaborFilter(0.15)),
|
||||
("SWT LH (db2)", WaveletFilter("LH", "db2")),
|
||||
]
|
||||
# One-step composites preserve the existing independent comparisons.
|
||||
panels = [("Input", image)] + [
|
||||
(title, Pipeline([operation]).apply(image).image)
|
||||
for title, operation in operations
|
||||
]
|
||||
with plt.rc_context(STYLE):
|
||||
figure, axes = plt.subplots(3, 4, figsize=(16, 12))
|
||||
figure.subplots_adjust(left=.025, right=.975, bottom=.035, top=.88,
|
||||
hspace=.23, wspace=.10)
|
||||
figure.text(.025, .965, "IMAGE FILTER", fontsize=12, weight="bold", color="#426080")
|
||||
figure.text(.025, .925, f"{info['modality']} · {info['format']} · {name}", fontsize=23, weight="bold")
|
||||
for index, (axis, (title, values)) in enumerate(zip(axes.flat, panels)):
|
||||
limits = (0, 1) if values.dtype == np.bool_ else np.percentile(values, (1, 99))
|
||||
axis.imshow(values, cmap="gray", vmin=limits[0], vmax=limits[1])
|
||||
axis.set_title(f"{index:02d} {title}", fontsize=12, loc="left", pad=9)
|
||||
axis.axis("off")
|
||||
note = axes.flat[-1]
|
||||
note.axis("off")
|
||||
selection = f"Axis {info['slice_axis']} / slice {info['slice_index']}" if info['slice_index'] is not None else "Native 2D frame"
|
||||
source_label = textwrap.fill(info['source'], width=30)
|
||||
note.text(.02, .95, "CASE / PROCESSING", transform=note.transAxes, weight="bold", fontsize=12)
|
||||
note.text(.02, .83,
|
||||
f"Source: {source_label}\n{selection} · {info['files']} file(s)\nInput: {image.shape[1]} × {image.shape[0]}\n{info['orientation']}\n\n1–99% window → [0, 1] input.\nAll filters act on this 2D slice.\nIndependent output display contrast.\nCanny: boolean edges.",
|
||||
transform=note.transAxes, fontsize=10, va="top", linespacing=1.55)
|
||||
figure.savefig(output, dpi=150, metadata={"Title": f"Image Filter comparison: {name}"})
|
||||
plt.close(figure)
|
||||
|
||||
|
||||
def render_overview(originals: dict[str, tuple[np.ndarray, dict]], output: Path) -> None:
|
||||
rows = (len(originals) + 2) // 3
|
||||
with plt.rc_context(STYLE):
|
||||
figure, axes = plt.subplots(rows, 3, figsize=(12, 4 * rows + 1), squeeze=False)
|
||||
figure.subplots_adjust(left=.035, right=.965, bottom=.045, top=1 - 1.25 / (4 * rows + 1),
|
||||
hspace=.25, wspace=.12)
|
||||
figure.suptitle("Image Filter · medical imaging examples", fontsize=20, weight="bold", y=.975)
|
||||
for axis in axes.flat:
|
||||
axis.axis("off")
|
||||
for axis, (name, (original, info)) in zip(axes.flat, originals.items()):
|
||||
axis.imshow(original, cmap="gray", vmin=0, vmax=1)
|
||||
axis.set_anchor("N")
|
||||
axis.set_title(f"{info['modality']} · {info['format']}\n{name}-filters.png", fontsize=12, loc="left")
|
||||
figure.savefig(output, dpi=150)
|
||||
plt.close(figure)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
selection = parser.add_mutually_exclusive_group()
|
||||
selection.add_argument("--all", action="store_true", help="All available local medical fixtures")
|
||||
selection.add_argument("--images", nargs="+", choices=CASE_NAMES, help="Select local medical cases")
|
||||
parser.add_argument("--input", type=Path, nargs="+", default=[], help="User NIfTI, NRRD or single DICOM paths")
|
||||
parser.add_argument("--dicom-series", type=Path, nargs="+", default=[], help="DICOM series directories (one volume each)")
|
||||
parser.add_argument("--series-uid", help="Select a series when a directory contains multiple series")
|
||||
parser.add_argument("--modality", default="Unknown", help="Modality label for user NIfTI/NRRD files")
|
||||
parser.add_argument("--slice-axis", type=int, choices=(0, 1, 2), default=2, help="Canonical voxel axis for volumes")
|
||||
parser.add_argument("--slice-index", type=int, help="Override deterministic non-empty slice selection")
|
||||
destination = parser.add_mutually_exclusive_group()
|
||||
destination.add_argument("--output", type=Path, help="Single PNG output")
|
||||
destination.add_argument("--output-dir", type=Path, default=None, help="Default: package example-output directory")
|
||||
parser.add_argument("--contact-sheet", action="store_true", help="Write source-overview.png")
|
||||
args = parser.parse_args(argv)
|
||||
args.names = list(CASE_NAMES) if args.all else list(dict.fromkeys(args.images or ([] if args.input or args.dicom_series else ['mri'])))
|
||||
count = len(args.names) + len(args.input) + len(args.dicom_series)
|
||||
if args.output and (count != 1 or args.output.suffix.lower() != '.png'):
|
||||
parser.error('--output requires exactly one source and a .png extension')
|
||||
if args.output and args.output.name == 'source-overview.png':
|
||||
parser.error('--output must not use the reserved overview filename')
|
||||
return args
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
args = parse_args(argv)
|
||||
catalog = bundled_cases()
|
||||
selections = []
|
||||
for name in args.names:
|
||||
if name not in catalog or not catalog[name][0].exists():
|
||||
if args.all:
|
||||
print(f"Unavailable bundled case: {name}; no download attempted")
|
||||
continue
|
||||
raise SystemExit(f"Unavailable bundled case: {name}")
|
||||
path, modality = catalog[name]
|
||||
selections.append((name, path, modality))
|
||||
for path in args.input + args.dicom_series:
|
||||
name = re.sub(r'[^a-z0-9_-]+', '-', path.name.lower().removesuffix('.gz').removesuffix('.nii').removesuffix('.nrrd').removesuffix('.dcm')).strip('-')
|
||||
selections.append((name or 'case', path, args.modality))
|
||||
if len({name for name, _, _ in selections}) != len(selections):
|
||||
raise SystemExit('Source basenames collide; run cases separately with distinct --output names')
|
||||
if not selections:
|
||||
raise SystemExit('No local medical fixtures available')
|
||||
directory = args.output.parent if args.output else (args.output_dir or DEFAULT_DIR)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
results = {}
|
||||
for name, path, modality in selections:
|
||||
try:
|
||||
case = load_medical(path, modality, args.series_uid)
|
||||
image, info = representative_plane(case, args.slice_axis, args.slice_index)
|
||||
except (ValueError, FileNotFoundError, RuntimeError, ImportError) as exc:
|
||||
raise SystemExit(f"{name}: {exc}") from exc
|
||||
output = args.output or directory / f'{name}-filters.png'
|
||||
render_panel(name, image, info, output)
|
||||
results[name] = (image, info)
|
||||
print(output.resolve())
|
||||
(directory / 'cases.json').write_text(json.dumps({name: info for name, (_, info) in results.items()}, indent=2) + '\n')
|
||||
if args.contact_sheet:
|
||||
output = directory / 'source-overview.png'
|
||||
render_overview(results, output)
|
||||
print(output.resolve())
|
||||
if args.all:
|
||||
print('Bundled NRRD coverage unavailable; use --input /path/to/case.nrrd. No downloads performed.')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user