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,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