diff --git a/docs/notebooks/filters.ipynb b/docs/notebooks/filters.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..8d3ba898d56c663144527e893d5b670ef8f4ac83 --- /dev/null +++ b/docs/notebooks/filters.ipynb @@ -0,0 +1,145 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import qim3d\n", + "import qim3d.processing.filters as filters\n", + "import numpy as np\n", + "from scipy import ndimage" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "vol = qim3d.examples.fly_150x256x256" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Using the filter functions directly" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "### Gaussian filter\n", + "out1_gauss = filters.gaussian(vol,3)\n", + "# or\n", + "out2_gauss = filters.gaussian(vol,sigma=3) # sigma is positional, but can be passed as a kwarg\n", + "\n", + "### Median filter\n", + "out_median = filters.median(vol,size=5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Using filter classes" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "gaussian_fn = filters.Gaussian(sigma=3)\n", + "out3_gauss = gaussian_fn(vol)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Using filter classes to construct a pipeline of filters" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "pipeline = filters.Pipeline(\n", + " filters.Gaussian(sigma=3),\n", + " filters.Median(size=10))\n", + "out_seq = pipeline(vol)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Filter functions can also be appended to the sequence after defining the class instance:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "pipeline.append(filters.Maximum(size=5))\n", + "out_seq2 = pipeline(vol)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The filter objects are stored in the `filters` dictionary:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'0': <qim3d.processing.filters.Gaussian object at 0x7b3fbdad7bb0>, '1': <qim3d.processing.filters.Median object at 0x7b3fbdad52a0>, '2': <qim3d.processing.filters.Maximum object at 0x7b40f7d3f6d0>}\n" + ] + } + ], + "source": [ + "print(pipeline.filters)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/qim3d/__init__.py b/qim3d/__init__.py index c51e707e84951b7f23725fcfbe3e4ade6a3aea8b..be0ced896a64dfd8b70c3a6b2cddd6e40371e83e 100644 --- a/qim3d/__init__.py +++ b/qim3d/__init__.py @@ -3,6 +3,7 @@ import qim3d.gui as gui import qim3d.viz as viz import qim3d.utils as utils import qim3d.models as models +import qim3d.processing as processing import logging examples = io.ImgExamples() diff --git a/qim3d/processing/__init__.py b/qim3d/processing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ac51f92fb4028cf960c6d5d4d73255856d2c9c00 --- /dev/null +++ b/qim3d/processing/__init__.py @@ -0,0 +1 @@ +from .filters import * \ No newline at end of file diff --git a/qim3d/processing/filters.py b/qim3d/processing/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..6b21205a02e7b9b9e99a349f0bfbb6aa87f28581 --- /dev/null +++ b/qim3d/processing/filters.py @@ -0,0 +1,176 @@ +"""Provides filter functions and classes for image processing""" + +from typing import Union, Type +import numpy as np +from scipy import ndimage + +__all__ = ['Gaussian','Median','Maximum','Minimum','Pipeline','gaussian','median','maximum','minimum'] + +class FilterBase: + def __init__(self, *args, **kwargs): + """ + Base class for image filters. + + Args: + *args: Additional positional arguments for filter initialization. + **kwargs: Additional keyword arguments for filter initialization. + """ + self.args = args + self.kwargs = kwargs + +class Gaussian(FilterBase): + def __call__(self, input): + """ + Applies a Gaussian filter to the input. + + Args: + input: The input image or volume. + + Returns: + The filtered image or volume. + """ + return gaussian(input, *self.args, **self.kwargs) + +class Median(FilterBase): + def __call__(self, input): + """ + Applies a median filter to the input. + + Args: + input: The input image or volume. + + Returns: + The filtered image or volume. + """ + return median(input, **self.kwargs) + +class Maximum(FilterBase): + def __call__(self, input): + """ + Applies a maximum filter to the input. + + Args: + input: The input image or volume. + + Returns: + The filtered image or volume. + """ + return maximum(input, **self.kwargs) + +class Minimum(FilterBase): + def __call__(self, input): + """ + Applies a minimum filter to the input. + + Args: + input: The input image or volume. + + Returns: + The filtered image or volume. + """ + return minimum(input, **self.kwargs) + +class Pipeline: + def __init__(self, *args: Type[FilterBase]): + """ + Represents a sequence of image filters. + + Args: + *args: Variable number of filter instances to be applied sequentially. + """ + self.filters = {} + + for idx, fn in enumerate(args): + self._add_filter(str(idx), fn) + + def _add_filter(self, name: str, fn: Type[FilterBase]): + """ + Adds a filter to the sequence. + + Args: + name: A string representing the name or identifier of the filter. + fn: An instance of a FilterBase subclass. + + Raises: + AssertionError: If `fn` is not an instance of the FilterBase class. + """ + if not isinstance(fn,FilterBase): + filter_names = [subclass.__name__ for subclass in FilterBase.__subclasses__()] + raise AssertionError(f'filters should be instances of one of the following classes: {filter_names}') + self.filters[name] = fn + + def append(self, fn: Type[FilterBase]): + """ + Appends a filter to the end of the sequence. + + Args: + fn: An instance of a FilterBase subclass to be appended. + """ + self._add_filter(str(len(self.filters)), fn) + + def __call__(self, input): + """ + Applies the sequential filters to the input in order. + + Args: + input: The input image or volume. + + Returns: + The filtered image or volume after applying all sequential filters. + """ + for fn in self.filters.values(): + input = fn(input) + return input + +def gaussian(vol, *args, **kwargs): + """ + Applies a Gaussian filter to the input volume using scipy.ndimage.gaussian_filter. + + Args: + vol: The input image or volume. + *args: Additional positional arguments for the Gaussian filter. + **kwargs: Additional keyword arguments for the Gaussian filter. + + Returns: + The filtered image or volume. + """ + return ndimage.gaussian_filter(vol, *args, **kwargs) + +def median(vol, **kwargs): + """ + Applies a median filter to the input volume using scipy.ndimage.median_filter. + + Args: + vol: The input image or volume. + **kwargs: Additional keyword arguments for the median filter. + + Returns: + The filtered image or volume. + """ + return ndimage.median_filter(vol, **kwargs) + +def maximum(vol, **kwargs): + """ + Applies a maximum filter to the input volume using scipy.ndimage.maximum_filter. + + Args: + vol: The input image or volume. + **kwargs: Additional keyword arguments for the maximum filter. + + Returns: + The filtered image or volume. + """ + return ndimage.maximum_filter(vol, **kwargs) + +def minimum(vol, **kwargs): + """ + Applies a minimum filter to the input volume using scipy.ndimage.mainimum_filter. + + Args: + vol: The input image or volume. + **kwargs: Additional keyword arguments for the minimum filter. + + Returns: + The filtered image or volume. + """ + return ndimage.minimum_filter(vol, **kwargs) \ No newline at end of file diff --git a/qim3d/tests/processing/test_filters.py b/qim3d/tests/processing/test_filters.py new file mode 100644 index 0000000000000000000000000000000000000000..0f0f967c9b64f54ba06da6f6f5280377daf1c0d0 --- /dev/null +++ b/qim3d/tests/processing/test_filters.py @@ -0,0 +1,127 @@ +import qim3d +from qim3d.processing.filters import * +import numpy as np +import pytest +import re + +def test_filter_base_initialization(): + filter_base = qim3d.processing.filters.FilterBase(3,size=2) + assert filter_base.args == (3,) + assert filter_base.kwargs == {'size': 2} + +def test_gaussian_filter(): + input_image = np.random.rand(50, 50) + + # Testing the function + filtered_image_fn = gaussian(input_image,sigma=1.5) + + # Testing the class method + gaussian_filter_cls = Gaussian(sigma=1.5) + filtered_image_cls = gaussian_filter_cls(input_image) + + # Assertions + assert filtered_image_cls.shape == filtered_image_fn.shape == input_image.shape + assert np.array_equal(filtered_image_fn,filtered_image_cls) + assert not np.array_equal(filtered_image_fn, input_image) + +def test_median_filter(): + input_image = np.random.rand(50, 50) + + # Testing the function + filtered_image_fn = median(input_image, size=3) + + # Testing the class method + median_filter_cls = Median(size=3) + filtered_image_cls = median_filter_cls(input_image) + + # Assertions + assert filtered_image_cls.shape == filtered_image_fn.shape == input_image.shape + assert np.array_equal(filtered_image_fn, filtered_image_cls) + assert not np.array_equal(filtered_image_fn, input_image) + +def test_maximum_filter(): + input_image = np.random.rand(50, 50) + + # Testing the function + filtered_image_fn = maximum(input_image, size=3) + + # Testing the class method + maximum_filter_cls = Maximum(size=3) + filtered_image_cls = maximum_filter_cls(input_image) + + # Assertions + assert filtered_image_cls.shape == filtered_image_fn.shape == input_image.shape + assert np.array_equal(filtered_image_fn, filtered_image_cls) + assert not np.array_equal(filtered_image_fn, input_image) + +def test_minimum_filter(): + input_image = np.random.rand(50, 50) + + # Testing the function + filtered_image_fn = minimum(input_image, size=3) + + # Testing the class method + minimum_filter_cls = Minimum(size=3) + filtered_image_cls = minimum_filter_cls(input_image) + + # Assertions + assert filtered_image_cls.shape == filtered_image_fn.shape == input_image.shape + assert np.array_equal(filtered_image_fn, filtered_image_cls) + assert not np.array_equal(filtered_image_fn, input_image) + +def test_sequential_filter_pipeline(): + input_image = np.random.rand(50, 50) + + # Individual filters + gaussian_filter = Gaussian(sigma=1.5) + median_filter = Median(size=3) + maximum_filter = Maximum(size=3) + + # Testing the sequential pipeline + sequential_pipeline = Sequential(gaussian_filter, median_filter, maximum_filter) + filtered_image_pipeline = sequential_pipeline(input_image) + + # Testing the equivalence to maximum(median(gaussian(input,**kwargs),**kwargs),**kwargs) + expected_output = maximum(median(gaussian(input_image, sigma=1.5), size=3), size=3) + + # Assertions + assert filtered_image_pipeline.shape == expected_output.shape == input_image.shape + assert not np.array_equal(filtered_image_pipeline, input_image) + assert np.array_equal(filtered_image_pipeline, expected_output) + +def test_sequential_filter_appending(): + input_image = np.random.rand(50, 50) + + # Individual filters + gaussian_filter = Gaussian(sigma=1.5) + median_filter = Median(size=3) + maximum_filter = Maximum(size=3) + + # Sequential pipeline with filter initialized at the beginning + sequential_pipeline_initial = Sequential(gaussian_filter, median_filter, maximum_filter) + filtered_image_initial = sequential_pipeline_initial(input_image) + + # Sequential pipeline with filter appended + sequential_pipeline_appended = Sequential(gaussian_filter, median_filter) + sequential_pipeline_appended.append(maximum_filter) + filtered_image_appended = sequential_pipeline_appended(input_image) + + # Assertions + assert filtered_image_initial.shape == filtered_image_appended.shape == input_image.shape + assert not np.array_equal(filtered_image_appended,input_image) + assert np.array_equal(filtered_image_initial, filtered_image_appended) + +def test_assertion_error_not_filterbase_subclass(): + # Get valid filter classes + valid_filters = [subclass.__name__ for subclass in qim3d.processing.filters.FilterBase.__subclasses__()] + + # Create invalid object + invalid_filter = object() # An object that is not an instance of FilterBase + + + # Construct error message + message = f"filters should be instances of one of the following classes: {valid_filters}" + + # Use pytest.raises to catch the AssertionError + with pytest.raises(AssertionError, match=re.escape(message)): + sequential_pipeline = Sequential(invalid_filter) \ No newline at end of file