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,280 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user