Files
image-filter/examples/visual_compare.py
T
mohamad bcaae5ec22 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.
2026-09-10 01:58:07 +03:30

156 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()