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
+259
View File
@@ -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
+51
View File
@@ -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)
+53
View File
@@ -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})
+11
View File
@@ -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)
+112
View File
@@ -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
+95
View File
@@ -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]
+47
View File
@@ -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()
+46
View File
@@ -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