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.
156 lines
8.3 KiB
Python
156 lines
8.3 KiB
Python
"""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()
|