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