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:
mohamad
2026-09-10 01:58:07 +03:30
commit bcaae5ec22
31 changed files with 2831 additions and 0 deletions
+280
View File
@@ -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.