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,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy import ndimage as ndi
|
||||
from skimage.feature import canny
|
||||
|
||||
from image_filter import (
|
||||
CannyFilter,
|
||||
GaussianFilter,
|
||||
InvalidConfigurationError,
|
||||
LaplacianFilter,
|
||||
MedianFilter,
|
||||
Pipeline,
|
||||
SliceWise,
|
||||
SobelFilter,
|
||||
UnsupportedDimensionError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dimension", [2, 3])
|
||||
def test_gaussian_matches_scipy_for_2d_and_3d(dimension):
|
||||
shape = (7, 8) if dimension == 2 else (6, 7, 8)
|
||||
image = np.arange(np.prod(shape), dtype=np.int16).reshape(shape)
|
||||
sigma = tuple(0.6 + 0.2 * index for index in range(dimension))
|
||||
metadata = None if dimension == 2 else {"spatial_dims": 3}
|
||||
actual = GaussianFilter(
|
||||
sigma=sigma, truncate=3.0, mode="mirror", output_dtype="float32"
|
||||
).apply(image, metadata).image
|
||||
expected = ndi.gaussian_filter(
|
||||
image, sigma=sigma, truncate=3.0, mode="mirror", output=np.float32
|
||||
)
|
||||
np.testing.assert_allclose(actual, expected, rtol=0, atol=0)
|
||||
assert actual.dtype == np.float32
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size,mode", [(3, "reflect"), (4, "constant")])
|
||||
def test_median_matches_scipy_for_window_and_boundary_modes(size, mode):
|
||||
image = np.array(
|
||||
[[9, 1, 7, 2, 8], [4, 6, 0, 5, 3], [8, 2, 9, 1, 7], [0, 5, 3, 6, 4]],
|
||||
dtype=np.int16,
|
||||
)
|
||||
origin = -1 if size == 4 else 0
|
||||
actual = MedianFilter(
|
||||
size=size, origin=origin, mode=mode, cval=-3, output_dtype="float64"
|
||||
).apply(image).image
|
||||
expected = ndi.median_filter(
|
||||
image, size=size, origin=origin, mode=mode, cval=-3, output=np.float64
|
||||
)
|
||||
np.testing.assert_array_equal(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("axis", [1, None])
|
||||
def test_sobel_matches_scipy_components_and_magnitude(axis):
|
||||
image = np.arange(8 * 9, dtype=np.float64).reshape(8, 9) ** 1.2
|
||||
actual = SobelFilter(axis=axis, mode="nearest").apply(image).image
|
||||
components = [ndi.sobel(image, spatial_axis, mode="nearest", output=np.float64) for spatial_axis in (0, 1)]
|
||||
expected = components[axis] if axis is not None else np.hypot(*components)
|
||||
np.testing.assert_allclose(actual, expected, rtol=1e-15, atol=1e-15)
|
||||
|
||||
|
||||
def test_sobel_matches_scipy_in_3d():
|
||||
image = np.arange(5 * 6 * 7, dtype=np.float32).reshape(5, 6, 7)
|
||||
actual = SobelFilter(axis=-1, mode="wrap", output_dtype="float32").apply(
|
||||
image, {"spatial_dims": 3}
|
||||
).image
|
||||
expected = ndi.sobel(image, axis=2, mode="wrap", output=np.float32)
|
||||
np.testing.assert_array_equal(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dimension", [2, 3])
|
||||
def test_laplacian_matches_scipy_for_2d_and_3d(dimension):
|
||||
shape = (8, 9) if dimension == 2 else (5, 6, 7)
|
||||
image = np.linspace(-2, 3, np.prod(shape), dtype=np.float64).reshape(shape) ** 3
|
||||
metadata = None if dimension == 2 else {"spatial_dims": 3}
|
||||
actual = LaplacianFilter(mode="constant", cval=1.5).apply(image, metadata).image
|
||||
expected = ndi.laplace(image, mode="constant", cval=1.5, output=np.float64)
|
||||
np.testing.assert_array_equal(actual, expected)
|
||||
|
||||
|
||||
def test_canny_matches_scikit_image_reference():
|
||||
image = np.zeros((40, 44), dtype=np.float32)
|
||||
image[9:31, 11:35] = 1
|
||||
configured = CannyFilter(
|
||||
sigma=1.4, low_threshold=0.1, high_threshold=0.3,
|
||||
mode="reflect",
|
||||
)
|
||||
actual = configured.apply(image).image
|
||||
expected = canny(
|
||||
image, sigma=1.4, low_threshold=0.1, high_threshold=0.3,
|
||||
mode="reflect",
|
||||
)
|
||||
np.testing.assert_array_equal(actual, expected)
|
||||
assert actual.dtype == np.bool_
|
||||
|
||||
|
||||
NEW_FILTERS = [GaussianFilter(.8), MedianFilter(4, origin=-1), SobelFilter(), LaplacianFilter(), CannyFilter(.8, .1, .3, True)]
|
||||
|
||||
|
||||
def reference(configured, image):
|
||||
if isinstance(configured, CannyFilter):
|
||||
return canny(image, sigma=.8, low_threshold=.1, high_threshold=.3, use_quantiles=True)
|
||||
image = image.astype(np.float64)
|
||||
if isinstance(configured, GaussianFilter):
|
||||
return ndi.gaussian_filter(image, .8)
|
||||
if isinstance(configured, MedianFilter):
|
||||
return ndi.median_filter(image, 4, origin=-1)
|
||||
if isinstance(configured, SobelFilter):
|
||||
return np.sqrt(sum(ndi.sobel(image, axis=i) ** 2 for i in range(image.ndim)))
|
||||
return ndi.laplace(image)
|
||||
|
||||
|
||||
# Representative layouts exercise each spatial-block implementation without a
|
||||
# filter × singleton × channel Cartesian product. Canny has its own mapper.
|
||||
@pytest.mark.parametrize('configured,singleton_axis,channel_axis', [
|
||||
(NEW_FILTERS[0], 0, None), (NEW_FILTERS[1], 1, 0),
|
||||
(NEW_FILTERS[2], 2, -1), (NEW_FILTERS[3], 0, -1),
|
||||
(NEW_FILTERS[4], 1, 0), (NEW_FILTERS[4], None, -1),
|
||||
(NEW_FILTERS[0], None, 0),
|
||||
])
|
||||
def test_numerics_singletons_channels_and_history(configured, singleton_axis, channel_axis):
|
||||
base = np.random.default_rng(123).normal(size=(12, 13))
|
||||
def retain_singleton(values):
|
||||
return values if singleton_axis is None else np.expand_dims(values, singleton_axis)
|
||||
plane = retain_singleton(base)
|
||||
expected = retain_singleton(reference(configured, base))
|
||||
metadata = {'case': {'ids': [12]}}
|
||||
if channel_axis is not None:
|
||||
image = np.stack([plane, plane * 2], axis=channel_axis)
|
||||
expected = np.stack([expected, retain_singleton(reference(configured, base * 2))], axis=channel_axis)
|
||||
metadata['channel_axis'] = channel_axis
|
||||
else:
|
||||
image = plane
|
||||
before = image.copy()
|
||||
result = configured.apply(image, metadata)
|
||||
np.testing.assert_allclose(result.image, expected, rtol=1e-14, atol=1e-14)
|
||||
np.testing.assert_array_equal(image, before)
|
||||
assert result.image.shape == image.shape
|
||||
assert result.image.dtype == (np.bool_ if isinstance(configured, CannyFilter) else np.float64)
|
||||
assert np.isfinite(result.image).all()
|
||||
entry = result.metadata['processing_history'][0]
|
||||
assert entry['filter'] == type(configured).__name__
|
||||
assert entry['spatial_dimension'] == 2
|
||||
assert entry['input_shape'] == entry['output_shape'] == list(image.shape)
|
||||
assert entry['output_dtype'] == result.image.dtype.name
|
||||
assert entry['parameters']['mode'] == configured.mode
|
||||
assert result.metadata['case'] == metadata['case']
|
||||
result.metadata['case']['ids'].append(42)
|
||||
assert metadata['case']['ids'] == [12]
|
||||
assert 'processing_history' not in metadata
|
||||
with pytest.raises(TypeError):
|
||||
result.metadata['case'] = {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('configured,axis', list(zip(NEW_FILTERS[:4], [0, 1, 2, 3])))
|
||||
def test_multichannel_volumes_numerically(configured, axis):
|
||||
volume = np.random.default_rng(40).normal(size=(6, 7, 8))
|
||||
image = np.stack([volume, volume * 3], axis=axis)
|
||||
actual = configured.apply(image, {'channel_axis': axis}).image
|
||||
expected = np.stack([reference(configured, volume), reference(configured, volume * 3)], axis=axis)
|
||||
np.testing.assert_allclose(actual, expected, rtol=1e-14, atol=1e-14)
|
||||
assert actual.shape == image.shape
|
||||
assert actual.dtype == np.float64
|
||||
assert np.isfinite(actual).all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('factory', [
|
||||
pytest.param(lambda: GaussianFilter(sigma=[1, 2]), id='immutable-sigma'),
|
||||
pytest.param(lambda: GaussianFilter(sigma=0), id='positive-sigma'),
|
||||
pytest.param(lambda: GaussianFilter(truncate=0), id='positive-truncate'),
|
||||
pytest.param(lambda: MedianFilter(size=0), id='positive-window'),
|
||||
pytest.param(lambda: MedianFilter(size=4, origin=2), id='origin-boundary'),
|
||||
pytest.param(lambda: SobelFilter(axis=True), id='boolean-is-not-axis'),
|
||||
pytest.param(lambda: LaplacianFilter(cval=np.inf), id='finite-padding'),
|
||||
pytest.param(lambda: LaplacianFilter(mode='invalid'), id='boundary-mode'),
|
||||
pytest.param(lambda: GaussianFilter(output_dtype='int16'), id='floating-output'),
|
||||
pytest.param(lambda: CannyFilter(low_threshold=-1), id='nonnegative-threshold'),
|
||||
pytest.param(lambda: CannyFilter(low_threshold=1.1, use_quantiles=True), id='quantile-range'),
|
||||
pytest.param(lambda: CannyFilter(low_threshold=.8, high_threshold=.2), id='threshold-order'),
|
||||
])
|
||||
|
||||
def test_invalid_types_and_nonfinite_parameters_use_domain_errors(factory):
|
||||
with pytest.raises(InvalidConfigurationError):
|
||||
factory()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('configured', [GaussianFilter((1, 2, 3)), SobelFilter(axis=2), CannyFilter(low_threshold=.8)])
|
||||
def test_image_dependent_configuration_errors(configured):
|
||||
with pytest.raises(InvalidConfigurationError):
|
||||
configured.apply(np.ones((10, 11)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('dtype,quantiles', [(np.uint8, False), (np.int16, True), (np.float32, False), (np.bool_, True)])
|
||||
def test_canny_dtype_normalization_matches_backend(dtype, quantiles):
|
||||
image = np.random.default_rng(12).integers(0, 100, (24, 25)).astype(dtype)
|
||||
config = CannyFilter(use_quantiles=quantiles)
|
||||
actual = config.apply(image).image
|
||||
np.testing.assert_array_equal(actual, canny(image, use_quantiles=quantiles))
|
||||
assert actual.dtype == np.bool_
|
||||
|
||||
|
||||
@pytest.mark.parametrize('axis', [1, -1])
|
||||
def test_canny_slicewise_reference_and_nested_provenance(axis):
|
||||
volume = np.random.default_rng(11).normal(size=(10, 11, 12))
|
||||
with pytest.raises(UnsupportedDimensionError, match='SliceWise'):
|
||||
NEW_FILTERS[-1].apply(volume)
|
||||
result = SliceWise(NEW_FILTERS[-1], axis=axis).apply(volume, {'source': 'test'})
|
||||
expected = np.stack([reference(NEW_FILTERS[-1], np.take(volume, i, axis=axis)) for i in range(volume.shape[axis])], axis=axis)
|
||||
np.testing.assert_array_equal(result.image, expected)
|
||||
assert result.image.shape == volume.shape
|
||||
assert result.image.dtype == np.bool_
|
||||
entry = result.metadata['processing_history'][0]
|
||||
assert entry['parameters']['filter']['filter'] == 'CannyFilter'
|
||||
assert entry['parameters']['axis'] == axis
|
||||
assert entry['output_dtype'] == 'bool'
|
||||
assert result.metadata['source'] == 'test'
|
||||
|
||||
|
||||
def test_combined_repeated_pipeline_batch_reference():
|
||||
from image_filter import ImageItem
|
||||
images = [np.random.default_rng(i).normal(size=shape) for i, shape in enumerate([(13, 14), (7, 8, 9)])]
|
||||
steps = [*NEW_FILTERS[:4], GaussianFilter(.8)]
|
||||
pipeline = Pipeline(steps)
|
||||
results = pipeline.apply_batch([ImageItem(image, {'id': i}) for i, image in enumerate(images)])
|
||||
for i, (image, result) in enumerate(zip(images, results)):
|
||||
expected = image
|
||||
for step in steps:
|
||||
expected = reference(step, expected)
|
||||
np.testing.assert_allclose(result.image, expected, rtol=1e-12, atol=1e-12)
|
||||
assert result.image.shape == image.shape
|
||||
assert result.image.dtype == np.float64
|
||||
assert np.isfinite(result.image).all()
|
||||
assert result.metadata['id'] == i
|
||||
assert all(entry['spatial_dimension'] == image.ndim for entry in result.metadata['processing_history'])
|
||||
assert [entry['filter'] for entry in result.metadata['processing_history']] == [type(step).__name__ for step in steps]
|
||||
# Boolean output remains valid numeric input for a subsequent filter.
|
||||
image = images[0]
|
||||
result = Pipeline([NEW_FILTERS[-1], GaussianFilter(.8), NEW_FILTERS[-1]]).apply(image)
|
||||
expected = reference(NEW_FILTERS[-1], reference(NEW_FILTERS[0], reference(NEW_FILTERS[-1], image)))
|
||||
np.testing.assert_array_equal(result.image, expected)
|
||||
assert len(result.metadata['processing_history']) == 3
|
||||
|
||||
|
||||
def test_exactly_ten_public_concrete_filters():
|
||||
import image_filter as api
|
||||
names = [name for name in api.__all__ if name.endswith('Filter') and name != 'ImageFilter']
|
||||
assert set(names) == {'MeanFilter', 'LoGFilter', 'LawsFilter', 'GaborFilter', 'WaveletFilter', 'GaussianFilter', 'MedianFilter', 'SobelFilter', 'LaplacianFilter', 'CannyFilter'}
|
||||
assert api.CannyFilter.capabilities.spatial_dimensions == frozenset({2})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('dtype', [np.int64, np.uint64])
|
||||
def test_canny_declares_backend_integer_restriction(dtype):
|
||||
from image_filter import InvalidImageError
|
||||
image = np.ones((10, 11), dtype=dtype)
|
||||
with pytest.raises(ValueError, match='64-bit'):
|
||||
canny(image)
|
||||
with pytest.raises(InvalidImageError, match='64-bit'):
|
||||
CannyFilter().apply(image)
|
||||
assert '32 bits' in CannyFilter.capabilities.accepted_dtypes
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Composite behavior: substitution, ordering, metadata, and adapters."""
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from image_filter import (
|
||||
CannyFilter, GaussianFilter, ImageFilter, ImageItem, InvalidConfigurationError,
|
||||
MeanFilter, Pipeline, SliceWise, SobelFilter, UnsupportedDimensionError,
|
||||
)
|
||||
|
||||
|
||||
def test_nested_pipeline_matches_explicit_execution_and_preserves_metadata():
|
||||
image = np.arange(120, dtype=float).reshape(10, 12)
|
||||
metadata = {"patient": {"label": "test"}}
|
||||
first, second, third = MeanFilter(3), GaussianFilter(0.8), SobelFilter()
|
||||
operation = Pipeline([Pipeline([first, second]), third])
|
||||
assert isinstance(operation, ImageFilter)
|
||||
result = operation.apply(image, metadata)
|
||||
expected = third.apply(second.apply(first.apply(image).image).image)
|
||||
np.testing.assert_array_equal(result.image, expected.image)
|
||||
assert [x["filter"] for x in result.metadata["processing_history"]] == [
|
||||
"MeanFilter", "GaussianFilter", "SobelFilter",
|
||||
]
|
||||
assert metadata == {"patient": {"label": "test"}}
|
||||
assert result.metadata["patient"] is not metadata["patient"]
|
||||
np.testing.assert_array_equal(image, np.arange(120).reshape(10, 12))
|
||||
for component in (first, operation):
|
||||
batch = component.apply_batch([ImageItem(image), ImageItem(image[None])])
|
||||
np.testing.assert_array_equal(batch[0].image, batch[1].image[0])
|
||||
|
||||
|
||||
def test_pipeline_in_slicewise_preserves_boolean_output_and_serializable_history():
|
||||
volume = np.random.default_rng(12).random((10, 12, 5))
|
||||
pipeline = Pipeline([GaussianFilter(0.8), CannyFilter(use_quantiles=True)])
|
||||
result = SliceWise(pipeline, axis=2).apply(volume)
|
||||
expected = np.stack([pipeline.apply(volume[:, :, i]).image for i in range(5)], axis=2)
|
||||
np.testing.assert_array_equal(result.image, expected)
|
||||
assert result.image.dtype == bool
|
||||
json.dumps(dict(result.metadata))
|
||||
assert pipeline.capabilities.spatial_dimensions == frozenset({2})
|
||||
with pytest.raises(UnsupportedDimensionError):
|
||||
pipeline.apply(volume)
|
||||
|
||||
|
||||
def test_invalid_composites_fail_at_construction():
|
||||
for steps in ([], [object()], [Pipeline([MeanFilter()]), "invalid"]):
|
||||
with pytest.raises(InvalidConfigurationError):
|
||||
Pipeline(steps)
|
||||
with pytest.raises(InvalidConfigurationError):
|
||||
SliceWise(object(), axis=0)
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from image_filter import (
|
||||
DimensionInferenceError,
|
||||
GaborFilter,
|
||||
SliceWise,
|
||||
UnsupportedDimensionError,
|
||||
infer_spatial_layout,
|
||||
)
|
||||
|
||||
|
||||
def test_short_volume_or_rgb_without_metadata_is_ambiguous():
|
||||
with pytest.raises(DimensionInferenceError, match="ambiguous"):
|
||||
infer_spatial_layout(np.zeros((16, 17, 3)))
|
||||
assert infer_spatial_layout(np.zeros((16, 17, 3)), {"spatial_dims": 3}).dimension == 3
|
||||
|
||||
|
||||
def test_4d_requires_channel_axis():
|
||||
image = np.zeros((8, 9, 10, 2))
|
||||
with pytest.raises(DimensionInferenceError, match="requires"):
|
||||
infer_spatial_layout(image)
|
||||
assert infer_spatial_layout(image, {"channel_axis": 3}).dimension == 3
|
||||
|
||||
|
||||
def test_intrinsically_2d_filter_rejects_volume_in_strict_mode():
|
||||
with pytest.raises(UnsupportedDimensionError, match="SliceWise"):
|
||||
GaborFilter().apply(np.ones((6, 7, 8)), {"spatial_dims": 3})
|
||||
|
||||
|
||||
def test_slice_wise_is_explicit_and_axis_sensitive():
|
||||
image = np.zeros((4, 12, 13), dtype=np.float32)
|
||||
image[:, 5, :] = 1
|
||||
adapter = SliceWise(GaborFilter(frequency=0.2), axis=0)
|
||||
result = adapter.apply(image)
|
||||
from skimage.filters import gabor
|
||||
expected = np.stack([np.hypot(*gabor(plane.astype(np.float64), frequency=.2, sigma_x=2, sigma_y=2)) for plane in image])
|
||||
np.testing.assert_allclose(result.image, expected)
|
||||
assert result.image.shape == image.shape
|
||||
assert result.image.dtype == np.float64
|
||||
assert np.isfinite(result.image).all()
|
||||
assert result.metadata["processing_history"][-1]["filter"] == "SliceWise"
|
||||
nested = result.metadata["processing_history"][-1]["parameters"]["filter"]
|
||||
assert nested["filter"] == "GaborFilter"
|
||||
assert nested["parameters"]["frequency"] == 0.2
|
||||
|
||||
|
||||
def test_slice_wise_rejects_non_spatial_axis():
|
||||
image = np.ones((5, 6, 7, 3))
|
||||
with pytest.raises(UnsupportedDimensionError, match="spatial axes"):
|
||||
SliceWise(GaborFilter(), axis=3).apply(image, {"channel_axis": 3})
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Unsupported features must fail explicitly instead of silently approximating."""
|
||||
import pytest
|
||||
|
||||
from image_filter import LawsFilter, UnsupportedOptionError, WaveletFilter
|
||||
|
||||
|
||||
def test_unsupported_advanced_options_fail_at_configuration_time():
|
||||
with pytest.raises(UnsupportedOptionError):
|
||||
LawsFilter(rotation_invariant=True)
|
||||
with pytest.raises(UnsupportedOptionError):
|
||||
WaveletFilter(rotation_invariant=True)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Integration tests using real repository scans and scikit-image's camera.
|
||||
|
||||
``skimage.data.camera`` is a public-domain photograph bundled/cached by the
|
||||
installed scientific dependency; it supplies a genuine 2D image without
|
||||
network access. NIfTI inputs below are existing Radiuma repository assets and
|
||||
are used only as image data, never as an implementation reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import nibabel as nib
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy import ndimage as ndi
|
||||
from skimage import data
|
||||
from skimage.filters import gabor
|
||||
|
||||
from image_filter import (
|
||||
CannyFilter,
|
||||
GaborFilter,
|
||||
LoGFilter,
|
||||
MeanFilter,
|
||||
Pipeline,
|
||||
SliceWise,
|
||||
)
|
||||
|
||||
REPOSITORY = Path(__file__).resolve().parents[1] / 'data/tests'
|
||||
|
||||
|
||||
@pytest.mark.real_data
|
||||
def test_public_api_on_real_2d_photograph():
|
||||
image = data.camera()
|
||||
result = Pipeline([
|
||||
MeanFilter(3, output_dtype="float32"),
|
||||
GaborFilter(frequency=0.16, orientations=(0.0, np.pi / 2), output_dtype="float32"),
|
||||
]).apply(image, {"source": "skimage.data.camera (public domain)"})
|
||||
assert result.image.shape == (512, 512)
|
||||
assert result.image.dtype == np.float32
|
||||
assert np.isfinite(result.image).all()
|
||||
smoothed = ndi.uniform_filter(image, 3, output=np.float32)
|
||||
expected = np.maximum(*[
|
||||
np.hypot(*gabor(smoothed, frequency=.16, theta=theta, sigma_x=2, sigma_y=2))
|
||||
for theta in (0, np.pi / 2)
|
||||
])
|
||||
np.testing.assert_allclose(result.image, expected)
|
||||
assert result.metadata['processing_history'][1]['filter'] == 'GaborFilter'
|
||||
|
||||
|
||||
@pytest.mark.real_data
|
||||
@pytest.mark.parametrize(
|
||||
"relative_path",
|
||||
["test4.nii.gz", "0001_MRI_Image_a.nii.gz"],
|
||||
)
|
||||
def test_real_nifti_volume_end_to_end(relative_path):
|
||||
nii = nib.load(REPOSITORY / relative_path)
|
||||
full = np.asanyarray(nii.dataobj)
|
||||
widths = (12, 24, 28)
|
||||
peak = np.unravel_index(np.argmax(np.abs(full)), full.shape)
|
||||
slices = tuple(
|
||||
slice(start := min(max(0, center - width // 2), size - width), start + width)
|
||||
for size, width, center in zip(full.shape, widths, peak, strict=True)
|
||||
)
|
||||
source = full[slices]
|
||||
metadata = {"spacing": tuple(float(v) for v in nii.header.get_zooms()[:3]), "spatial_dims": 3}
|
||||
result = Pipeline([
|
||||
MeanFilter(3, mode="nearest", output_dtype="float32"),
|
||||
LoGFilter(0.9, output_dtype="float32"),
|
||||
MeanFilter(4, origin=-1, output_dtype="float32"),
|
||||
]).apply(source, metadata)
|
||||
assert result.image.shape == source.shape
|
||||
assert result.image.dtype == np.float32
|
||||
assert np.isfinite(result.image).all()
|
||||
assert not np.shares_memory(result.image, source)
|
||||
assert len(result.metadata["processing_history"]) == 3
|
||||
expected = ndi.uniform_filter(source, 3, mode="nearest", output=np.float32)
|
||||
expected = ndi.gaussian_laplace(expected, .9)
|
||||
expected = ndi.uniform_filter(expected, 4, origin=-1, output=np.float32)
|
||||
np.testing.assert_allclose(result.image, expected, rtol=1e-6, atol=1e-6)
|
||||
assert result.metadata['spacing'] == metadata['spacing']
|
||||
|
||||
|
||||
@pytest.mark.real_data
|
||||
@pytest.mark.parametrize('source', ['camera', 'test4.nii.gz', '0001_MRI_Image_a.nii.gz'])
|
||||
def test_all_new_filters_real_backend_numerics(source):
|
||||
from test_additional_filters import NEW_FILTERS, reference
|
||||
if source == 'camera':
|
||||
image = data.camera()
|
||||
else:
|
||||
nii = nib.load(REPOSITORY / source)
|
||||
full = np.asanyarray(nii.dataobj)
|
||||
peak = np.unravel_index(np.argmax(np.abs(full)), full.shape)
|
||||
region = tuple(slice(max(0, min(c - 8, n - 16)), max(0, min(c - 8, n - 16)) + 16) for c, n in zip(peak, full.shape))
|
||||
image = full[region]
|
||||
metadata = {'source': source, 'spatial_dims': image.ndim}
|
||||
for configured in NEW_FILTERS:
|
||||
if isinstance(configured, CannyFilter) and image.ndim == 3:
|
||||
applied = SliceWise(configured, axis=0)
|
||||
expected = np.stack([reference(configured, plane) for plane in image])
|
||||
else:
|
||||
applied = configured
|
||||
expected = reference(configured, image)
|
||||
result = applied.apply(image, metadata)
|
||||
np.testing.assert_allclose(result.image, expected, rtol=1e-13, atol=1e-10)
|
||||
assert result.image.shape == image.shape
|
||||
assert result.image.dtype == expected.dtype
|
||||
assert np.isfinite(result.image).all()
|
||||
assert result.metadata['source'] == source
|
||||
assert result.metadata['processing_history'][0]['filter'] == type(applied).__name__
|
||||
assert result.metadata['processing_history'][0]['spatial_dimension'] == image.ndim
|
||||
assert 'processing_history' not in metadata
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pywt
|
||||
import pytest
|
||||
from scipy import ndimage as ndi
|
||||
from skimage.filters import gabor
|
||||
|
||||
from image_filter import GaborFilter, LawsFilter, LoGFilter, MeanFilter, WaveletFilter
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size,mode", [(3, "reflect"), (4, "nearest"), (3, "mirror"), (4, "wrap"), (4, "constant")])
|
||||
def test_mean_matches_scipy_for_even_odd_and_boundaries(size, mode):
|
||||
image = np.arange(42, dtype=np.int16).reshape(6, 7)
|
||||
actual = MeanFilter(size=size, mode=mode, cval=-2, output_dtype="float32").apply(image).image
|
||||
expected = ndi.uniform_filter(image, size=size, mode=mode, cval=-2, output=np.float32)
|
||||
np.testing.assert_allclose(actual, expected, rtol=0, atol=0)
|
||||
assert actual.dtype == np.float32
|
||||
|
||||
|
||||
def test_mean_origin_defines_even_kernel_alignment():
|
||||
image = np.arange(30, dtype=np.float64).reshape(5, 6)
|
||||
actual = MeanFilter(size=4, origin=-1).apply(image).image
|
||||
expected = ndi.uniform_filter(image, size=4, origin=-1, output=np.float64)
|
||||
np.testing.assert_array_equal(actual, expected)
|
||||
assert not np.array_equal(actual, MeanFilter(size=4, origin=0).apply(image).image)
|
||||
|
||||
|
||||
def test_log_matches_scipy_gaussian_laplace():
|
||||
image = np.arange(72, dtype=np.float64).reshape(8, 9)
|
||||
actual = LoGFilter(sigma=(1.2, 0.8), truncate=3, mode="mirror").apply(image).image
|
||||
expected = ndi.gaussian_laplace(image, sigma=(1.2, 0.8), truncate=3, mode="mirror")
|
||||
np.testing.assert_allclose(actual, expected, rtol=2e-14, atol=2e-14)
|
||||
|
||||
|
||||
def test_log_rgb_matches_per_channel_scipy_reference():
|
||||
image = np.arange(8 * 9 * 3, dtype=np.float64).reshape(8, 9, 3)
|
||||
actual = LoGFilter(sigma=1.1).apply(image, {"channel_axis": 2}).image
|
||||
expected = np.stack([ndi.gaussian_laplace(image[..., c], 1.1) for c in range(3)], axis=2)
|
||||
np.testing.assert_allclose(actual, expected, rtol=2e-14, atol=2e-14)
|
||||
|
||||
|
||||
def test_laws_matches_separable_scipy_reference():
|
||||
image = np.arange(90, dtype=np.float64).reshape(9, 10)
|
||||
l5 = np.array([1, 4, 6, 4, 1], dtype=float); l5 /= np.linalg.norm(l5)
|
||||
e5 = np.array([-1, -2, 0, 2, 1], dtype=float); e5 /= np.linalg.norm(e5)
|
||||
expected = ndi.convolve1d(image, l5, axis=0, mode="reflect")
|
||||
expected = ndi.convolve1d(expected, e5, axis=1, mode="reflect")
|
||||
actual = LawsFilter("L5E5").apply(image).image
|
||||
np.testing.assert_allclose(actual, expected, rtol=1e-14, atol=1e-14)
|
||||
|
||||
|
||||
def test_laws_energy_is_local_mean_absolute_response():
|
||||
image = np.arange(90, dtype=np.float64).reshape(9, 10)
|
||||
response = LawsFilter("L5E5").apply(image).image
|
||||
expected = ndi.uniform_filter(np.abs(response), size=3, mode="reflect")
|
||||
actual = LawsFilter("L5E5", energy_radius=1).apply(image).image
|
||||
np.testing.assert_allclose(actual, expected)
|
||||
assert np.all(actual >= 0)
|
||||
|
||||
|
||||
def test_gabor_matches_skimage_reference():
|
||||
y, x = np.mgrid[:32, :35]
|
||||
image = np.sin(2 * np.pi * 0.2 * x)
|
||||
actual = GaborFilter(
|
||||
frequency=0.2, orientations=(0.3,), sigma=2.1, gamma=0.7,
|
||||
response="magnitude", mode="reflect",
|
||||
).apply(image).image
|
||||
real, imaginary = gabor(
|
||||
image, frequency=0.2, theta=0.3, sigma_x=2.1,
|
||||
sigma_y=2.1 / 0.7, mode="reflect", cval=0,
|
||||
)
|
||||
np.testing.assert_allclose(actual, np.hypot(real, imaginary))
|
||||
|
||||
|
||||
def test_wavelet_matches_pywavelets_stationary_transform():
|
||||
image = np.arange(8 * 10, dtype=np.float64).reshape(8, 10)
|
||||
padded = np.pad(image, ((0, 0), (0, 2)), mode="reflect")
|
||||
expected = pywt.swtn(padded, "db2", level=2, axes=(0, 1), trim_approx=False)[0]["ad"]
|
||||
actual = WaveletFilter("LH", wavelet="db2", level=2).apply(image).image
|
||||
np.testing.assert_allclose(actual, expected[:, :10])
|
||||
assert actual.shape == image.shape
|
||||
|
||||
|
||||
|
||||
|
||||
def test_mean_excludes_channel_and_singleton_axes():
|
||||
# Mean has a separate full-array implementation from the newer filters.
|
||||
base = np.random.default_rng(14).normal(size=(8, 9))
|
||||
image = np.stack([base, base * 2 + 20], axis=0)[:, :, None, :]
|
||||
result = MeanFilter(4).apply(image, {"channel_axis": 0})
|
||||
expected = np.stack([ndi.uniform_filter(plane[:, 0, :], 4) for plane in image])[:, :, None, :]
|
||||
np.testing.assert_allclose(result.image, expected)
|
||||
assert result.image.shape == image.shape
|
||||
assert result.metadata['processing_history'][0]['spatial_axes'] == [1, 3]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Exercise the common runner with real MRI data and different components."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parents[1] / "examples"
|
||||
sys.path.insert(0, str(EXAMPLES))
|
||||
from example_support import run_example
|
||||
from image_filter import UnsupportedDimensionError
|
||||
|
||||
|
||||
def operation(filename):
|
||||
spec = importlib.util.spec_from_file_location("usage_example", EXAMPLES / filename)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.build_operation()
|
||||
|
||||
|
||||
def test_all_examples_share_runner_and_leaf_matches_single_step_pipeline(tmp_path):
|
||||
outputs = []
|
||||
for script in sorted(EXAMPLES.glob("0[1-5]_*.py")):
|
||||
run_example(operation(script.name), script.stem, ["--output-dir", str(tmp_path)])
|
||||
outputs.append(np.load(tmp_path / f"{script.stem}.npy"))
|
||||
metadata = json.loads((tmp_path / f"{script.stem}.json").read_text())
|
||||
assert metadata["processing_history"]
|
||||
assert outputs[-1].ndim == 2
|
||||
np.testing.assert_array_equal(outputs[0], outputs[3])
|
||||
|
||||
|
||||
def test_volume_policy_and_user_file_input(tmp_path):
|
||||
from medical_io import bundled_cases
|
||||
path, _ = bundled_cases()["mri"]
|
||||
args = ["--input", str(path), "--output-dir", str(tmp_path)]
|
||||
run_example(operation("01_single_filter.py"), "volume", args + ["--mode", "volume"])
|
||||
volume = np.load(tmp_path / "volume.npy")
|
||||
assert volume.ndim == 3
|
||||
edges = operation("05_slicewise_pipeline.py")
|
||||
run_example(edges, "slices", args + ["--mode", "slice-wise"])
|
||||
result = np.load(tmp_path / "slices.npy")
|
||||
assert result.shape == volume.shape and result.dtype == bool
|
||||
with pytest.raises(UnsupportedDimensionError):
|
||||
run_example(edges, "invalid", args + ["--mode", "volume"])
|
||||
assert not (tmp_path / "invalid.npy").exists()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Small CLI contract and real rendering checks; numerical filters are tested elsewhere."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / 'examples' / 'visual_compare.py'
|
||||
spec = importlib.util.spec_from_file_location('visual_compare', SCRIPT)
|
||||
visual = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(visual)
|
||||
|
||||
|
||||
def test_cli_selection_and_destination_contract():
|
||||
assert visual.parse_args([]).names == ['mri']
|
||||
assert visual.parse_args(['--all']).names == list(visual.CASE_NAMES)
|
||||
args = visual.parse_args(['--images', 'mri', 'pet', '--output-dir', 'panels'])
|
||||
assert args.names == ['mri', 'pet'] and args.output_dir == Path('panels')
|
||||
with pytest.raises(SystemExit):
|
||||
visual.parse_args(['--all', '--output', 'one.png'])
|
||||
with pytest.raises(SystemExit):
|
||||
visual.parse_args(['--images', 'unknown'])
|
||||
|
||||
|
||||
def test_real_multi_image_rendering_offline(tmp_path, monkeypatch):
|
||||
# Fail if any sample loader attempts to use networking.
|
||||
import socket
|
||||
def forbid_network(*args, **kwargs):
|
||||
raise AssertionError('Example generation must stay offline')
|
||||
monkeypatch.setattr(socket.socket, 'connect', forbid_network)
|
||||
visual.main(['--images', 'mri', 'mr-dicom', '--output-dir', str(tmp_path), '--contact-sheet'])
|
||||
expected = {'mri-filters.png': (2400, 1800), 'mr-dicom-filters.png': (2400, 1800),
|
||||
'source-overview.png': (1800, 750), 'cases.json': None}
|
||||
assert {p.name for p in tmp_path.iterdir()} == set(expected)
|
||||
for name, size in expected.items():
|
||||
if name == 'cases.json':
|
||||
assert 'mri' in (tmp_path / name).read_text()
|
||||
continue
|
||||
with Image.open(tmp_path / name) as image:
|
||||
image.load()
|
||||
assert image.format == 'PNG' and image.size == size and image.mode == 'RGBA'
|
||||
pixels = np.asarray(image.convert('RGB'))
|
||||
assert pixels.std() > 20
|
||||
# Image area must contain actual content, beyond just titles.
|
||||
assert pixels[size[1] // 3:2 * size[1] // 3].std() > 20
|
||||
Reference in New Issue
Block a user