# Image Filter `image-filter` is a standalone, typed Python library for reproducible filtering of in-memory NumPy images. It has no dependency on the Radiuma or ViSERA application and performs no file or folder I/O. The repository and distribution are named `image-filter`. Python imports use `image_filter` because hyphens are not valid Python identifiers. Source lives in `src/image_filter/`. Commands below run from this repository's root. See the [class diagrams](docs/architecture.md) for the Composite architecture and domain model, with editable Mermaid source files. ## Local medical data Real scans are kept locally in ignored `data/samples/` and `data/tests/`; they are not included in Git or the wheel. This Mac has the existing fixtures copied into those directories. A fresh clone needs MRI.nii.gz, PET.nii.gz, CT.nii.gz, SPECT.nii.gz and Ultrasound.nii.gz in `data/samples/`, plus test4.nii.gz and 0001_MRI_Image_a.nii.gz in `data/tests/` to reproduce the full real-data suite. Alternatively, examples accept `--input` for your own files. DICOM demo fixtures come from the installed pydicom package. ## Install ```bash python -m pip install . ``` For development: ```bash python -m pip install -e '.[test,examples]' python -m pytest tests ``` ## Quick start ```python import numpy as np from image_filter import MeanFilter, Pipeline, WaveletFilter image = np.asarray(...) # a 2D or 3D numeric image pipeline = Pipeline([ MeanFilter(size=3), WaveletFilter(configuration="LH", wavelet="db2"), MeanFilter(size=4, origin=-1, mode="nearest"), ]) result = pipeline.apply(image, {"case_id": "anonymous-17"}) filtered = result.image history = result.metadata["processing_history"] ``` Configuration objects and pipelines are immutable. Repeating a filter class is supported because a pipeline stores ordered filter instances, not a registry keyed by type. ## Composite API and runnable code examples `ImageFilter` is the abstract Component with `apply(image, metadata)` and `apply_batch(items)`. The ten filters are Leaves. `Pipeline(ImageFilter)` is the Composite: its children can be filters, adapters, or nested pipelines. Each child receives the preceding child's image and metadata. A nested pipeline records its leaves in execution order, without duplicate pipeline history entries. Empty pipelines are rejected. Shared instances are immutable. ```python from image_filter import GaussianFilter, ImageFilter, Pipeline, SobelFilter def process(image, operation: ImageFilter): return operation.apply(image) preprocessing = Pipeline([GaussianFilter(sigma=1.0)]) edges = Pipeline([preprocessing, SobelFilter()]) # process(image, GaussianFilter()) and process(image, edges) use the same API. ``` All five files have the same structure: `build_operation() -> ImageFilter`, then `run_example(build_operation(), name=...)`. Only the operation changes. The shared runner loads the image, applies the component, and saves the result. It defaults to local MRI data; every example accepts the same input options. | File | Demonstrates | | --- | --- | | `examples/01_single_filter.py` | One-filter pipeline | | `examples/02_sequential_filters.py` | Mean → wavelet → mean with different settings | | `examples/03_nested_pipeline.py` | Reusable preprocessing composite followed by Sobel | | `examples/04_component_api.py` | A direct leaf passed to the same runner | | `examples/05_slicewise_pipeline.py` | Gaussian → Canny; use `--mode slice-wise` for a volume | From the library root, with example dependencies installed: ```bash cd /Users/mohamad/vollabor/image-filter for example in examples/0[1-5]_*.py; do PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src python3 "$example" || break done ``` Outputs are NumPy arrays and JSON processing metadata in `example-output/pipelines/`. Matching filenames are overwritten; use different `--output-dir` values to retain multiple runs. All five scripts accept these options: | Option | Behavior | | --- | --- | | `--case mri` | Local case: mri, pet, ct, spect, ultrasound, mr-dicom, ct-series | | `--input PATH` | NIfTI, NRRD, single DICOM, or DICOM series directory | | `--series-uid UID` | Select a series when the directory contains several | | `--mode plane` | Default: representative 2D plane, 1–99% window normalized to [0, 1] | | `--mode volume` | Original intensities and dimensions; every step must support the input | | `--mode slice-wise` | Original intensities; automatically wrap a 3D input in `SliceWise` | | `--axis 2` | Plane selection or slice-wise axis (0, 1, or 2) | | `--modality CT` | Label for a user-supplied image | | `--output-dir PATH` | Destination for .npy and .json files | ```bash PYTHONPATH=src python3 examples/03_nested_pipeline.py --case ct PYTHONPATH=src python3 examples/01_single_filter.py --case mri --mode volume PYTHONPATH=src python3 examples/05_slicewise_pipeline.py --case mri --mode slice-wise PYTHONPATH=src python3 examples/03_nested_pipeline.py --input /path/to/scan.nii.gz ``` `slice-wise` is an explicit user choice; within that mode a 2D input runs directly and a 3D input gets the adapter automatically. The runner does not change wavelet configuration or thresholds to accommodate incompatible data. For example, `LH` in example 02 requires 2D execution: use `plane` or `slice-wise`, or explicitly configure a 3D wavelet for `volume` mode. The existing `visual_compare.py` still produces all ten independent medical comparisons; it now executes each operation through a one-step composite. These comparison panels are independent, whereas examples 02, 03 and 05 are sequential pipelines. ## Dimensionality and channels Dimension is inferred for every image independently. A singleton spatial axis does not change a 2D image into a volume and its original shape is retained: `(1, H, W)`, `(H, 1, W)`, and `(H, W, 1)` are all 2D. Always declare a channel axis: ```python result = MeanFilter(3).apply(rgb, {"channel_axis": -1}) ``` A three-axis `(H, W, 3)` or `(H, W, 4)` array without metadata is deliberately rejected because it may be either color data or a short volume. Set `channel_axis=2` for color, or `spatial_dims=3` for a volume. Four-axis data must declare `channel_axis`; time-series inference is intentionally outside the core contract. ## Explicit slice-wise filtering Gabor and Canny are intrinsically 2D. It raises `UnsupportedDimensionError` for a volume. Slice processing is available only through an explicit adapter with a chosen axis: ```python from image_filter import GaborFilter, SliceWise filter_3d = SliceWise(GaborFilter(frequency=0.2), axis=0) result = filter_3d.apply(volume) ``` See [docs/semantics.md](docs/semantics.md) for numerical contracts, boundary behavior, capability declarations, and intentionally unsupported options. ## Collection execution ```python from image_filter import ImageItem results = Pipeline([MeanFilter(3)]).apply_batch([ ImageItem(two_dimensional_image, {"id": "2d"}), ImageItem(volume, {"id": "3d", "spatial_dims": 3}), ]) ``` No caller metadata dictionary is mutated. Each output receives a deep-copied mapping with deterministic, ordered processing provenance. ## Ten public filters | Filter | Spatial dimensions | Output | | --- | --- | --- | | MeanFilter | 2D, 3D | float32/float64 | | GaussianFilter | 2D, 3D | float32/float64 | | MedianFilter | 2D, 3D | float32/float64 | | SobelFilter | 2D, 3D | float32/float64 magnitude by default | | LaplacianFilter | 2D, 3D | float32/float64 signed response | | LoGFilter | 2D, 3D | float32/float64 | | LawsFilter | 2D, 3D | float32/float64 | | GaborFilter | 2D | float32/float64 | | CannyFilter | 2D | bool | | WaveletFilter | 2D, 3D | float32/float64 | ```python from skimage.data import camera from image_filter import GaussianFilter, MedianFilter, CannyFilter, Pipeline edges = Pipeline([ GaussianFilter(sigma=0.8), MedianFilter(size=3), CannyFilter(low_threshold=0.1, high_threshold=0.3, use_quantiles=True), ]).apply(camera(), {"source": "public-domain camera"}) assert edges.image.dtype == bool ``` Canny follows scikit-image dtype scaling; float images are not automatically normalized. See the [threshold and boundary contracts](docs/semantics.md). ## Reproduce validation on macOS From the library root, with Python 3.10+ and the dependencies installed: ```bash cd /Users/mohamad/vollabor/image-filter python3 -m pip install -e '.[test,examples]' build PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src python3 -m pytest tests -p no:cacheprovider PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src MPLBACKEND=Agg python3 examples/visual_compare.py --all --output-dir example-output --contact-sheet python3 -m build --wheel --no-isolation --outdir dist . open example-output/source-overview.png ``` Real-data tests read local NIfTI assets and medical fixtures. They do not import application runtime code. ## Generate the presentation examples Generate seven consistently styled medical comparison panels and a source overview with one command, from the library root (development dependencies installed): ```bash cd /Users/mohamad/vollabor/image-filter PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src python3 examples/visual_compare.py --all --output-dir example-output --contact-sheet open example-output/source-overview.png ``` The bundled command uses local local NIfTI volumes and installed pydicom fixtures: | Source | Input | Panel filename | | --- | --- | --- | | mri | local MRI NIfTI volume | mri-filters.png | | pet | local PET NIfTI volume | pet-filters.png | | ct | local CT NIfTI volume | ct-filters.png | | spect | local SPECT NIfTI volume | spect-filters.png | | ultrasound | local ultrasound NIfTI volume | ultrasound-filters.png | | mr-dicom | installed pydicom single MR DICOM | mr-dicom-filters.png | | ct-series | installed pydicom five-slice CT series | ct-series-filters.png | All sources are loaded locally; no network download is attempted. Volumes are canonicalized to RAS axes where the reader supports it. Each panel selects a deterministic high-contrast non-border slice and labels modality, format, source, axis/index, orientation, and file count. All ten filters operate independently on that selected 2D slice. Intensities are windowed to the 1st– 99th percentile and normalized to [0, 1] for the demo only; filter outputs are displayed with independent contrast. All panels are 2400×1800 PNGs. The overview, `source-overview.png`, is 1800×1350 for the seven-case set. Outputs remain in the ignored `example-output/` directory, also the default directory for `--all` or `--images` when `--output-dir` is omitted. Rerunning overwrites matching filenames. The existing root `filter-qa.png` is preserved by this multi-image command. Select a subset with `--images mri pet ct`, or supply user files with `--input /path/case.nii.gz /path/case.nrrd /path/image.dcm` and `--dicom-series /path/series-directory`. NRRD is supported through SimpleITK, but no bundled NRRD fixture exists in this checkout, so it is not claimed in the default demonstration. A DICOM directory is read as one ordered volume using SimpleITK's series reader. `--slice-axis` and `--slice-index` override the deterministic selection. `--contact-sheet` is optional. `--output` and `--output-dir` are mutually exclusive. `cases.json` records the exact source, format, dimensions, slice choice, and window for each generated panel.