Files
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

281 lines
11 KiB
Markdown
Raw Permalink 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.
# 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.