# HG changeset patch # User imgteam # Date 1766255301 0 # Node ID 5ab62693dca5f0d90c5a00e48a4e1507f954d974 # Parent 53c55776a9740d181d17589bc027ac07bba08731 planemo upload for repository https://github.com/BMCV/galaxy-image-analysis/tree/master/tools/2d_simple_filter/ commit c9cc62c508da3804872d105800993746c36cca48 diff -r 53c55776a974 -r 5ab62693dca5 filter.py --- a/filter.py Wed Dec 17 11:21:02 2025 +0000 +++ b/filter.py Sat Dec 20 18:28:21 2025 +0000 @@ -8,88 +8,180 @@ import giatools import numpy as np import scipy.ndimage as ndi -from skimage.morphology import disk + + +def image_astype(image: giatools.Image, dtype: np.dtype) -> giatools.Image: + if np.issubdtype(image.data.dtype, dtype): + return image # no conversion needed + else: + return giatools.Image( + data=image.data.astype(dtype), + axes=image.axes, + original_axes=image.original_axes, + metadata=image.metadata, + ) -def image_astype(img: giatools.Image, dtype: np.dtype) -> giatools.Image: - return giatools.Image( - data=img.data.astype(dtype), - axes=img.axes, - original_axes=img.original_axes, - metadata=img.metadata, - ) +def get_anisotropy(image: giatools.Image, axes: str) -> tuple[float, ...] | None: + """ + Get the anisotropy of the image pixels/voxels along the given `axes`. + + TODO: Migrate to `giatools.Image.get_anisotropy` with `giatools >=0.7` + """ + + # Determine the pixel/voxel size + voxel_size = list() + for axis in axes: + match axis: + case 'X': + if image.metadata.pixel_size is None: + return None # unknown size + else: + voxel_size.append(image.metadata.pixel_size[0]) + case 'Y': + if image.metadata.pixel_size is None: + return None # unknown size + else: + voxel_size.append(image.metadata.pixel_size[1]) + case 'Z': + if image.metadata.z_spacing is None: + return None # unknown size + else: + voxel_size.append(image.metadata.z_spacing) + + # Check for unknown size and compute anisotropy + if any(abs(s) < 1e-8 for s in voxel_size): + print('Unknown pixel/voxel size') + return None + else: + denom = pow(np.prod(voxel_size), 1 / len(voxel_size)) # geometric mean + anisotropy = tuple(np.divide(voxel_size, denom).tolist()) + print(f'Anisotropy of {axes} pixels/voxels:', anisotropy) + return anisotropy + + +def get_anisotropic_size(image: giatools.Image, axes: str, size: int) -> tuple[int, ...] | int: + if (anisotropy := get_anisotropy(image, axes)) is not None: + return tuple( + np.divide(size, anisotropy).round().clip(1, np.inf).astype(int).tolist(), + ) + else: + return size -filters = { - 'gaussian': lambda img, sigma, order=0, axis=None: ( - apply_2d_filter( +class Filters: + + @staticmethod + def gaussian( + image: giatools.Image, + sigma: float, + anisotropic: bool, + axes: str, + order: int = 0, + direction: int | None = None, + **kwargs: Any, + ) -> giatools.Image: + if direction is None: + _order = 0 + elif order >= 1: + _order = [0] * len(axes) + _order[direction] = order + _order = tuple(_order) + if anisotropic and (anisotropy := get_anisotropy(image, axes)) is not None: + _sigma = tuple(np.divide(sigma, anisotropy).tolist()) + else: + _sigma = sigma + return apply_nd_filter( ndi.gaussian_filter, - img if order == 0 else image_astype(img, float), - sigma=sigma, - order=order, - axes=axis, + image, + sigma=_sigma, + order=_order, + axes=axes, + **kwargs, ) - ), - 'uniform': lambda img, size: ( - apply_2d_filter(ndi.uniform_filter, img, size=size) - ), - 'median': lambda img, radius: ( - apply_2d_filter(ndi.median_filter, img, footprint=disk(radius)) - ), - 'prewitt': lambda img, axis: ( - apply_2d_filter(ndi.prewitt, img, axis=axis) - ), - 'sobel': lambda img, axis: ( - apply_2d_filter(ndi.sobel, img, axis=axis) - ), -} + + @staticmethod + def uniform(image: giatools.Image, size: int, anisotropic: bool, axes: str, **kwargs: Any) -> giatools.Image: + _size = get_anisotropic_size(image, axes, size) if anisotropic else size + return apply_nd_filter( + ndi.uniform_filter, + image, + size=_size, + axes=axes, + **kwargs, + ) + + @staticmethod + def median(image: giatools.Image, size: int, anisotropic: bool, axes: str, **kwargs: Any) -> giatools.Image: + _size = get_anisotropic_size(image, axes, size) if anisotropic else size + return apply_nd_filter( + ndi.median_filter, + image, + size=_size, + axes=axes, + **kwargs, + ) + + @staticmethod + def prewitt(image: giatools.Image, direction: int, **kwargs: Any) -> giatools.Image: + return apply_nd_filter( + ndi.prewitt, + image, + axis=direction, + **kwargs, + ) + + @staticmethod + def sobel(image: giatools.Image, direction: int, **kwargs: Any) -> giatools.Image: + return apply_nd_filter( + ndi.sobel, + image, + axis=direction, + **kwargs, + ) -def apply_2d_filter( +def apply_nd_filter( filter_impl: Callable[[np.ndarray, Any, ...], np.ndarray], - img: giatools.Image, + image: giatools.Image, + axes: str, **kwargs: Any, ) -> giatools.Image: """ - Apply the 2-D filter to the 2-D/3-D, potentially multi-frame and multi-channel image. + Apply the filter to the 2-D/3-D, potentially multi-frame and multi-channel image. """ + print( + 'Applying filter:', + filter_impl.__name__, + 'with', + ', '.join( + f'{key}={repr(value)}' for key, value in (kwargs | dict(axes=axes)).items() + if not isinstance(value, np.ndarray) + ), + ) result_data = None - for qtzc in np.ndindex( - img.data.shape[ 0], # Q axis - img.data.shape[ 1], # T axis - img.data.shape[ 2], # Z axis - img.data.shape[-1], # C axis - ): - sl = np.s_[*qtzc[:3], ..., qtzc[3]] # noqa: E999 - arr = img.data[sl] - assert arr.ndim == 2 # sanity check, should always be True + for section_sel, section_arr in image.iterate_jointly(axes): + assert len(axes) == section_arr.ndim and section_arr.ndim in (2, 3) # sanity check, always True + + # Define the section using the requested axes layout (compatible with `kwargs`) + joint_axes_original_order = ''.join(filter(lambda axis: axis in axes, image.axes)) + section = giatools.Image(section_arr, joint_axes_original_order).reorder_axes_like(axes) - # Perform 2-D filtering - res = filter_impl(arr, **kwargs) + # Perform 2-D or 3-D filtering + section_result = giatools.Image( + filter_impl(section.data, **kwargs), + axes, # axes layout compatible with `kwargs` + ).reorder_axes_like( + joint_axes_original_order, # axes order compatible to the input `image` + ) + + # Update the result image for the current section if result_data is None: - result_data = np.empty(img.data.shape, res.dtype) - result_data[sl] = res + result_data = np.empty(image.data.shape, section_result.data.dtype) + result_data[section_sel] = section_result.data # Return results - return giatools.Image(result_data, img.axes) - - -def apply_filter( - input_filepath: str, - output_filepath: str, - filter_type: str, - **kwargs: Any, -): - # Read the input image - img = giatools.Image.read(input_filepath) - - # Perform filtering - filter_impl = filters[filter_type] - res = filter_impl(img, **kwargs).normalize_axes_like(img.original_axes) - - # Adopt metadata and write the result - res.metadata = img.metadata - res.write(output_filepath, backend='tifffile') + return giatools.Image(result_data, image.axes, image.metadata) if __name__ == "__main__": @@ -102,9 +194,40 @@ # Read the config file with open(args.params) as cfgf: cfg = json.load(cfgf) + cfg.setdefault('axes', 'YX') - apply_filter( - args.input, - args.output, - **cfg, + # Read the input image + image = giatools.Image.read(args.input) + print('Input image shape:', image.data.shape) + print('Input image axes:', image.axes) + print('Input image dtype:', image.data.dtype) + + # Convert the image to the explicitly requested `dtype`, or the same as the input image + convert_to = getattr(np, cfg.pop('dtype', str(image.data.dtype))) + if np.issubdtype(image.data.dtype, convert_to): + convert_to = image.data.dtype # use the input image `dtype` if `convert_to` is a superset + elif convert_to == np.floating: + convert_to = np.float64 # use `float64` if conversion to *any* float is *required* + + # If the input image is `float16` or conversion to `float16` is requested, ... + if convert_to == np.float16: + image = image_astype(image, np.float32) # ...convert to `float32` as an intermediate + else: + image = image_astype(image, convert_to) # ...otherwise, convert now + + # Perform filtering + filter_type = cfg.pop('filter_type') + filter_impl = getattr(Filters, filter_type) + result = filter_impl(image, **cfg) + + # Apply `dtype` conversion + result = image_astype(result, convert_to) + + # Write the result + result = result.normalize_axes_like( + image.original_axes, ) + print('Output image shape:', result.data.shape) + print('Output image axes:', result.axes) + print('Output image dtype:', result.data.dtype) + result.write(args.output, backend='tifffile') diff -r 53c55776a974 -r 5ab62693dca5 filter.xml --- a/filter.xml Wed Dec 17 11:21:02 2025 +0000 +++ b/filter.xml Sat Dec 20 18:28:21 2025 +0000 @@ -1,16 +1,159 @@ - + with scipy + creators.xml tests.xml 1.16.3 - 0 - - - - + 1 + + Direction + Direction of the derivative + The axis to compute the derivative along. + + The Prewitt filter is a 2-D image filter that computes an approximation of the derivative of the image intensities in the given direction. + + + The Sobel filter is a 2-D image filter that computes an approximation of the derivative of the image intensities in the given direction. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -26,15 +169,23 @@ scipy numpy - scikit-image + ome-zarr tifffile - giatools + giatools + + + - - - - - - - - + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + = 2]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -121,131 +335,1016 @@ - + - - - - - - + Below, we use an assertion in addition to the `image_diff` comparison, to ensure that the range of + values is preserved. The motiviation behind this is that the expectation images are usually checked + visually, which means that the `image_diff` comparison is likely to ensure that the brightness of + the image is correct, thus it's good to double-check the range of values (hence the comparably large + value for `eps`). This also concerns the Median and Box filters (see below). + --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + - + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + - **Applies a standard, general-purpose 2-D filter to an image.** +**Applies a standard, general-purpose image filter to an image.** + +Support for different image types: + +- For 3-D images, filters can either be applied jointly to the 3-D image data, or separately to all 2-D slices of the image. +- For multi-channel images, filters are applied separately to all channels of the image. +- For time-series images, filter also are applied separately for all time steps. - Support for different image types: +Mean filters like the Gaussian filter, the box filter, or the median filter preserve the brightness of the image and the range +of values. Derivative filters like 1st and 2nd order Gaussians, Prewitt filters, and Sobel filters naturally may yield negative +or fractional values, and thus generally produce floating point-encoded images. The different filters are described below. + +**Gaussian filters** are crucial for pre-processing in many image analysis tasks like edge detection and object recognition. +They are also employed for image denoising, when images are deteriorated by—or approximately by—white additive Gaussian noise. +Gaussian filters are linear filters that smoothen images and blur out fine details, which is why they are also used for scale +selection. These filters use a Gaussian bell-shaped kernel for weighted averaging of pixels, giving more importance to central +pixels and less to distant ones. - - For 3-D images, the filter is applied to all z-slices of the image. - - For multi-channel images, the filter is applied to all channels of the image. - - For time-series images, the filter is also applied for all time steps. +**Gaussian derivative operators** (1st and 2nd order Gaussians) are filters that are used in image analysis for approximate +computation of the 1st and 2nd order derivatives of the image intensities. Due to their scale-space theoretical and +noise-reducing properties, they are popular candidates for edge and feature detection. They are implemented by convolving an +image with the derivative of a Gaussian function. - Mean filters like the Gaussian filter, the box filter, or the median filter preserve both the brightness of the image, and - the range of values. This does not hold for the derivative variants of the Gaussian filter, which may produce negative values. +**Box filters** are another family of linear smoothing filter, that uses a uniform kernel for arithmetic averaging of pixels. +The name stems from the rectangular shape of the kernel. The box filter corresponds to a sinc function in the frequency +domain. This causes smoothing artifacts in the spatial domain. It is rarely used as a low-pass filter and is more of academic +interest. + +**Median filters** are non-linear filters, specifically well suited for reduction of impulse noise (e.g., salt-&-pepper +noise). Median filters compute the local median intensity value. An important advantage of median filters is that they +preserve the set of intensity values in the image (or yield a subset). This trait makes them specifically well suited for +smoothing of label maps and binary images. The median filters implemented in this tool uses rectangular neighborhoods for the +computation of the local median values. + +**Prewitt and Sobel filters** are popular 2-D filters for approximate computation of the 1-st order derivatives of the image +intensities. Sobel filters have better isotropy properties than Prewitt filters. 10.1016/j.jbiotec.2017.07.019 + 10.1038/s41592-019-0686-2 diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/README.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/test-data/input/README.md Sat Dec 20 18:28:21 2025 +0000 @@ -0,0 +1,60 @@ +# Overview of the test images + +## `input1_uint8.tiff`: + +- axes: `YX` +- resolution: `(265, 329)` +- dtype: `uint8` +- metadata: none + +## `input2_float64.tiff`: + +- axes: `YX` +- resolution: `(265, 329)` +- dtype: `float64` +- metadata: + - resolution: `(1.0, 1.0)` + +## `input3_uint16.tiff`: + +- axes: `YXC` +- resolution: `(58, 64, 3)` +- dtype: `uint16` +- metadata: + - resolution: `(2.0, 1.0)` + - unit: `mm` + +## `input4_float16.tiff`: + +- axes: `ZYX` +- resolution: `(10, 15, 18)` +- dtype: `float16` +- metadata: + - resolution: `(2.0, 1.0)` + - z-spacing: `2.0` + - unit: `inch` + +## `input5.jpg`: + +- axes: `YX` +- resolution: `(10, 10, 3)` +- dtype: `uint8` + +## `input6_yx.zarr`: + +- axes: `YX` +- resolution: `(200, 200)` +- dtype: `float64` +- metadata: + - resolution: `(1.0, 1.0)` + - unit: `um` + +## `input7_zyx.zarr`: + +- axes: `ZYX` +- resolution: `(2, 64, 64)` +- dtype: `float64` +- metadata: + - resolution: `(1.0, 1.0)` + - z-spacing: `1.0` + - unit: `um` diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/input1_uint8.tiff Binary file test-data/input/input1_uint8.tiff has changed diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/input2_float64.tiff Binary file test-data/input/input2_float64.tiff has changed diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/input3_uint16.tiff Binary file test-data/input/input3_uint16.tiff has changed diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/input4_float16.tiff Binary file test-data/input/input4_float16.tiff has changed diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/input5.jpg Binary file test-data/input/input5.jpg has changed diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/ome-zarr-examples/LICENSE --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/test-data/input/ome-zarr-examples/LICENSE Sat Dec 20 18:28:21 2025 +0000 @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2023, Tommaso Comparin + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/ome-zarr-examples/image-02.zarr/.zattrs --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/test-data/input/ome-zarr-examples/image-02.zarr/.zattrs Sat Dec 20 18:28:21 2025 +0000 @@ -0,0 +1,34 @@ +{ + "multiscales": [ + { + "axes": [ + { + "name": "y", + "type": "space", + "unit": "micrometer" + }, + { + "name": "x", + "type": "space", + "unit": "micrometer" + } + ], + "datasets": [ + { + "coordinateTransformations": [ + { + "scale": [ + 1.0, + 1.0 + ], + "type": "scale" + } + ], + "path": "0" + } + ], + "version": "0.4" + } + ], + "version": "0.4" +} \ No newline at end of file diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/ome-zarr-examples/image-02.zarr/.zgroup --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/test-data/input/ome-zarr-examples/image-02.zarr/.zgroup Sat Dec 20 18:28:21 2025 +0000 @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff -r 53c55776a974 -r 5ab62693dca5 test-data/input/ome-zarr-examples/image-02.zarr/0/.zarray --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/test-data/input/ome-zarr-examples/image-02.zarr/0/.zarray Sat Dec 20 18:28:21 2025 +0000 @@ -0,0 +1,23 @@ +{ + "chunks": [ + 100, + 100 + ], + "compressor": { + "blocksize": 0, + "clevel": 5, + "cname": "lz4", + "id": "blosc", + "shuffle": 1 + }, + "dimension_separator": "/", + "dtype": "