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:
mohamad
2026-09-10 01:58:07 +03:30
commit bcaae5ec22
31 changed files with 2831 additions and 0 deletions
+11
View File
@@ -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")
+15
View File
@@ -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")
+12
View File
@@ -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")
+11
View File
@@ -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")
+14
View File
@@ -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")
+51
View File
@@ -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()}")
+130
View File
@@ -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
+155
View File
@@ -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()