diff options
Diffstat (limited to 'src/silx/math')
82 files changed, 24507 insertions, 0 deletions
diff --git a/src/silx/math/__init__.py b/src/silx/math/__init__.py new file mode 100644 index 0000000..d8b7d81 --- /dev/null +++ b/src/silx/math/__init__.py @@ -0,0 +1,39 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""This package provides some processing functions for 1D, 2D, 3D or nD arrays. + +For additional processing functions dedicated to 2D images, +see the silx.image package. +For OpenCL-based processing functions see the silx.opencl package. + +See silx documentation: http://www.silx.org/doc/silx/latest/ +""" + +__authors__ = ["D. Naudet", "V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "11/05/2017" + +from .histogram import Histogramnd # noqa +from .histogram import HistogramndLut # noqa +from .medianfilter import medfilt, medfilt1d, medfilt2d diff --git a/src/silx/math/_colormap.pyx b/src/silx/math/_colormap.pyx new file mode 100644 index 0000000..70857f0 --- /dev/null +++ b/src/silx/math/_colormap.pyx @@ -0,0 +1,571 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""This module provides :func:`cmap` which applies a colormap to a dataset. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "16/05/2018" + + +import os +cimport cython +from cython.parallel import prange +cimport numpy as cnumpy +from libc.math cimport frexp, sinh, sqrt +from .math_compatibility cimport asinh, isnan, isfinite, lrint, INFINITY, NAN + +import logging +import numbers + +import numpy + +__all__ = ['cmap'] + +_logger = logging.getLogger(__name__) + + +cdef int DEFAULT_NUM_THREADS +if hasattr(os, 'sched_getaffinity'): + DEFAULT_NUM_THREADS = min(4, len(os.sched_getaffinity(0))) +elif os.cpu_count() is not None: + DEFAULT_NUM_THREADS = min(4, os.cpu_count()) +else: # Fallback + DEFAULT_NUM_THREADS = 1 +# Number of threads to use for the computation (initialized to up to 4) + +cdef int USE_OPENMP_THRESHOLD = 1000 +"""OpenMP is not used for arrays with less elements than this threshold""" + +# Supported data types +ctypedef fused data_types: + cnumpy.uint8_t + cnumpy.int8_t + cnumpy.uint16_t + cnumpy.int16_t + cnumpy.uint32_t + cnumpy.int32_t + cnumpy.uint64_t + cnumpy.int64_t + float + double + long double + + +# Data types using a LUT to apply the colormap +ctypedef fused lut_types: + cnumpy.uint8_t + cnumpy.int8_t + cnumpy.uint16_t + cnumpy.int16_t + + +# Data types using default colormap implementation +ctypedef fused default_types: + cnumpy.uint32_t + cnumpy.int32_t + cnumpy.uint64_t + cnumpy.int64_t + float + double + long double + + +# Supported colors/output types +ctypedef fused image_types: + cnumpy.uint8_t + float + + +# Normalization + +ctypedef double (*NormalizationFunction)(double) nogil + + +cdef class Normalization: + """Base class for colormap normalization""" + + def apply(self, data, double vmin, double vmax): + """Apply normalization. + + :param Union[float,numpy.ndarray] data: + :param float vmin: Lower bound of the range + :param float vmax: Upper bound of the range + :rtype: Union[float,numpy.ndarray] + """ + cdef int length + cdef double[:] result + + if isinstance(data, numbers.Real): + return self.apply_double(<double> data, vmin, vmax) + else: + data = numpy.array(data, copy=False) + length = <int> data.size + result = numpy.empty(length, dtype=numpy.float64) + data1d = numpy.ravel(data) + for index in range(length): + result[index] = self.apply_double( + <double> data1d[index], vmin, vmax) + return numpy.array(result).reshape(data.shape) + + def revert(self, data, double vmin, double vmax): + """Revert normalization. + + :param Union[float,numpy.ndarray] data: + :param float vmin: Lower bound of the range + :param float vmax: Upper bound of the range + :rtype: Union[float,numpy.ndarray] + """ + cdef int length + cdef double[:] result + + if isinstance(data, numbers.Real): + return self.revert_double(<double> data, vmin, vmax) + else: + data = numpy.array(data, copy=False) + length = <int> data.size + result = numpy.empty(length, dtype=numpy.float64) + data1d = numpy.ravel(data) + for index in range(length): + result[index] = self.revert_double( + <double> data1d[index], vmin, vmax) + return numpy.array(result).reshape(data.shape) + + cdef double apply_double(self, double value, double vmin, double vmax) nogil: + """Apply normalization to a floating point value + + Override in subclass + + :param float value: + :param float vmin: Lower bound of the range + :param float vmax: Upper bound of the range + """ + return value + + cdef double revert_double(self, double value, double vmin, double vmax) nogil: + """Apply inverse of normalization to a floating point value + + Override in subclass + + :param float value: + :param float vmin: Lower bound of the range + :param float vmax: Upper bound of the range + """ + return value + + +cdef class LinearNormalization(Normalization): + """Linear normalization""" + + cdef double apply_double(self, double value, double vmin, double vmax) nogil: + return value + + cdef double revert_double(self, double value, double vmin, double vmax) nogil: + return value + + +cdef class LogarithmicNormalization(Normalization): + """Logarithmic normalization using a fast log approximation""" + cdef: + readonly int lutsize + readonly double[::1] lut # LUT used for fast log approximation + + def __cinit__(self, int lutsize=4096): + # Initialize log approximation LUT + self.lutsize = lutsize + self.lut = numpy.log2( + numpy.linspace(0.5, 1., lutsize + 1, + endpoint=True).astype(numpy.float64)) + # index_lut can overflow of 1 + self.lut[lutsize] = self.lut[lutsize - 1] + + def __dealloc__(self): + self.lut = None + + @cython.wraparound(False) + @cython.boundscheck(False) + @cython.nonecheck(False) + @cython.cdivision(True) + cdef double apply_double(self, double value, double vmin, double vmax) nogil: + """Return log10(value) fast approximation based on LUT""" + cdef double result = NAN # if value < 0.0 or value == NAN + cdef int exponent, index_lut + cdef double mantissa # in [0.5, 1) unless value == 0 NaN or +/-inf + + if value <= 0.0 or not isfinite(value): + if value == 0.0: + result = - INFINITY + elif value > 0.0: # i.e., value = +INFINITY + result = value # i.e. +INFINITY + else: + mantissa = frexp(value, &exponent) + index_lut = lrint(self.lutsize * 2 * (mantissa - 0.5)) + # 1/log2(10) = 0.30102999566398114 + result = 0.30102999566398114 * (<double> exponent + + self.lut[index_lut]) + return result + + cdef double revert_double(self, double value, double vmin, double vmax) nogil: + return 10**value + + +cdef class ArcsinhNormalization(Normalization): + """Inverse hyperbolic sine normalization""" + + cdef double apply_double(self, double value, double vmin, double vmax) nogil: + return asinh(value) + + cdef double revert_double(self, double value, double vmin, double vmax) nogil: + return sinh(value) + + +cdef class SqrtNormalization(Normalization): + """Square root normalization""" + + cdef double apply_double(self, double value, double vmin, double vmax) nogil: + return sqrt(value) + + cdef double revert_double(self, double value, double vmin, double vmax) nogil: + return value**2 + + +cdef class PowerNormalization(Normalization): + """Gamma correction: + + Linear normalization to [0, 1] followed by power normalization. + + :param gamma: Gamma correction factor + """ + + cdef: + readonly double gamma + + def __cinit__(self, double gamma): + self.gamma = gamma + + def __init__(self, gamma): + # Needed for multiple inheritance to work + pass + + cdef double apply_double(self, double value, double vmin, double vmax) nogil: + if vmin == vmax: + return 0. + elif value <= vmin: + return 0. + elif value >= vmax: + return 1. + else: + return ((value - vmin) / (vmax - vmin))**self.gamma + + cdef double revert_double(self, double value, double vmin, double vmax) nogil: + if value <= 0.: + return vmin + elif value >= 1.: + return vmax + else: + return vmin + (vmax - vmin) * value**(1.0/self.gamma) + + +# Colormap + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.nonecheck(False) +@cython.cdivision(True) +cdef image_types[:, ::1] compute_cmap( + default_types[:] data, + image_types[:, ::1] colors, + Normalization normalization, + double vmin, + double vmax, + image_types[::1] nan_color): + """Apply colormap to data. + + :param data: Input data + :param colors: Colors look-up-table + :param vmin: Lower bound of the colormap range + :param vmax: Upper bound of the colormap range + :param nan_color: Color to use for NaN value + :param normalization: Normalization to apply + :return: Data converted to colors + """ + cdef image_types[:, ::1] output + cdef double scale, value, normalized_vmin, normalized_vmax + cdef int length, nb_channels, nb_colors + cdef int channel, index, lut_index, num_threads + + nb_colors = <int> colors.shape[0] + nb_channels = <int> colors.shape[1] + length = <int> data.size + + output = numpy.empty((length, nb_channels), + dtype=numpy.array(colors, copy=False).dtype) + + normalized_vmin = normalization.apply_double(vmin, vmin, vmax) + normalized_vmax = normalization.apply_double(vmax, vmin, vmax) + + if not isfinite(normalized_vmin) or not isfinite(normalized_vmax): + raise ValueError('Colormap range is not valid') + + if normalized_vmin == normalized_vmax: + scale = 0. + else: + scale = nb_colors / (normalized_vmax - normalized_vmin) + + if length < USE_OPENMP_THRESHOLD: + num_threads = 1 + else: + num_threads = min( + DEFAULT_NUM_THREADS, + int(os.environ.get("OMP_NUM_THREADS", DEFAULT_NUM_THREADS))) + + with nogil: + for index in prange(length, num_threads=num_threads): + value = normalization.apply_double( + <double> data[index], vmin, vmax) + + # Handle NaN + if isnan(value): + for channel in range(nb_channels): + output[index, channel] = nan_color[channel] + continue + + if value <= normalized_vmin: + lut_index = 0 + elif value >= normalized_vmax: + lut_index = nb_colors - 1 + else: + lut_index = <int>((value - normalized_vmin) * scale) + # Index can overflow of 1 + if lut_index >= nb_colors: + lut_index = nb_colors - 1 + + for channel in range(nb_channels): + output[index, channel] = colors[lut_index, channel] + + return output + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.nonecheck(False) +@cython.cdivision(True) +cdef image_types[:, ::1] compute_cmap_with_lut( + lut_types[:] data, + image_types[:, ::1] colors, + Normalization normalization, + double vmin, + double vmax, + image_types[::1] nan_color): + """Convert data to colors using look-up table to speed the process. + + Only supports data of types: uint8, uint16, int8, int16. + + :param data: Input data + :param colors: Colors look-up-table + :param vmin: Lower bound of the colormap range + :param vmax: Upper bound of the colormap range + :param nan_color: Color to use for NaN values + :param normalization: Normalization to apply + :return: The generated image + """ + cdef image_types[:, ::1] output + cdef double[:] values + cdef image_types[:, ::1] lut + cdef int type_min, type_max + cdef int nb_channels, length + cdef int channel, index, lut_index, num_threads + + length = <int> data.size + nb_channels = <int> colors.shape[1] + + if lut_types is cnumpy.int8_t: + type_min = -128 + type_max = 127 + elif lut_types is cnumpy.uint8_t: + type_min = 0 + type_max = 255 + elif lut_types is cnumpy.int16_t: + type_min = -32768 + type_max = 32767 + else: # uint16_t + type_min = 0 + type_max = 65535 + + colors_dtype = numpy.array(colors).dtype + + values = numpy.arange(type_min, type_max + 1, dtype=numpy.float64) + lut = compute_cmap( + values, colors, normalization, vmin, vmax, nan_color) + + output = numpy.empty((length, nb_channels), dtype=colors_dtype) + + if length < USE_OPENMP_THRESHOLD: + num_threads = 1 + else: + num_threads = min( + DEFAULT_NUM_THREADS, + int(os.environ.get("OMP_NUM_THREADS", DEFAULT_NUM_THREADS))) + + with nogil: + # Apply LUT + for index in prange(length, num_threads=num_threads): + lut_index = data[index] - type_min + for channel in range(nb_channels): + output[index, channel] = lut[lut_index, channel] + + return output + + +# Normalizations without parameters +_BASIC_NORMALIZATIONS = { + 'linear': LinearNormalization(), + 'log': LogarithmicNormalization(), + 'arcsinh': ArcsinhNormalization(), + 'sqrt': SqrtNormalization(), + } + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.nonecheck(False) +@cython.cdivision(True) +def _cmap(data_types[:] data, + image_types[:, ::1] colors, + Normalization normalization, + double vmin, + double vmax, + image_types[::1] nan_color): + """Implementation of colormap. + + Use :func:`cmap`. + + :param data: Input data + :param colors: Colors look-up-table + :param normalization: Normalization object to apply + :param vmin: Lower bound of the colormap range + :param vmax: Upper bound of the colormap range + :param nan_color: Color to use for NaN value. + :return: The generated image + """ + cdef image_types[:, ::1] output + + # Proxy for calling the right implementation depending on data type + if data_types in lut_types: # Use LUT implementation + output = compute_cmap_with_lut( + data, colors, normalization, vmin, vmax, nan_color) + + elif data_types in default_types: # Use default implementation + output = compute_cmap( + data, colors, normalization, vmin, vmax, nan_color) + + else: + raise ValueError('Unsupported data type') + + return numpy.array(output, copy=False) + + +def cmap(data not None, + colors not None, + double vmin, + double vmax, + normalization='linear', + nan_color=None): + """Convert data to colors with provided colors look-up table. + + :param numpy.ndarray data: The input data + :param numpy.ndarray colors: Color look-up table as a 2D array. + It MUST be of type uint8 or float32 + :param vmin: Data value to map to the beginning of colormap. + :param vmax: Data value to map to the end of the colormap. + :param Union[str,Normalization] normalization: + Either a :class:`Normalization` instance or a str in: + + - 'linear' (default) + - 'log' + - 'arcsinh' + - 'sqrt' + - 'gamma' + + :param nan_color: Color to use for NaN value. + Default: A color with all channels set to 0 + :return: Array of colors. The shape of the + returned array is that of data array + the last dimension of colors. + The dtype of the returned array is that of the colors array. + :rtype: numpy.ndarray + :raises ValueError: If data of colors dtype is not supported + """ + cdef int nb_channels + cdef Normalization norm + + # Make data a numpy array of native endian type (no need for contiguity) + data = numpy.array(data, copy=False) + if data.dtype.kind not in ('b', 'i', 'u', 'f'): + raise ValueError("Unsupported data dtype: %s" % data.dtype) + native_endian_dtype = data.dtype.newbyteorder('N') + if native_endian_dtype.kind == 'f' and native_endian_dtype.itemsize == 2: + native_endian_dtype = "=f4" # Use native float32 instead of float16 + data = numpy.array(data, copy=False, dtype=native_endian_dtype) + + # Make colors a contiguous array of native endian type + colors = numpy.array(colors, copy=False) + if colors.dtype.kind == 'f': + colors_dtype = numpy.dtype('float32') + elif colors.dtype.kind in ('b', 'i', 'u'): + colors_dtype = numpy.dtype('uint8') + else: + raise ValueError("Unsupported colors dtype: %s" % colors.dtype) + if (colors_dtype.kind != colors.dtype.kind or + colors_dtype.itemsize != colors.dtype.itemsize): + # Do not warn if only endianness has changed + _logger.warning("Casting colors from %s to %s", colors.dtype, colors_dtype) + nb_channels = colors.shape[colors.ndim - 1] + colors = numpy.ascontiguousarray(colors, dtype=colors_dtype) + + # Make normalization a Normalization object + if isinstance(normalization, str): + norm = _BASIC_NORMALIZATIONS.get(normalization, None) + if norm is None: + raise ValueError('Unsupported normalization %s' % normalization) + else: + norm = normalization + + # Check nan_color + if nan_color is None: + nan_color = numpy.zeros((nb_channels,), dtype=colors.dtype) + else: + nan_color = numpy.ascontiguousarray( + nan_color, dtype=colors.dtype).reshape(-1) + assert nan_color.shape == (nb_channels,) + + image = _cmap( + data.reshape(-1), + colors.reshape(-1, nb_channels), + norm, + vmin, + vmax, + nan_color) + image.shape = data.shape + (nb_channels,) + + return image diff --git a/src/silx/math/calibration.py b/src/silx/math/calibration.py new file mode 100644 index 0000000..658e2dc --- /dev/null +++ b/src/silx/math/calibration.py @@ -0,0 +1,180 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This module provides classes to calibrate data. + +Classes +------- + +- :class:`NoCalibration` +- :class:`LinearCalibration` +- :class:`ArrayCalibration` + +""" +import numpy + + +class AbstractCalibration(object): + """A calibration is a transformation to be applied to an axis (i.e. a 1D array). + + """ + def __init__(self): + super(AbstractCalibration, self).__init__() + + def __call__(self, x): + """Apply calibration to an axis or to a value. + + :param x: Axis (1-D array), or value""" + raise NotImplementedError( + "AbstractCalibration can not be used directly. " + + "You must subclass it and implement __call__") + + def is_affine(self): + """Returns True for an affine calibration of the form + :math:`x \\mapsto a + b * x`, or else False. + """ + return False + + def get_slope(self): + raise NotImplementedError( + "get_slope is implemented only for affine calibrations") + + +class NoCalibration(AbstractCalibration): + """No calibration :math:`x \\mapsto x` + """ + def __init__(self): + super(NoCalibration, self).__init__() + + def __call__(self, x): + return x + + def is_affine(self): + return True + + def get_slope(self): + return 1. + + +class LinearCalibration(AbstractCalibration): + """Linear calibration :math:`x \\mapsto a + b x`, + where *a* is the y-intercept and *b* is the slope. + + :param y_intercept: y-intercept + :param slope: Slope of the affine transformation + """ + def __init__(self, y_intercept, slope): + super(LinearCalibration, self).__init__() + self.constant = y_intercept + self.slope = slope + + def __call__(self, x): + return self.constant + self.slope * x + + def is_affine(self): + return True + + def get_slope(self): + return self.slope + + +class ArrayCalibration(AbstractCalibration): + """One-to-one mapping calibration, defined by an array *x'*, + such as :math:`x \\mapsto x'`. + + This calibration can only be applied to x arrays of the same length as the + calibration array *x'*. + It is typically applied to an axis of indices or + channels (:math:`0, 1, ..., n-1`). + + :param x1: Calibration array""" + def __init__(self, x1): + super(ArrayCalibration, self).__init__() + if not isinstance(x1, (list, tuple)) and not hasattr(x1, "shape"): + raise TypeError( + "The calibration array must be a sequence (list, dataset, array)") + self.calibration_array = numpy.array(x1) + self._is_affine = None + + def __call__(self, x): + # calibrate the entire axis + if isinstance(x, (list, tuple, numpy.ndarray)) and \ + len(self.calibration_array) == len(x): + return self.calibration_array + # calibrate one value, by index + if isinstance(x, int) and x < len(self.calibration_array): + return self.calibration_array[x] + raise ValueError("ArrayCalibration must be applied to array of same size " + "or to index.") + + def is_affine(self): + """If all values in the calibration array are regularly spaced, + return True.""" + if self._is_affine is None: + delta_x = self.calibration_array[1:] - self.calibration_array[:-1] + # use a less strict relative tolerance to account for rounding errors + # e.g. when using float64 into float32 (see #1823) + if not numpy.isclose(delta_x, delta_x[0], rtol=1e-4).all(): + self._is_affine = False + else: + self._is_affine = True + return self._is_affine + + def get_slope(self): + """If the calibration array is regularly spaced, return the spacing.""" + if not self.is_affine(): + raise AttributeError( + "get_slope only makes sense for affine transformations" + ) + return self.calibration_array[1] - self.calibration_array[0] + + +class FunctionCalibration(AbstractCalibration): + """Calibration defined by a function *f*, such as :math:`x \\mapsto f(x)`*. + + :param function: Calibration function""" + def __init__(self, function, is_affine=False): + super(FunctionCalibration, self).__init__() + if not hasattr(function, "__call__"): + raise TypeError("The calibration function must be a callable") + self.function = function + self._is_affine = is_affine + + def __call__(self, x): + return self.function(x) + + def is_affine(self): + """Return True if calibration is affine. + This is False by default, unless the object is instantiated with + ``is_affine=True``.""" + return self._is_affine + + def get_slope(self): + """If the calibration array is regularly spaced, return the spacing.""" + if not self.is_affine(): + raise AttributeError( + "get_slope only makes sense for affine transformations" + ) + # fixme: what if function is not defined at x=1 or x=2? + return self.function(2) - self.function(1) diff --git a/src/silx/math/chistogramnd.pyx b/src/silx/math/chistogramnd.pyx new file mode 100644 index 0000000..8484f35 --- /dev/null +++ b/src/silx/math/chistogramnd.pyx @@ -0,0 +1,1251 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["D. Naudet"] +__license__ = "MIT" +__date__ = "02/10/2017" + +cimport numpy as cnumpy # noqa +cimport cython +import numpy as np + +cimport silx.math.histogramnd_c as histogramnd_c + + +def chistogramnd(sample, + histo_range, + n_bins, + weights=None, + weight_min=None, + weight_max=None, + last_bin_closed=False, + histo=None, + weighted_histo=None, + wh_dtype=None): + """Computes the multidimensional histogram of some data. + + :param sample: + The data to be histogrammed. + Its shape must be either + (N,) if it contains one dimensional coordinates, + or an (N,D) array where the rows are the + coordinates of points in a D dimensional space. + The following dtypes are supported : :class:`numpy.float64`, + :class:`numpy.float32`, :class:`numpy.int32`. + + .. warning:: if sample is not a C_CONTIGUOUS ndarray (e.g : a non + contiguous slice) then histogramnd will have to do make an internal + copy. + :type sample: :class:`numpy.array` + + :param histo_range: + A (N, 2) array containing the histogram range along each dimension, + where N is the sample's number of dimensions. + :type histo_range: array_like + + :param n_bins: + The number of bins : + * a scalar (same number of bins for all dimensions) + * a D elements array (number of bins for each dimensions) + :type n_bins: scalar or array_like + + :param weights: + A N elements numpy array of values associated with + each sample. + The values of the *weighted_histo* array + returned by the function are equal to the sum of + the weights associated with the samples falling + into each bin. + The following dtypes are supported : :class:`numpy.float64`, + :class:`numpy.float32`, :class:`numpy.int32`. + + .. note:: If None, the weighted histogram returned will be None. + :type weights: *optional*, :class:`numpy.array` + + :param weight_min: + Use this parameter to filter out all samples whose + weights are lower than this value. + + .. note:: This value will be cast to the same type + as *weights*. + :type weight_min: *optional*, scalar + + :param weight_max: + Use this parameter to filter out all samples whose + weights are higher than this value. + + .. note:: This value will be cast to the same type + as *weights*. + + :type weight_max: *optional*, scalar + + :param last_bin_closed: + By default the last bin is half + open (i.e.: [x,y) ; x included, y + excluded), like all the other bins. + Set this parameter to true if you want + the LAST bin to be closed. + :type last_bin_closed: *optional*, :class:`python.boolean` + + :param histo: + Use this parameter if you want to pass your + own histogram array instead of the one + created by this function. New values + will be added to this array. The returned array + will then be this one (same reference). + + .. warning:: If the histo array was created by a previous + call to histogramnd then the user is + responsible for providing the same parameters + (*n_bins*, *histo_range*, ...). + :type histo: *optional*, :class:`numpy.array` + + :param weighted_histo: + Use this parameter if you want to pass your + own weighted histogram array instead of + the created by this function. New + values will be added to this array. The returned array + will then be this one (same reference). + + .. warning:: If the weighted_histo array was created by a previous + call to histogramnd then the user is + responsible for providing the same parameters + (*n_bins*, *histo_range*, ...). + + .. warning:: if weighted_histo is not a C_CONTIGUOUS ndarray (e.g : a + non contiguous slice) then histogramnd will have to do make an + internal copy. + :type weighted_histo: *optional*, :class:`numpy.array` + + :param wh_dtype: type of the weighted histogram array. This parameter is + ignored if *weighted_histo* is provided. If not provided, the + weighted histogram array will contain values of the same type as + *weights*. Allowed values are : `numpu.double` and `numpy.float32`. + :type wh_dtype: *optional*, numpy data type + + :return: Histogram (bin counts, always returned), weighted histogram of + the sample (or *None* if weights is *None*) and bin edges for each + dimension. + :rtype: *tuple* (:class:`numpy.array`, :class:`numpy.array`, `tuple`) or + (:class:`numpy.array`, None, `tuple`) + """ + + if wh_dtype is None: + wh_dtype = np.double + elif wh_dtype not in (np.double, np.float32): + raise ValueError('<wh_dtype> type not supported : {0}.'.format(wh_dtype)) + + if (weighted_histo is not None and + weighted_histo.flags['C_CONTIGUOUS'] is False): + raise ValueError('<weighted_histo> must be a C_CONTIGUOUS numpy array.') + + if histo is not None and histo.flags['C_CONTIGUOUS'] is False: + raise ValueError('<histo> must be a C_CONTIGUOUS numpy array.') + + s_shape = sample.shape + + n_dims = 1 if len(s_shape) == 1 else s_shape[1] + + if weights is not None: + w_shape = weights.shape + + # making sure the sample and weights sizes are coherent + # 2 different cases : 2D sample (N,M) and 1D (N) + if len(w_shape) != 1 or w_shape[0] != s_shape[0]: + raise ValueError('<weights> must be an array whose length ' + 'is equal to the number of samples.') + + weights_type = weights.dtype + else: + weights_type = None + + # just in case those arent numpy arrays + # (this allows the user to provide native python lists, + # => easier for testing) + i_histo_range = histo_range + histo_range = np.array(histo_range) + err_histo_range = False + + if n_dims == 1: + if histo_range.shape == (2,): + pass + elif histo_range.shape == (1, 2): + histo_range.shape = -1 + else: + err_histo_range = True + elif n_dims != 1 and histo_range.shape != (n_dims, 2): + err_histo_range = True + + if err_histo_range: + raise ValueError('<histo_range> error : expected {n_dims} sets of ' + 'lower and upper bin edges, ' + 'got the following instead : {histo_range}. ' + '(provided <sample> contains ' + '{n_dims}D values)' + ''.format(histo_range=i_histo_range, + n_dims=n_dims)) + + # check range value + if np.inf in histo_range: + raise ValueError('Range parameter should be finite value') + if np.nan in histo_range: + raise ValueError('Range value can\'t be nan') + + # checking n_bins size + n_bins = np.array(n_bins, ndmin=1) + if len(n_bins) == 1: + n_bins = np.tile(n_bins, n_dims) + elif n_bins.shape != (n_dims,): + raise ValueError('n_bins must be either a scalar (same number ' + 'of bins for all dimensions) or ' + 'an array (number of bins for each ' + 'dimension).') + + # checking if None is in n_bins, otherwise a rather cryptic + # exception is thrown when calling np.zeros + # also testing for negative/null values + if np.any(np.equal(n_bins, None)) or np.any(n_bins <= 0): + raise ValueError('<n_bins> : only positive values allowed.') + + output_shape = tuple(n_bins) + + # checking the histo array, if provided + if histo is None: + histo = np.zeros(output_shape, dtype=np.uint32) + else: + if histo.shape != output_shape: + raise ValueError('Provided <histo> array doesn\'t have ' + 'a shape compatible with <n_bins> ' + ': should be {0} instead of {1}.' + ''.format(output_shape, histo.shape)) + if histo.dtype != np.uint32: + raise ValueError('Provided <histo> array doesn\'t have ' + 'the expected type ' + ': should be {0} instead of {1}.' + ''.format(np.uint32, histo.dtype)) + + # checking the weighted_histo array, if provided + if weights_type is None: + # no weights provided, not creating the weighted_histo array + weighted_histo = None + elif weighted_histo is None: + # weights provided, but no weighted_histo, creating it + if wh_dtype is None: + wh_dtype = weights_type + weighted_histo = np.zeros(output_shape, dtype=wh_dtype) + else: + # weighted_histo provided, checking shape/dtype + if weighted_histo.shape != output_shape: + raise ValueError('Provided <weighted_histo> array doesn\'t have ' + 'a shape compatible with <n_bins> ' + ': should be {0} instead of {1}.' + ''.format(output_shape, weighted_histo.shape)) + if (weighted_histo.dtype != np.float64 and + weighted_histo.dtype != np.float32): + raise ValueError('Provided <weighted_histo> array doesn\'t have ' + 'the expected type ' + ': should be {0} or {1} instead of {2}.' + ''.format(np.double, + np.float32, + weighted_histo.dtype)) + + option_flags = 0 + + if weight_min is not None: + option_flags |= histogramnd_c.HISTO_WEIGHT_MIN + else: + weight_min = 0 + + if weight_max is not None: + option_flags |= histogramnd_c.HISTO_WEIGHT_MAX + else: + weight_max = 0 + + if last_bin_closed is not None and last_bin_closed: + option_flags |= histogramnd_c.HISTO_LAST_BIN_CLOSED + + sample_type = sample.dtype + sample_type = sample_type.newbyteorder('N') + + n_elem = sample.size // n_dims + + bin_edges = np.zeros(n_bins.sum() + n_bins.size, dtype=np.double) + + # wanted to store the functions in a dict (with the supported types + # as keys, but I couldn't find a way to make it work with cdef + # functions. so I have to explicitly list them all... + + def raise_unsupported_type(): + raise TypeError('Case not supported - sample:{0} ' + 'and weights:{1}.' + ''.format(sample_type, weights_type)) + + sample_c = np.ascontiguousarray(sample.reshape((sample.size,)), + dtype=sample_type) + + weights_c = (np.ascontiguousarray(weights.reshape((weights.size,)), + dtype=weights.dtype.newbyteorder('N')) + if weights is not None else None) + + histo_range_c = np.ascontiguousarray(histo_range.reshape((histo_range.size,)), + dtype=np.double) + + n_bins_c = np.ascontiguousarray(n_bins.reshape((n_bins.size,)), + dtype=np.int32) + + histo_c = histo.reshape((histo.size,)) + + if weighted_histo is not None: + cumul_c = weighted_histo.reshape((weighted_histo.size,)) + else: + cumul_c = None + + bin_edges_c = np.ascontiguousarray(bin_edges.reshape((bin_edges.size,)), + dtype=bin_edges.dtype.newbyteorder('N')) + + rc = 0 + + if weighted_histo is None or weighted_histo.dtype == np.double: + + if sample_type == np.float64: + + if weights_type == np.float64 or weights_type is None: + + rc = _histogramnd_double_double_double(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.float32: + + rc = _histogramnd_double_float_double(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.int32: + + rc = _histogramnd_double_int32_t_double(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + else: + raise_unsupported_type() + + # endif sample_type == np.float64 + elif sample_type == np.float32: + + if weights_type == np.float64 or weights_type is None: + + rc = _histogramnd_float_double_double(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.float32: + + rc = _histogramnd_float_float_double(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.int32: + + rc = _histogramnd_float_int32_t_double(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + else: + raise_unsupported_type() + + # endif sample_type == np.float32 + elif sample_type == np.int32: + + if weights_type == np.float64 or weights_type is None: + + rc = _histogramnd_int32_t_double_double(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.float32: + + rc = _histogramnd_int32_t_float_double(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.int32: + + rc = _histogramnd_int32_t_int32_t_double(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + else: + raise_unsupported_type() + + # endif sample_type == np.int32: + else: + raise_unsupported_type() + + # endif weighted_histo is None or weighted_histo.dtype == np.double: + elif weighted_histo.dtype == np.float32: + + if sample_type == np.float64: + + if weights_type == np.float64 or weights_type is None: + + rc = _histogramnd_double_double_float(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.float32: + + rc = _histogramnd_double_float_float(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.int32: + + rc = _histogramnd_double_int32_t_float(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + else: + raise_unsupported_type() + + # endif sample_type == np.float64 + elif sample_type == np.float32: + + if weights_type == np.float64 or weights_type is None: + + rc = _histogramnd_float_double_float(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.float32: + + rc = _histogramnd_float_float_float(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.int32: + + rc = _histogramnd_float_int32_t_float(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + else: + raise_unsupported_type() + + # endif sample_type == np.float32 + elif sample_type == np.int32: + + if weights_type == np.float64 or weights_type is None: + + rc = _histogramnd_int32_t_double_float(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.float32: + + rc = _histogramnd_int32_t_float_float(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + elif weights_type == np.int32: + + rc = _histogramnd_int32_t_int32_t_float(sample_c, + weights_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + histo_c, + cumul_c, + bin_edges_c, + option_flags, + weight_min=weight_min, + weight_max=weight_max) + + else: + raise_unsupported_type() + + # endif sample_type == np.int32: + else: + raise_unsupported_type() + + # end elseif weighted_histo.dtype == np.float32: + else: + # this isnt supposed to happen since weighted_histo type was checked earlier + raise_unsupported_type() + + if rc != histogramnd_c.HISTO_OK: + if rc == histogramnd_c.HISTO_ERR_ALLOC: + raise MemoryError('histogramnd failed to allocate memory.') + else: + raise Exception('histogramnd returned an error : {0}' + ''.format(rc)) + + edges = [] + offset = 0 + for i_dim in range(n_dims): + edges.append(bin_edges[offset:offset + n_bins[i_dim] + 1]) + offset += n_bins[i_dim] + 1 + + return histo, weighted_histo, tuple(edges) + +# ===================== +# double sample, double cumul +# ===================== + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_double_double_double(double[:] sample, + double[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + double[:] cumul, + double[:] bin_edges, + int option_flags, + double weight_min, + double weight_max) nogil: + + return histogramnd_c.histogramnd_double_double_double(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_double_float_double(double[:] sample, + float[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + double[:] cumul, + double[:] bin_edges, + int option_flags, + float weight_min, + float weight_max) nogil: + + return histogramnd_c.histogramnd_double_float_double(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_double_int32_t_double(double[:] sample, + cnumpy.int32_t[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + double[:] cumul, + double[:] bin_edges, + int option_flags, + cnumpy.int32_t weight_min, + cnumpy.int32_t weight_max) nogil: + + return histogramnd_c.histogramnd_double_int32_t_double(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +# ===================== +# float sample, double cumul +# ===================== + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_float_double_double(float[:] sample, + double[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + double[:] cumul, + double[:] bin_edges, + int option_flags, + double weight_min, + double weight_max) nogil: + + return histogramnd_c.histogramnd_float_double_double(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_float_float_double(float[:] sample, + float[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + double[:] cumul, + double[:] bin_edges, + int option_flags, + float weight_min, + float weight_max) nogil: + + return histogramnd_c.histogramnd_float_float_double(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_float_int32_t_double(float[:] sample, + cnumpy.int32_t[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + double[:] cumul, + double[:] bin_edges, + int option_flags, + cnumpy.int32_t weight_min, + cnumpy.int32_t weight_max) nogil: + + return histogramnd_c.histogramnd_float_int32_t_double(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +# ===================== +# numpy.int32_t sample, double cumul +# ===================== + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_int32_t_double_double(cnumpy.int32_t[:] sample, + double[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + double[:] cumul, + double[:] bin_edges, + int option_flags, + double weight_min, + double weight_max) nogil: + + return histogramnd_c.histogramnd_int32_t_double_double(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_int32_t_float_double(cnumpy.int32_t[:] sample, + float[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + double[:] cumul, + double[:] bin_edges, + int option_flags, + float weight_min, + float weight_max) nogil: + + return histogramnd_c.histogramnd_int32_t_float_double(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_int32_t_int32_t_double(cnumpy.int32_t[:] sample, + cnumpy.int32_t[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + double[:] cumul, + double[:] bin_edges, + int option_flags, + cnumpy.int32_t weight_min, + cnumpy.int32_t weight_max) nogil: + + return histogramnd_c.histogramnd_int32_t_int32_t_double(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +# ===================== +# double sample, float cumul +# ===================== + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_double_double_float(double[:] sample, + double[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + float[:] cumul, + double[:] bin_edges, + int option_flags, + double weight_min, + double weight_max) nogil: + + return histogramnd_c.histogramnd_double_double_float(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_double_float_float(double[:] sample, + float[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + float[:] cumul, + double[:] bin_edges, + int option_flags, + float weight_min, + float weight_max) nogil: + + return histogramnd_c.histogramnd_double_float_float(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_double_int32_t_float(double[:] sample, + cnumpy.int32_t[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + float[:] cumul, + double[:] bin_edges, + int option_flags, + cnumpy.int32_t weight_min, + cnumpy.int32_t weight_max) nogil: + + return histogramnd_c.histogramnd_double_int32_t_float(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +# ===================== +# float sample, float cumul +# ===================== + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_float_double_float(float[:] sample, + double[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + float[:] cumul, + double[:] bin_edges, + int option_flags, + double weight_min, + double weight_max) nogil: + + return histogramnd_c.histogramnd_float_double_float(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_float_float_float(float[:] sample, + float[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + float[:] cumul, + double[:] bin_edges, + int option_flags, + float weight_min, + float weight_max) nogil: + + return histogramnd_c.histogramnd_float_float_float(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_float_int32_t_float(float[:] sample, + cnumpy.int32_t[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + float[:] cumul, + double[:] bin_edges, + int option_flags, + cnumpy.int32_t weight_min, + cnumpy.int32_t weight_max) nogil: + + return histogramnd_c.histogramnd_float_int32_t_float(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +# ===================== +# numpy.int32_t sample, float cumul +# ===================== + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_int32_t_double_float(cnumpy.int32_t[:] sample, + double[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + float[:] cumul, + double[:] bin_edges, + int option_flags, + double weight_min, + double weight_max) nogil: + + return histogramnd_c.histogramnd_int32_t_double_float(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_int32_t_float_float(cnumpy.int32_t[:] sample, + float[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + float[:] cumul, + double[:] bin_edges, + int option_flags, + float weight_min, + float weight_max) nogil: + + return histogramnd_c.histogramnd_int32_t_float_float(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +cdef int _histogramnd_int32_t_int32_t_float(cnumpy.int32_t[:] sample, + cnumpy.int32_t[:] weights, + int n_dims, + int n_elem, + double[:] histo_range, + int[:] n_bins, + cnumpy.uint32_t[:] histo, + float[:] cumul, + double[:] bin_edges, + int option_flags, + cnumpy.int32_t weight_min, + cnumpy.int32_t weight_max) nogil: + + return histogramnd_c.histogramnd_int32_t_int32_t_float(&sample[0], + &weights[0], + n_dims, + n_elem, + &histo_range[0], + &n_bins[0], + &histo[0], + &cumul[0], + &bin_edges[0], + option_flags, + weight_min, + weight_max) diff --git a/src/silx/math/chistogramnd_lut.pyx b/src/silx/math/chistogramnd_lut.pyx new file mode 100644 index 0000000..3a3f05e --- /dev/null +++ b/src/silx/math/chistogramnd_lut.pyx @@ -0,0 +1,435 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["D. Naudet"] +__license__ = "MIT" +__date__ = "15/05/2016" + + +cimport numpy as cnumpy # noqa +cimport cython +import numpy as np + +ctypedef fused sample_t: + cnumpy.float64_t + cnumpy.float32_t + cnumpy.int32_t + cnumpy.int64_t + +ctypedef fused cumul_t: + cnumpy.float64_t + cnumpy.float32_t + cnumpy.int32_t + cnumpy.int64_t + +ctypedef fused weights_t: + cnumpy.float64_t + cnumpy.float32_t + cnumpy.int32_t + cnumpy.int64_t + +ctypedef fused lut_t: + cnumpy.int64_t + cnumpy.int32_t + cnumpy.int16_t + + +def histogramnd_get_lut(sample, + histo_range, + n_bins, + last_bin_closed=False): + """TBD + + :param sample: + The data to be histogrammed. + Its shape must be either (N,) if it contains one dimensional + coordinates, or an (N, D) array where the rows are the + coordinates of points in a D dimensional space. + The following dtypes are supported : :class:`numpy.float64`, + :class:`numpy.float32`, :class:`numpy.int32`. + :type sample: :class:`numpy.array` + + :param histo_range: + A (N, 2) array containing the histogram range along each dimension, + where N is the sample's number of dimensions. + :type histo_range: array_like + + :param n_bins: + The number of bins : + * a scalar (same number of bins for all dimensions) + * a D elements array (number of bins for each dimensions) + :type n_bins: scalar or array_like + + :param last_bin_closed: + By default the last bin is half + open (i.e.: [x,y) ; x included, y + excluded), like all the other bins. + Set this parameter to true if you want + the LAST bin to be closed. + :type last_bin_closed: *optional*, :class:`python.boolean` + + :return: The indices for each sample and the histogram (bin counts). + :rtype: tuple : (:class:`numpy.array`, :class:`numpy.array`) + """ + + s_shape = sample.shape + + n_dims = 1 if len(s_shape) == 1 else s_shape[1] + + # just in case those arent numpy arrays + # (this allows the user to provide native python lists, + # => easier for testing) + i_histo_range = histo_range + histo_range = np.array(histo_range) + err_histo_range = False + + if n_dims == 1: + if histo_range.shape == (2,): + pass + elif histo_range.shape == (1, 2): + histo_range.reshape(-1) + else: + err_histo_range = True + elif n_dims != 1 and histo_range.shape != (n_dims, 2): + err_histo_range = True + + if err_histo_range: + raise ValueError('<histo_range> error : expected {n_dims} sets of ' + 'lower and upper bin edges, ' + 'got the following instead : {histo_range}. ' + '(provided <sample> contains ' + '{n_dims}D values)' + ''.format(histo_range=i_histo_range, + n_dims=n_dims)) + + histo_range = np.double(histo_range) + + # checking n_bins size + n_bins = np.array(n_bins, ndmin=1) + if len(n_bins) == 1: + n_bins = np.tile(n_bins, n_dims) + elif n_bins.shape != (n_dims,): + raise ValueError('n_bins must be either a scalar (same number ' + 'of bins for all dimensions) or ' + 'an array (number of bins for each ' + 'dimension).') + + # checking if None is in n_bins, otherwise a rather cryptic + # exception is thrown when calling np.zeros + # also testing for negative/null values + if np.any(np.equal(n_bins, None)) or np.any(n_bins <= 0): + raise ValueError('<n_bins> : only positive values allowed.') + + sample_type = sample.dtype + + n_elem = sample.size // n_dims + + if n_bins.prod(dtype=np.uint64) < 2**15: + lut_dtype = np.int16 + elif n_bins.prod(dtype=np.uint64) < 2**31: + lut_dtype = np.int32 + + else: + lut_dtype = np.int64 + + # allocating the output arrays + lut = np.zeros(n_elem, dtype=lut_dtype) + histo = np.zeros(n_bins, dtype=np.uint32) + + dtype = sample.dtype.newbyteorder("N") + sample_c = np.ascontiguousarray(sample.reshape((sample.size,)), + dtype=dtype) + + histo_range_c = np.ascontiguousarray(histo_range.reshape((histo_range.size,)), + dtype=histo_range.dtype.newbyteorder("N")) + + n_bins_c = np.ascontiguousarray(n_bins.reshape((n_bins.size,)), + dtype=np.int32) + + lut_c = np.ascontiguousarray(lut.reshape((lut.size,))) + histo_c = np.ascontiguousarray(histo.reshape((histo.size,)), + dtype=histo.dtype.newbyteorder('N')) + + rc = 0 + + try: + rc = _histogramnd_get_lut_fused(sample_c, + n_dims, + n_elem, + histo_range_c, + n_bins_c, + lut_c, + histo_c, + last_bin_closed) + except TypeError as ex: + raise TypeError('Type not supported - sample : {0}' + ''.format(sample_type)) + + if rc != 0: + raise Exception('histogramnd returned an error : {0}' + ''.format(rc)) + + edges = [] + histo_range = histo_range.reshape(-1) + for i_dim in range(n_dims): + dim_edges = np.zeros(n_bins[i_dim] + 1) + rng_min = histo_range[2 * i_dim] + rng_max = histo_range[2 * i_dim + 1] + dim_edges[:-1] = (rng_min + np.arange(n_bins[i_dim]) * + ((rng_max - rng_min) / n_bins[i_dim])) + dim_edges[-1] = rng_max + edges.append(dim_edges) + + return lut, histo, tuple(edges) + + +# ===================== +# ===================== + + +def histogramnd_from_lut(weights, + histo_lut, + histo=None, + weighted_histo=None, + shape=None, + dtype=None, + weight_min=None, + weight_max=None): + """ + dtype ignored if weighted_histo provided + """ + + if histo is None and weighted_histo is None: + if shape is None: + raise ValueError('At least one of the following parameters has to ' + 'be provided : <shape> or <histo> or ' + '<weighted_histo>') + + if shape is not None: + if histo is not None and list(histo.shape) != list(shape): + raise ValueError('The <shape> value does not match' + 'the <histo> shape.') + + if(weighted_histo is not None and + list(weighted_histo.shape) != list(shape)): + raise ValueError('The <shape> value does not match' + 'the <weighted_histo> shape.') + else: + if histo is not None: + shape = histo.shape + else: + shape = weighted_histo.shape + + if histo is not None: + if histo.dtype != np.uint32: + raise ValueError('Provided <histo> array doesn\'t have ' + 'the expected type ' + ': should be {0} instead of {1}.' + ''.format(np.uint32, histo.dtype)) + + if weighted_histo is not None: + if histo.shape != weighted_histo.shape: + raise ValueError('The <histo> shape does not match' + 'the <weighted_histo> shape.') + else: + histo = np.zeros(shape, dtype=np.uint32) + + w_dtype = weights.dtype + + if dtype is None: + if weighted_histo is None: + dtype = w_dtype + else: + dtype = weighted_histo.dtype + elif weighted_histo is not None: + if weighted_histo.dtype != dtype: + raise ValueError('Provided <dtype> and <weighted_histo>\'s dtype' + ' do not match.') + dtype = weighted_histo.dtype + + if weighted_histo is None: + weighted_histo = np.zeros(shape, dtype=dtype) + + if histo_lut.size != weights.size: + raise ValueError('The LUT and weights arrays must have the same ' + 'number of elements.') + + w_c = np.ascontiguousarray(weights.reshape((weights.size,)), + dtype=weights.dtype.newbyteorder('N')) + + h_c = np.ascontiguousarray(histo.reshape((histo.size,)), + dtype=histo.dtype.newbyteorder('N')) + + w_h_c = np.ascontiguousarray(weighted_histo.reshape((weighted_histo.size,)), + dtype=weighted_histo.dtype.newbyteorder('N')) # noqa + + h_lut_c = np.ascontiguousarray(histo_lut.reshape((histo_lut.size,)), + histo_lut.dtype.newbyteorder('N')) + + rc = 0 + + if weight_min is None: + weight_min = 0 + filt_min_weights = False + else: + filt_min_weights = True + + if weight_max is None: + weight_max = 0 + filt_max_weights = False + else: + filt_max_weights = True + + try: + _histogramnd_from_lut_fused(w_c, + h_lut_c, + h_c, + w_h_c, + weights.size, + filt_min_weights, + w_dtype.type(weight_min), + filt_max_weights, + w_dtype.type(weight_max)) + except TypeError as ex: + print(ex) + raise TypeError('Case not supported - weights:{0} ' + 'and histo:{1}.' + ''.format(weights.dtype, histo.dtype)) + + return histo, weighted_histo + + +# ===================== +# ===================== + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +@cython.cdivision(True) +def _histogramnd_from_lut_fused(weights_t[:] i_weights, + lut_t[:] i_lut, + cnumpy.uint32_t[:] o_histo, + cumul_t[:] o_weighted_histo, + int i_n_elems, + bint i_filt_min_weights, + weights_t i_weight_min, + bint i_filt_max_weights, + weights_t i_weight_max): + with nogil: + for i in range(i_n_elems): + if (i_lut[i] >= 0): + if i_filt_min_weights and i_weights[i] < i_weight_min: + continue + if i_filt_max_weights and i_weights[i] > i_weight_max: + continue + o_histo[i_lut[i]] += 1 + o_weighted_histo[i_lut[i]] += <cumul_t>i_weights[i] # noqa + + +# ===================== +# ===================== + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.initializedcheck(False) +@cython.nonecheck(False) +@cython.cdivision(True) +def _histogramnd_get_lut_fused(sample_t[:] i_sample, + int i_n_dims, + int i_n_elems, + double[:] i_histo_range, + int[:] i_n_bins, + lut_t[:] o_lut, + cnumpy.uint32_t[:] o_histo, + bint last_bin_closed): + + cdef: + int i = 0 + long elem_idx = 0 + long max_idx = 0 + long lut_idx = -1 + + # computed bin index (i_sample -> grid) + long bin_idx = 0 + + sample_t elem_coord = 0 + + double[50] g_min + double[50] g_max + double[50] bins_range + + for i in range(i_n_dims): + g_min[i] = i_histo_range[2*i] + g_max[i] = i_histo_range[2*i+1] + bins_range[i] = g_max[i] - g_min[i] + + elem_idx = 0 - i_n_dims + max_idx = i_n_elems * i_n_dims - i_n_dims + + with nogil: + while elem_idx < max_idx: + elem_idx += i_n_dims + lut_idx += 1 + + bin_idx = 0 + + for i in range(i_n_dims): + elem_coord = i_sample[elem_idx+i] + # ===================== + # Element is rejected if any of the following is NOT true : + # 1. coordinate is >= than the minimum value + # 2. coordinate is <= than the maximum value + # 3. coordinate==maximum value and last_bin_closed is True + # ===================== + if elem_coord < g_min[i]: + bin_idx = -1 + break + + # Here we make the assumption that most of the time + # there will be more coordinates inside the grid interval + # (one test) + # than coordinates higher or equal to the max + # (two tests) + if elem_coord < g_max[i]: + bin_idx = <long>(bin_idx * i_n_bins[i] + # noqa + (((elem_coord - g_min[i]) * i_n_bins[i]) / + bins_range[i])) + else: + # if equal and the last bin is closed : + # put it in the last bin + # else : discard + if last_bin_closed and elem_coord == g_max[i]: + bin_idx = (bin_idx + 1) * i_n_bins[i] - 1 + else: + bin_idx = -1 + break + + o_lut[lut_idx] = bin_idx + if bin_idx >= 0: + o_histo[bin_idx] += 1 + + return 0 diff --git a/src/silx/math/colormap.py b/src/silx/math/colormap.py new file mode 100644 index 0000000..43b8949 --- /dev/null +++ b/src/silx/math/colormap.py @@ -0,0 +1,450 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""This module provides helper functions for applying colormaps to datasets""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/08/2021" + + +import collections +import warnings +import numpy + +from ..resources import resource_filename as _resource_filename +from .combo import min_max as _min_max +from . import _colormap +from ._colormap import cmap # noqa + + +__all__ = ["apply_colormap", "cmap"] + + +_LUT_DESCRIPTION = collections.namedtuple("_LUT_DESCRIPTION", ["source", "cursor_color"]) +"""Description of a LUT for internal purpose.""" + + +_AVAILABLE_LUTS = collections.OrderedDict([ + ('gray', _LUT_DESCRIPTION('builtin', '#ff66ff')), + ('reversed gray', _LUT_DESCRIPTION('builtin', '#ff66ff')), + ('red', _LUT_DESCRIPTION('builtin', '#00ff00')), + ('green', _LUT_DESCRIPTION('builtin', '#ff66ff')), + ('blue', _LUT_DESCRIPTION('builtin', '#ffff00')), + ('viridis', _LUT_DESCRIPTION('resource', '#ff66ff')), + ('cividis', _LUT_DESCRIPTION('resource', '#ff66ff')), + ('magma', _LUT_DESCRIPTION('resource', '#00ff00')), + ('inferno', _LUT_DESCRIPTION('resource', '#00ff00')), + ('plasma', _LUT_DESCRIPTION('resource', '#00ff00')), + ('temperature', _LUT_DESCRIPTION('builtin', '#ff66ff')), +]) +"""Description for internal porpose of all the default LUT provided by the library.""" + + +# Colormap loader + +_COLORMAP_CACHE = {} +"""Cache already used colormaps as name: color LUT""" + + +def array_to_rgba8888(colors): + """Convert colors from a numpy array using float (0..1) int or uint + (0..255) to uint8 RGBA. + + :param numpy.ndarray colors: Array of float int or uint colors to convert + :return: colors as uint8 + :rtype: numpy.ndarray + """ + assert len(colors.shape) == 2 + assert colors.shape[1] in (3, 4) + + if colors.dtype == numpy.uint8: + pass + elif colors.dtype.kind == 'f': + # Each bin is [N, N+1[ except the last one: [255, 256] + colors = numpy.clip(colors.astype(numpy.float64) * 256, 0., 255.) + colors = colors.astype(numpy.uint8) + elif colors.dtype.kind in 'iu': + colors = numpy.clip(colors, 0, 255) + colors = colors.astype(numpy.uint8) + + if colors.shape[1] == 3: + tmp = numpy.empty((len(colors), 4), dtype=numpy.uint8) + tmp[:, 0:3] = colors + tmp[:, 3] = 255 + colors = tmp + + return colors + + +def _create_colormap_lut(name): + """Returns the color LUT corresponding to a colormap name + + :param str name: Name of the colormap to load + :returns: Corresponding table of colors + :rtype: numpy.ndarray + :raise ValueError: If no colormap corresponds to name + """ + description = _AVAILABLE_LUTS.get(name) + if description is not None: + if description.source == "builtin": + # Build colormap LUT + lut = numpy.zeros((256, 4), dtype=numpy.uint8) + lut[:, 3] = 255 + + if name == 'gray': + lut[:, :3] = numpy.arange(256, dtype=numpy.uint8).reshape(-1, 1) + elif name == 'reversed gray': + lut[:, :3] = numpy.arange(255, -1, -1, dtype=numpy.uint8).reshape(-1, 1) + elif name == 'red': + lut[:, 0] = numpy.arange(256, dtype=numpy.uint8) + elif name == 'green': + lut[:, 1] = numpy.arange(256, dtype=numpy.uint8) + elif name == 'blue': + lut[:, 2] = numpy.arange(256, dtype=numpy.uint8) + elif name == 'temperature': + # Red + lut[128:192, 0] = numpy.arange(2, 255, 4, dtype=numpy.uint8) + lut[192:, 0] = 255 + # Green + lut[:64, 1] = numpy.arange(0, 255, 4, dtype=numpy.uint8) + lut[64:192, 1] = 255 + lut[192:, 1] = numpy.arange(252, -1, -4, dtype=numpy.uint8) + # Blue + lut[:64, 2] = 255 + lut[64:128, 2] = numpy.arange(254, 0, -4, dtype=numpy.uint8) + else: + raise RuntimeError("Built-in colormap not implemented") + return lut + + elif description.source == "resource": + # Load colormap LUT + colors = numpy.load(_resource_filename("gui/colormaps/%s.npy" % name)) + # Convert to uint8 and add alpha channel + lut = array_to_rgba8888(colors) + return lut + + else: + raise RuntimeError("Internal LUT source '%s' unsupported" % description.source) + + raise ValueError("Unknown colormap '%s'" % name) + + +def register_colormap(name, lut, cursor_color='#000000'): + """Register a custom colormap LUT + + It can override existing LUT names. + + :param str name: Name of the LUT as defined to configure colormaps + :param numpy.ndarray lut: The custom LUT to register. + Nx3 or Nx4 numpy array of RGB(A) colors, + either uint8 or float in [0, 1]. + :param str cursor_color: Color used to display overlay over images using + colormap with this LUT. + """ + description = _LUT_DESCRIPTION('user', cursor_color) + colors = array_to_rgba8888(lut) + _AVAILABLE_LUTS[name] = description + + # Register the cache as the LUT was already loaded + _COLORMAP_CACHE[name] = colors + + +def get_registered_colormaps(): + """Returns currently registered colormap names""" + return tuple(_AVAILABLE_LUTS.keys()) + + +def get_colormap_cursor_color(name): + """Get a color suitable for overlay over a colormap. + + :param str name: The name of the colormap. + :return: Name of the color. + :rtype: str + """ + description = _AVAILABLE_LUTS.get(name, None) + if description is not None: + color = description.cursor_color + if color is not None: + return color + return 'black' + + +def get_colormap_lut(name): + """Returns the color LUT corresponding to a colormap name + + :param str name: Name of the colormap to load + :returns: Corresponding table of colors + :rtype: numpy.ndarray + :raise ValueError: If no colormap corresponds to name + """ + name = str(name) + if name not in _COLORMAP_CACHE: + lut = _create_colormap_lut(name) + _COLORMAP_CACHE[name] = lut + return _COLORMAP_CACHE[name] + + +# Normalizations + +class _NormalizationMixIn: + """Colormap normalization mix-in class""" + + DEFAULT_RANGE = 0, 1 + """Fallback for (vmin, vmax)""" + + def is_valid(self, value): + """Check if a value is in the valid range for this normalization. + + Override in subclass. + + :param Union[float,numpy.ndarray] value: + :rtype: Union[bool,numpy.ndarray] + """ + if isinstance(value, collections.abc.Iterable): + return numpy.ones_like(value, dtype=numpy.bool_) + else: + return True + + def autoscale(self, data, mode): + """Returns range for given data and autoscale mode. + + :param Union[None,numpy.ndarray] data: + :param str mode: Autoscale mode: 'minmax' or 'stddev3' + :returns: Range as (min, max) + :rtype: Tuple[float,float] + """ + data = None if data is None else numpy.array(data, copy=False) + if data is None or data.size == 0: + return self.DEFAULT_RANGE + + if mode == "minmax": + vmin, vmax = self.autoscale_minmax(data) + elif mode == "stddev3": + dmin, dmax = self.autoscale_minmax(data) + stdmin, stdmax = self.autoscale_mean3std(data) + if dmin is None: + vmin = stdmin + elif stdmin is None: + vmin = dmin + else: + vmin = max(dmin, stdmin) + + if dmax is None: + vmax = stdmax + elif stdmax is None: + vmax = dmax + else: + vmax = min(dmax, stdmax) + + else: + raise ValueError('Unsupported mode: %s' % mode) + + # Check returned range and handle fallbacks + if vmin is None or not numpy.isfinite(vmin): + vmin = self.DEFAULT_RANGE[0] + if vmax is None or not numpy.isfinite(vmax): + vmax = self.DEFAULT_RANGE[1] + if vmax < vmin: + vmax = vmin + return float(vmin), float(vmax) + + def autoscale_minmax(self, data): + """Autoscale using min/max + + :param numpy.ndarray data: + :returns: (vmin, vmax) + :rtype: Tuple[float,float] + """ + data = data[self.is_valid(data)] + if data.size == 0: + return None, None + result = _min_max(data, min_positive=False, finite=True) + return result.minimum, result.maximum + + def autoscale_mean3std(self, data): + """Autoscale using mean+/-3std + + This implementation only works for normalization that do NOT + use the data range. + Override this method for normalization using the range. + + :param numpy.ndarray data: + :returns: (vmin, vmax) + :rtype: Tuple[float,float] + """ + # Use [0, 1] as data range for normalization not using range + normdata = self.apply(data, 0., 1.) + if normdata.dtype.kind == 'f': # Replaces inf by NaN + normdata[numpy.isfinite(normdata) == False] = numpy.nan + if normdata.size == 0: # Fallback + return None, None + + with warnings.catch_warnings(): + warnings.simplefilter('ignore', category=RuntimeWarning) + # Ignore nanmean "Mean of empty slice" warning and + # nanstd "Degrees of freedom <= 0 for slice" warning + mean, std = numpy.nanmean(normdata), numpy.nanstd(normdata) + + return self.revert(mean - 3 * std, 0., 1.), self.revert(mean + 3 * std, 0., 1.) + + +class _LinearNormalizationMixIn(_NormalizationMixIn): + """Colormap normalization mix-in class specific to autoscale taken from initial range""" + + def autoscale_mean3std(self, data): + """Autoscale using mean+/-3std + + Do the autoscale on the data itself, not the normalized data. + + :param numpy.ndarray data: + :returns: (vmin, vmax) + :rtype: Tuple[float,float] + """ + if data.dtype.kind == 'f': # Replaces inf by NaN + data = numpy.array(data, copy=True) # Work on a copy + data[numpy.isfinite(data) == False] = numpy.nan + if data.size == 0: # Fallback + return None, None + with warnings.catch_warnings(): + warnings.simplefilter('ignore', category=RuntimeWarning) + # Ignore nanmean "Mean of empty slice" warning and + # nanstd "Degrees of freedom <= 0 for slice" warning + mean, std = numpy.nanmean(data), numpy.nanstd(data) + return mean - 3 * std, mean + 3 * std + + +class LinearNormalization(_colormap.LinearNormalization, _LinearNormalizationMixIn): + """Linear normalization""" + def __init__(self): + _colormap.LinearNormalization.__init__(self) + _LinearNormalizationMixIn.__init__(self) + + +class LogarithmicNormalization(_colormap.LogarithmicNormalization, _NormalizationMixIn): + """Logarithm normalization""" + + DEFAULT_RANGE = 1, 10 + + def __init__(self): + _colormap.LogarithmicNormalization.__init__(self) + _NormalizationMixIn.__init__(self) + + def is_valid(self, value): + return value > 0. + + def autoscale_minmax(self, data): + result = _min_max(data, min_positive=True, finite=True) + return result.min_positive, result.maximum + + +class SqrtNormalization(_colormap.SqrtNormalization, _NormalizationMixIn): + """Square root normalization""" + + DEFAULT_RANGE = 0, 1 + + def __init__(self): + _colormap.SqrtNormalization.__init__(self) + _NormalizationMixIn.__init__(self) + + def is_valid(self, value): + return value >= 0. + + +class GammaNormalization(_colormap.PowerNormalization, _LinearNormalizationMixIn): + """Gamma correction normalization: + + Linear normalization to [0, 1] followed by power normalization. + + :param gamma: Gamma correction factor + """ + def __init__(self, gamma): + _colormap.PowerNormalization.__init__(self, gamma) + _LinearNormalizationMixIn.__init__(self) + + +# Backward compatibility +PowerNormalization = GammaNormalization + + +class ArcsinhNormalization(_colormap.ArcsinhNormalization, _NormalizationMixIn): + """Inverse hyperbolic sine normalization""" + + def __init__(self): + _colormap.ArcsinhNormalization.__init__(self) + _NormalizationMixIn.__init__(self) + + +# Colormap function + +_BASIC_NORMALIZATIONS = { + "linear": LinearNormalization(), + "log": LogarithmicNormalization(), + "sqrt": SqrtNormalization(), + "arcsinh": ArcsinhNormalization(), +} + +_DEFAULT_NAN_COLOR = 255, 255, 255, 0 + +def apply_colormap(data, + colormap: str, + norm: str="linear", + autoscale: str="minmax", + vmin=None, + vmax=None, + gamma=1.0): + """Apply colormap to data with given normalization and autoscale. + + :param numpy.ndarray data: Data on which to apply the colormap + :param str colormap: Name of the colormap to use + :param str norm: Normalization to use + :param str autoscale: Autoscale mode: "minmax" (default) or "stddev3" + :param vmin: Lower bound, None (default) to autoscale + :param vmax: Upper bound, None (default) to autoscale + :param float gamma: + Gamma correction parameter (used only for "gamma" normalization) + :returns: Array of colors + """ + colors = get_colormap_lut(colormap) + + if norm == "gamma": + normalizer = GammaNormalization(gamma) + else: + normalizer = _BASIC_NORMALIZATIONS[norm] + + if vmin is None or vmax is None: + auto_vmin, auto_vmax = normalizer.autoscale(data, autoscale) + if vmin is None: # Set vmin respecting provided vmax + vmin = auto_vmin if vmax is None else min(auto_vmin, vmax) + if vmax is None: + vmax = max(auto_vmax, vmin) # Handle max_ <= 0 for log scale + + return _colormap.cmap( + data, + colors, + vmin, + vmax, + normalizer, + _DEFAULT_NAN_COLOR, + ) diff --git a/src/silx/math/combo.pyx b/src/silx/math/combo.pyx new file mode 100644 index 0000000..e24edda --- /dev/null +++ b/src/silx/math/combo.pyx @@ -0,0 +1,329 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides combination of statistics as single operation. + +For now it provides min/max (and optionally positive min) and indices +of first occurrences (i.e., argmin/argmax) in a single pass. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + +cimport cython +from .math_compatibility cimport isnan, isfinite, INFINITY + + +import numpy + + +# All supported types +ctypedef fused _number: + float + double + long double + signed char + signed short + signed int + signed long + signed long long + unsigned char + unsigned short + unsigned int + unsigned long + unsigned long long + +# All supported floating types: +# cython.floating + long double +ctypedef fused _floating: + float + double + long double + + +class _MinMaxResult(object): + """Object storing result from :func:`min_max`""" + + def __init__(self, minimum, min_pos, maximum, + argmin, argmin_pos, argmax): + self._minimum = minimum + self._min_positive = min_pos + self._maximum = maximum + + self._argmin = argmin + self._argmin_positive = argmin_pos + self._argmax = argmax + + minimum = property( + lambda self: self._minimum, + doc="Minimum value of the array") + maximum = property( + lambda self: self._maximum, + doc="Maximum value of the array") + + argmin = property( + lambda self: self._argmin, + doc="Index of the first occurrence of the minimum value") + argmax = property( + lambda self: self._argmax, + doc="Index of the first occurrence of the maximum value") + + min_positive = property( + lambda self: self._min_positive, + doc="""Strictly positive minimum value + + It is None if no value is strictly positive. + """) + argmin_positive = property( + lambda self: self._argmin_positive, + doc="""Index of the strictly positive minimum value. + + It is None if no value is strictly positive. + It is the index of the first occurrence.""") + + def __getitem__(self, key): + if key == 0: + return self.minimum + elif key == 1: + return self.maximum + else: + raise IndexError("Index out of range") + + +@cython.initializedcheck(False) +@cython.boundscheck(False) +@cython.wraparound(False) +def _min_max(_number[::1] data, bint min_positive=False): + """:func:`min_max` implementation including infinite values + + See :func:`min_max` for documentation. + """ + cdef: + _number value, minimum, min_pos, maximum + unsigned int length + unsigned int index = 0 + unsigned int min_index = 0 + unsigned int min_pos_index = 0 + unsigned int max_index = 0 + + length = len(data) + + if length == 0: + raise ValueError('Zero-size array') + + with nogil: + # Init starting values + value = data[0] + minimum = value + maximum = value + if min_positive and value > 0: + min_pos = value + else: + min_pos = 0 + + if _number in _floating: + # For floating, loop until first not NaN value + for index in range(length): + value = data[index] + if not isnan(value): + minimum = value + min_index = index + maximum = value + max_index = index + break + + if not min_positive: + for index in range(index, length): + value = data[index] + if value > maximum: + maximum = value + max_index = index + elif value < minimum: + minimum = value + min_index = index + + else: + # Loop until min_pos is defined + for index in range(index, length): + value = data[index] + if value > maximum: + maximum = value + max_index = index + elif value < minimum: + minimum = value + min_index = index + + if value > 0: + min_pos = value + min_pos_index = index + break + + # Loop until the end + for index in range(index + 1, length): + value = data[index] + if value > maximum: + maximum = value + max_index = index + else: + if value < minimum: + minimum = value + min_index = index + + if 0 < value < min_pos: + min_pos = value + min_pos_index = index + + return _MinMaxResult(minimum, + min_pos if min_pos > 0 else None, + maximum, + min_index, + min_pos_index if min_pos > 0 else None, + max_index) + + +@cython.initializedcheck(False) +@cython.boundscheck(False) +@cython.wraparound(False) +def _finite_min_max(_floating[::1] data, bint min_positive=False): + """:func:`min_max` implementation for floats skipping infinite values + + See :func:`min_max` for documentation. + """ + cdef: + _floating value, minimum, min_pos, maximum + unsigned int length + unsigned int index = 0 + unsigned int min_index = 0 + unsigned int min_pos_index = 0 + unsigned int max_index = 0 + + length = len(data) + + if length == 0: + raise ValueError('Zero-size array') + + with nogil: + minimum = INFINITY + maximum = -INFINITY + min_pos = INFINITY + + if not min_positive: + for index in range(length): + value = data[index] + if isfinite(value): + if value > maximum: + maximum = value + max_index = index + if value < minimum: + minimum = value + min_index = index + + else: + for index in range(index, length): + value = data[index] + if isfinite(value): + if value > maximum: + maximum = value + max_index = index + if value < minimum: + minimum = value + min_index = index + + if 0. < value < min_pos: + min_pos = value + min_pos_index = index + + return _MinMaxResult(minimum if isfinite(minimum) else None, + min_pos if isfinite(min_pos) else None, + maximum if isfinite(maximum) else None, + min_index if isfinite(minimum) else None, + min_pos_index if isfinite(min_pos) else None, + max_index if isfinite(maximum) else None) + + +def min_max(data not None, bint min_positive=False, bint finite=False): + """Returns min, max and optionally strictly positive min of data. + + It also computes the indices of first occurrence of min/max. + + NaNs are ignored while computing min/max unless all data is NaNs, + in which case returned min/max are NaNs. + + The result data type is that of the input data, except for the following cases. + For input using non-native bytes order, the result is returned as native + floating-point or integers. For input using 16-bits floating-point, + the result is returned as 32-bits floating-point. + + Examples: + + >>> import numpy + >>> data = numpy.arange(10) + + Usage as a function returning min and max: + + >>> min_, max_ = min_max(data) + + Usage as a function returning a result object to access all information: + + >>> result = min_max(data) # Do not get positive min + >>> result.minimum, result.argmin + 0, 0 + >>> result.maximum, result.argmax + 9, 10 + >>> result.min_positive, result.argmin_positive # Not computed + None, None + + Getting strictly positive min information: + + >>> result = min_max(data, min_positive=True) + >>> result.min_positive, result.argmin_positive # Computed + 1, 1 + + If *finite* is True, min/max information is computed only from finite data. + Then, all result fields (include minimum and maximum) can be None + when all data is infinity or NaN. + + :param data: Array-like dataset + :param bool min_positive: True to compute the positive min and argmin + Default: False. + :param bool finite: True to compute min/max from finite data only + Default: False. + :returns: An object with minimum, maximum and min_positive attributes + and the indices of first occurrence in the flattened data: + argmin, argmax and argmin_positive attributes. + If all data is <= 0 or min_positive argument is False, then + min_positive and argmin_positive are None. + :raises: ValueError if data is empty + """ + data = numpy.array(data, copy=False) + native_endian_dtype = data.dtype.newbyteorder('N') + if native_endian_dtype.kind == 'f' and native_endian_dtype.itemsize == 2: + # Use native float32 instead of float16 + native_endian_dtype = "=f4" + data = numpy.ascontiguousarray(data, dtype=native_endian_dtype).ravel() + if finite and data.dtype.kind == 'f': + return _finite_min_max(data, min_positive) + else: + return _min_max(data, min_positive) diff --git a/src/silx/math/fft/__init__.py b/src/silx/math/fft/__init__.py new file mode 100644 index 0000000..ea12cd6 --- /dev/null +++ b/src/silx/math/fft/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +# coding: utf-8 + +__authors__ = ["P. Paleo"] +__license__ = "MIT" +__date__ = "12/12/2018" + +from .fft import FFT diff --git a/src/silx/math/fft/basefft.py b/src/silx/math/fft/basefft.py new file mode 100644 index 0000000..854ca37 --- /dev/null +++ b/src/silx/math/fft/basefft.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +import numpy as np +from pkg_resources import parse_version + + +def check_version(package, required_version): + """ + Check whether a given package version is superior or equal to required_version. + """ + try: + ver = getattr(package, "__version__") + except AttributeError: + try: + ver = getattr(package, "version") + except Exception: + return False + req_v = parse_version(required_version) + ver_v = parse_version(ver) + return ver_v >= req_v + + +class BaseFFT(object): + """ + Base class for all FFT backends. + """ + def __init__(self, **kwargs): + self.__get_args(**kwargs) + + if self.shape is None and self.dtype is None and self.template is None: + raise ValueError("Please provide either (shape and dtype) or template") + if self.template is not None: + self.shape = self.template.shape + self.dtype = self.template.dtype + self.user_data = self.template + self.data_allocated = False + self.__calc_axes() + self.__set_dtypes() + self.__calc_shape() + + def __get_args(self, **kwargs): + expected_args = { + "shape": None, + "dtype": None, + "template": None, + "shape_out": None, + "axes": None, + "normalize": "rescale", + } + for arg_name, default_val in expected_args.items(): + if arg_name not in kwargs: + # Base class was not instantiated properly + raise ValueError("Please provide argument %s" % arg_name) + setattr(self, arg_name, default_val) + for arg_name, arg_val in kwargs.items(): + setattr(self, arg_name, arg_val) + + def __set_dtypes(self): + dtypes_mapping = { + np.dtype("float32"): np.complex64, + np.dtype("float64"): np.complex128, + np.dtype("complex64"): np.complex64, + np.dtype("complex128"): np.complex128 + } + dp = { + np.dtype("float32"): np.float64, + np.dtype("complex64"): np.complex128 + } + self.dtype_in = np.dtype(self.dtype) + if self.dtype_in not in dtypes_mapping: + raise ValueError("Invalid input data type: got %s" % + self.dtype_in + ) + self.dtype_out = dtypes_mapping[self.dtype_in] + + def __calc_shape(self): + # TODO allow for C2C even for real input data (?) + if self.dtype_in in [np.float32, np.float64]: + last_dim = self.shape[-1]//2 + 1 + # FFTW convention + self.shape_out = self.shape[:-1] + (self.shape[-1]//2 + 1,) + else: + self.shape_out = self.shape + + def __calc_axes(self): + default_axes = tuple(range(len(self.shape))) + if self.axes is None: + self.axes = default_axes + self.user_axes = None + else: + self.user_axes = self.axes + # Handle possibly negative axes + self.axes = tuple(np.array(default_axes)[np.array(self.user_axes)]) + + def _allocate(self, shape, dtype): + raise ValueError("This should be implemented by back-end FFT") + + def set_data(self, dst, src, shape, dtype, copy=True): + raise ValueError("This should be implemented by back-end FFT") + + def allocate_arrays(self): + if not(self.data_allocated): + self.data_in = self._allocate(self.shape, self.dtype_in) + self.data_out = self._allocate(self.shape_out, self.dtype_out) + self.data_allocated = True + + def set_input_data(self, data, copy=True): + if data is None: + return self.data_in + else: + return self.set_data(self.data_in, data, self.shape, self.dtype_in, copy=copy, name="data_in") + + def set_output_data(self, data, copy=True): + if data is None: + return self.data_out + else: + return self.set_data(self.data_out, data, self.shape_out, self.dtype_out, copy=copy, name="data_out") + + def fft(self, array, **kwargs): + raise ValueError("This should be implemented by back-end FFT") + + def ifft(self, array, **kwargs): + raise ValueError("This should be implemented by back-end FFT") diff --git a/src/silx/math/fft/clfft.py b/src/silx/math/fft/clfft.py new file mode 100644 index 0000000..dad8ec1 --- /dev/null +++ b/src/silx/math/fft/clfft.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +import numpy as np + +from .basefft import BaseFFT, check_version +try: + import pyopencl as cl + import pyopencl.array as parray + import gpyfft + from gpyfft.fft import FFT as cl_fft + from ...opencl.common import ocl + __have_clfft__ = True +except ImportError: + __have_clfft__ = False + + +# Check gpyfft version +__required_gpyfft_version__ = "0.3.0" +if __have_clfft__: + __have_clfft__ = check_version(gpyfft, __required_gpyfft_version__) + + +class CLFFT(BaseFFT): + """Initialize a clfft plan. + + Please see FFT class for parameters help. + + CLFFT-specific parameters + -------------------------- + + :param pyopencl.Context ctx: + If set to other than None, an existing pyopencl context is used. + :param bool fast_math: + If set to True, computations will be done with "fast math" mode, + i.e., more speed but less accuracy. + :param bool choose_best_device: + Whether to automatically choose the best available OpenCL device. + """ + def __init__( + self, + shape=None, + dtype=None, + template=None, + shape_out=None, + axes=None, + normalize="rescale", + ctx=None, + fast_math=False, + choose_best_device=True, + ): + if not(__have_clfft__) or not(__have_clfft__): + raise ImportError("Please install pyopencl and gpyfft >= %s to use the OpenCL back-end" % __required_gpyfft_version__) + + super(CLFFT, self).__init__( + shape=shape, + dtype=dtype, + template=template, + shape_out=shape_out, + axes=axes, + normalize=normalize, + ) + self.ctx = ctx + self.choose_best_device = choose_best_device + self.fast_math = fast_math + self.backend = "clfft" + + self.fix_axes() + self.init_context_queue() + self.allocate_arrays() + self.real_transform = np.isrealobj(self.data_in) + self.compute_forward_plan() + self.compute_inverse_plan() + self.refs = { + "data_in": self.data_in, + "data_out": self.data_out, + } + # TODO + # Either pyopencl ElementWiseKernel, or built-in clfft callbacks + if self.normalize != "rescale": + raise NotImplementedError( + "Normalization modes other than rescale are not implemented with OpenCL backend yet." + ) + + def fix_axes(self): + """ + "Fix" axes. + + clfft does not have the same convention as FFTW/cuda/numpy. + """ + self.axes = self.axes[::-1] + + def _allocate(self, shape, dtype): + ary = parray.empty(self.queue, shape, dtype=dtype) + ary.fill(0) + return ary + + + def check_array(self, array, shape, dtype, copy=True): + if array.shape != shape: + raise ValueError("Invalid data shape: expected %s, got %s" % + (shape, array.shape) + ) + if array.dtype != dtype: + raise ValueError("Invalid data type: expected %s, got %s" % + (dtype, array.dtype) + ) + + + def set_data(self, dst, src, shape, dtype, copy=True, name=None): + """ + dst is a device array owned by the current instance + (either self.data_in or self.data_out). + + copy is ignored for device<-> arrays. + """ + self.check_array(src, shape, dtype) + if isinstance(src, np.ndarray): + if name == "data_out": + # Makes little sense to provide output=numpy_array + return dst + if not(src.flags["C_CONTIGUOUS"]): + src = np.ascontiguousarray(src, dtype=dtype) + # working on underlying buffer is notably faster + #~ dst[:] = src[:] + evt = cl.enqueue_copy(self.queue, dst.data, src) + evt.wait() + elif isinstance(src, parray.Array): + # No copy, use the data as self.d_input or self.d_output + # (this prevents the use of in-place transforms, however). + # We have to keep their old references. + if name is None: + # This should not happen + raise ValueError("Please provide either copy=True or name != None") + assert id(self.refs[name]) == id(dst) # DEBUG + setattr(self, name, src) + return src + else: + raise ValueError( + "Invalid array type %s, expected numpy.ndarray or pyopencl.array" % + type(src) + ) + return dst + + + def recover_array_references(self): + self.data_in = self.refs["data_in"] + self.data_out = self.refs["data_out"] + + + def init_context_queue(self): + if self.ctx is None: + if self.choose_best_device: + self.ctx = ocl.create_context() + else: + self.ctx = cl.create_some_context() + self.queue = cl.CommandQueue(self.ctx) + + + def compute_forward_plan(self): + self.plan_forward = cl_fft( + self.ctx, + self.queue, + self.data_in, + out_array=self.data_out, + axes=self.axes, + fast_math=self.fast_math, + real=self.real_transform, + ) + + + def compute_inverse_plan(self): + self.plan_inverse = cl_fft( + self.ctx, + self.queue, + self.data_out, + out_array=self.data_in, + axes=self.axes, + fast_math=self.fast_math, + real=self.real_transform, + ) + + + def update_forward_plan_arrays(self): + self.plan_forward.data = self.data_in + self.plan_forward.result = self.data_out + + + def update_inverse_plan_arrays(self): + self.plan_inverse.data = self.data_out + self.plan_inverse.result = self.data_in + + + def copy_output_if_numpy(self, dst, src): + if isinstance(dst, parray.Array): + return + # working on underlying buffer is notably faster + #~ dst[:] = src[:] + evt = cl.enqueue_copy(self.queue, dst, src.data) + evt.wait() + + + def fft(self, array, output=None, do_async=False): + """ + Perform a (forward) Fast Fourier Transform. + + :param Union[numpy.ndarray,pyopencl.array] array: + Input data. Must be consistent with the current context. + :param Union[numpy.ndarray,pyopencl.array] output: + Output data. By default, output is a numpy.ndarray. + :param bool do_async: + Whether to perform operation in asynchronous mode. + Default is False, meaning that we wait for transform to complete. + """ + self.set_input_data(array, copy=False) + self.set_output_data(output, copy=False) + self.update_forward_plan_arrays() + event, = self.plan_forward.enqueue() + if not(do_async): + event.wait() + if output is not None: + self.copy_output_if_numpy(output, self.data_out) + res = output + else: + res = self.data_out.get() + self.recover_array_references() + return res + + + def ifft(self, array, output=None, do_async=False): + """ + Perform a (inverse) Fast Fourier Transform. + + :param Union[numpy.ndarray,pyopencl.array] array: + Input data. Must be consistent with the current context. + :param Union[numpy.ndarray,pyopencl.array] output: + Output data. By default, output is a numpy.ndarray. + :param bool do_async: + Whether to perform operation in asynchronous mode. + Default is False, meaning that we wait for transform to complete. + """ + self.set_output_data(array, copy=False) + self.set_input_data(output, copy=False) + self.update_inverse_plan_arrays() + event, = self.plan_inverse.enqueue(forward=False) + if not(do_async): + event.wait() + if output is not None: + self.copy_output_if_numpy(output, self.data_in) + res = output + else: + res = self.data_in.get() + self.recover_array_references() + return res + + + def __del__(self): + # It seems that gpyfft underlying clFFT destructors are not called. + # This results in the following warning: + # Warning: Program terminating, but clFFT resources not freed. + # Please consider explicitly calling clfftTeardown( ) + del self.plan_forward + del self.plan_inverse + diff --git a/src/silx/math/fft/cufft.py b/src/silx/math/fft/cufft.py new file mode 100644 index 0000000..848f3e6 --- /dev/null +++ b/src/silx/math/fft/cufft.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +import numpy as np + +from .basefft import BaseFFT +try: + import pycuda.gpuarray as gpuarray + from skcuda.fft import Plan + from skcuda.fft import fft as cu_fft + from skcuda.fft import ifft as cu_ifft + __have_cufft__ = True +except ImportError: + __have_cufft__ = False + + +class CUFFT(BaseFFT): + """Initialize a cufft plan + + Please see FFT class for parameters help. + + CUFFT-specific parameters + -------------------------- + + :param pycuda.driver.Stream stream: + Stream with which to associate the plan. If no stream is specified, + the default stream is used. + """ + def __init__( + self, + shape=None, + dtype=None, + template=None, + shape_out=None, + axes=None, + normalize="rescale", + stream=None, + ): + if not(__have_cufft__) or not(__have_cufft__): + raise ImportError("Please install pycuda and scikit-cuda to use the CUDA back-end") + + super(CUFFT, self).__init__( + shape=shape, + dtype=dtype, + template=template, + shape_out=shape_out, + axes=axes, + normalize=normalize, + ) + self.cufft_stream = stream + self.backend = "cufft" + + self.configure_batched_transform() + self.allocate_arrays() + self.real_transform = np.isrealobj(self.data_in) + self.compute_forward_plan() + self.compute_inverse_plan() + self.refs = { + "data_in": self.data_in, + "data_out": self.data_out, + } + self.configure_normalization() + + def _allocate(self, shape, dtype): + return gpuarray.zeros(shape, dtype) + + # TODO support batched transform where batch is other than dimension 0 + def configure_batched_transform(self): + self.cufft_batch_size = 1 + self.cufft_shape = self.shape + if (self.axes is not None) and (len(self.axes) < len(self.shape)): + # In the easiest case, the transform is computed along the fastest dimensions: + # - 1D transforms of lines of 2D data + # - 2D transforms of images of 3D data (stacked along slow dim) + # - 1D transforms of 3D data along fastest dim + # Otherwise, we have to configure cuda "advanced memory layout", + # which is not implemented yet. + + data_ndims = len(self.shape) + supported_axes = { + 2: [(1,)], + 3: [(1, 2), (2, 1), (1,), (2,)], + } + if self.axes not in supported_axes[data_ndims]: + raise NotImplementedError("With the CUDA backend, batched transform is only supported along fastest dimensions") + self.cufft_batch_size = self.shape[0] + self.cufft_shape = self.shape[1:] + if data_ndims == 3 and len(self.axes) == 1: + # 1D transform on 3D data: here only supported along fast dim, + # so batch_size is Nx*Ny + self.cufft_batch_size = np.prod(self.shape[:2]) + self.cufft_shape = (self.shape[-1],) + if len(self.cufft_shape) == 1: + self.cufft_shape = self.cufft_shape[0] + + def configure_normalization(self): + # TODO + if self.normalize == "ortho": + raise NotImplementedError( + "Normalization mode 'ortho' is not implemented with CUDA backend yet." + ) + self.cufft_scale_inverse = (self.normalize == "rescale") + + def check_array(self, array, shape, dtype, copy=True): + if array.shape != shape: + raise ValueError("Invalid data shape: expected %s, got %s" % + (shape, array.shape)) + if array.dtype != dtype: + raise ValueError("Invalid data type: expected %s, got %s" % + (dtype, array.dtype)) + + def set_data(self, dst, src, shape, dtype, copy=True, name=None): + """ + dst is a device array owned by the current instance + (either self.data_in or self.data_out). + + copy is ignored for device<-> arrays. + """ + self.check_array(src, shape, dtype) + if isinstance(src, np.ndarray): + if name == "data_out": + # Makes little sense to provide output=numpy_array + return dst + if not(src.flags["C_CONTIGUOUS"]): + src = np.ascontiguousarray(src, dtype=dtype) + dst[:] = src[:] + elif isinstance(src, gpuarray.GPUArray): + # No copy, use the data as self.d_input or self.d_output + # (this prevents the use of in-place transforms, however). + # We have to keep their old references. + if name is None: + # This should not happen + raise ValueError("Please provide either copy=True or name != None") + assert id(self.refs[name]) == id(dst) # DEBUG + setattr(self, name, src) + return src + else: + raise ValueError( + "Invalid array type %s, expected numpy.ndarray or pycuda.gpuarray" % + type(src) + ) + return dst + + def recover_array_references(self): + self.data_in = self.refs["data_in"] + self.data_out = self.refs["data_out"] + + def compute_forward_plan(self): + self.plan_forward = Plan( + self.cufft_shape, + self.dtype, + self.dtype_out, + batch=self.cufft_batch_size, + stream=self.cufft_stream, + # cufft extensible plan API is only supported after 0.5.1 + # (commit 65288d28ca0b93e1234133f8d460dc6becb65121) + # but there is still no official 0.5.2 + #~ auto_allocate=True # cufft extensible plan API + ) + + def compute_inverse_plan(self): + self.plan_inverse = Plan( + self.cufft_shape, # not shape_out + self.dtype_out, + self.dtype, + batch=self.cufft_batch_size, + stream=self.cufft_stream, + # cufft extensible plan API is only supported after 0.5.1 + # (commit 65288d28ca0b93e1234133f8d460dc6becb65121) + # but there is still no official 0.5.2 + #~ auto_allocate=True + ) + + def copy_output_if_numpy(self, dst, src): + if isinstance(dst, gpuarray.GPUArray): + return + dst[:] = src[:] + + def fft(self, array, output=None): + """ + Perform a (forward) Fast Fourier Transform. + + :param Union[numpy.ndarray,pycuda.gpuarray] array: + Input data. Must be consistent with the current context. + :param Union[numpy.ndarray,pycuda.gpuarray] output: + Output data. By default, output is a numpy.ndarray. + """ + data_in = self.set_input_data(array, copy=False) + data_out = self.set_output_data(output, copy=False) + + cu_fft( + data_in, + data_out, + self.plan_forward, + scale=False + ) + + if output is not None: + self.copy_output_if_numpy(output, self.data_out) + res = output + else: + res = self.data_out.get() + self.recover_array_references() + return res + + def ifft(self, array, output=None): + """ + Perform a (inverse) Fast Fourier Transform. + + :param Union[numpy.ndarray,pycuda.gpuarray] array: + Input data. Must be consistent with the current context. + :param Union[numpy.ndarray,pycuda.gpuarray] output: + Output data. By default, output is a numpy.ndarray. + """ + data_in = self.set_output_data(array, copy=False) + data_out = self.set_input_data(output, copy=False) + + cu_ifft( + data_in, + data_out, + self.plan_inverse, + scale=self.cufft_scale_inverse, + ) + + if output is not None: + self.copy_output_if_numpy(output, self.data_in) + res = output + else: + res = self.data_in.get() + self.recover_array_references() + return res diff --git a/src/silx/math/fft/fft.py b/src/silx/math/fft/fft.py new file mode 100644 index 0000000..eb0d73b --- /dev/null +++ b/src/silx/math/fft/fft.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +from .fftw import FFTW +from .clfft import CLFFT +from .npfft import NPFFT +from .cufft import CUFFT + + +def FFT( + shape=None, + dtype=None, + template=None, + shape_out=None, + axes=None, + normalize="rescale", + backend="numpy", + **kwargs +): + """ + Initialize a FFT plan. + + :param List[int] shape: + Shape of the input data. + :param numpy.dtype dtype: + Data type of the input data. + :param numpy.ndarray template: + Optional data, replacement for "shape" and "dtype". + If provided, the arguments "shape" and "dtype" are ignored, + and are instead inferred from it. + :param List[int] shape_out: + Optional shape of output data. + By default, the data has the same shape as the input + data (in case of C2C transform), or a shape with the last dimension halved + (in case of R2C transform). If shape_out is provided, it must be greater + or equal than the shape of input data. In this case, FFT is performed + with zero-padding. + :param List[int] axes: + Axes along which FFT is computed. + * For 2D transform: axes=(1,0) + * For batched 1D transform of 2D image: axes=(0,) + :param str normalize: + Whether to normalize FFT and IFFT. Possible values are: + * "rescale": in this case, Fourier data is divided by "N" + before IFFT, so that (FFT(data)) = data + * "ortho": in this case, FFT and IFFT are adjoint of eachother, + the transform is unitary. Both FFT and IFFT are scaled with 1/sqrt(N). + * "none": no normalizatio is done : IFFT(FFT(data)) = data*N + :param str backend: + FFT Backend to use. Value can be "numpy", "fftw", "opencl", "cuda". + """ + backends = { + "numpy": NPFFT, + "np": NPFFT, + "fftw": FFTW, + "opencl": CLFFT, + "clfft": CLFFT, + "cuda": CUFFT, + "cufft": CUFFT, + } + + backend = backend.lower() + if backend not in backends: + raise ValueError("Unknown backend %s, available are %s" % (backend, backends)) + F = backends[backend]( + shape=shape, + dtype=dtype, + template=template, + shape_out=shape_out, + axes=axes, + normalize=normalize, + **kwargs + ) + return F diff --git a/src/silx/math/fft/fftw.py b/src/silx/math/fft/fftw.py new file mode 100644 index 0000000..ff6966c --- /dev/null +++ b/src/silx/math/fft/fftw.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +import numpy as np + +from .basefft import BaseFFT, check_version +try: + import pyfftw + __have_fftw__ = True +except ImportError: + __have_fftw__ = False + + +# Check pyfftw version +__required_pyfftw_version__ = "0.10.0" +if __have_fftw__: + __have_fftw__ = check_version(pyfftw, __required_pyfftw_version__) + + +class FFTW(BaseFFT): + """Initialize a FFTW plan. + + Please see FFT class for parameters help. + + FFTW-specific parameters + ------------------------- + + :param bool check_alignment: + If set to True and "data" is provided, this will enforce the input data + to be "byte aligned", which might imply extra memory usage. + :param int num_threads: + Number of threads for computing FFT. + """ + def __init__( + self, + shape=None, + dtype=None, + template=None, + shape_out=None, + axes=None, + normalize="rescale", + check_alignment=False, + num_threads=1, + ): + if not(__have_fftw__): + raise ImportError("Please install pyfftw >= %s to use the FFTW back-end" % __required_pyfftw_version__) + super(FFTW, self).__init__( + shape=shape, + dtype=dtype, + template=template, + shape_out=shape_out, + axes=axes, + normalize=normalize, + ) + self.check_alignment = check_alignment + self.num_threads = num_threads + self.backend = "fftw" + + self.allocate_arrays() + self.set_fftw_flags() + self.compute_forward_plan() + self.compute_inverse_plan() + self.refs = { + "data_in": self.data_in, + "data_out": self.data_out, + } + + def set_fftw_flags(self): + self.fftw_flags = ('FFTW_MEASURE', ) # TODO + self.fftw_planning_timelimit = None # TODO + self.fftw_norm_modes = { + "rescale": {"ortho": False, "normalize": True}, + "ortho": {"ortho": True, "normalize": False}, + "none": {"ortho": False, "normalize": False}, + } + if self.normalize not in self.fftw_norm_modes: + raise ValueError("Unknown normalization mode %s. Possible values are %s" % + (self.normalize, self.fftw_norm_modes.keys()) + ) + self.fftw_norm_mode = self.fftw_norm_modes[self.normalize] + + def _allocate(self, shape, dtype): + return pyfftw.zeros_aligned(shape, dtype=dtype) + + def check_array(self, array, shape, dtype, copy=True): + if array.shape != shape: + raise ValueError("Invalid data shape: expected %s, got %s" % + (shape, array.shape) + ) + if array.dtype != dtype: + raise ValueError("Invalid data type: expected %s, got %s" % + (dtype, array.dtype) + ) + + def set_data(self, self_array, array, shape, dtype, copy=True, name=None): + """ + :param self_array: array owned by the current instance + (either self.data_in or self.data_out). + :type: numpy.ndarray + :param self_array: data to set + :type: numpy.ndarray + :type tuple shape: shape of the array + :param dtype: type of the array + :type: numpy.dtype + :param bool copy: should we copy the array + :param str name: name of the array + + Copies are avoided when possible. + """ + self.check_array(array, shape, dtype) + if id(self.refs[name]) == id(array): + # nothing to do: fft is performed on self.data_in or self.data_out + arr_to_use = self.refs[name] + if self.check_alignment and not(pyfftw.is_byte_aligned(array)): + # If the array is not properly aligned, + # create a temp. array copy it to self.data_in or self.data_out + self_array[:] = array[:] + arr_to_use = self_array + else: + # If the array is properly aligned, use it directly + if copy: + arr_to_use = np.copy(array) + else: + arr_to_use = array + return arr_to_use + + def compute_forward_plan(self): + self.plan_forward = pyfftw.FFTW( + self.data_in, + self.data_out, + axes=self.axes, + direction='FFTW_FORWARD', + flags=self.fftw_flags, + threads=self.num_threads, + planning_timelimit=self.fftw_planning_timelimit, + # the following seems to be taken into account only when using __call__ + ortho=self.fftw_norm_mode["ortho"], + normalise_idft=self.fftw_norm_mode["normalize"], + ) + + def compute_inverse_plan(self): + self.plan_inverse = pyfftw.FFTW( + self.data_out, + self.data_in, + axes=self.axes, + direction='FFTW_BACKWARD', + flags=self.fftw_flags, + threads=self.num_threads, + planning_timelimit=self.fftw_planning_timelimit, + # the following seem to be taken into account only when using __call__ + ortho=self.fftw_norm_mode["ortho"], + normalise_idft=self.fftw_norm_mode["normalize"], + ) + + def fft(self, array, output=None): + """ + Perform a (forward) Fast Fourier Transform. + + :param numpy.ndarray array: + Input data. Must be consistent with the current context. + :param numpy.ndarray output: + Optional output data. + """ + data_in = self.set_input_data(array, copy=False) + data_out = self.set_output_data(output, copy=False) + self.plan_forward.update_arrays(data_in, data_out) + # execute.__call__ does both update_arrays() and normalization + self.plan_forward( + ortho=self.fftw_norm_mode["ortho"], + ) + self.plan_forward.update_arrays(self.refs["data_in"], self.refs["data_out"]) + return data_out + + def ifft(self, array, output=None): + """ + Perform a (inverse) Fast Fourier Transform. + + :param numpy.ndarray array: + Input data. Must be consistent with the current context. + :param numpy.ndarray output: + Optional output data. + """ + data_in = self.set_output_data(array, copy=False) + data_out = self.set_input_data(output, copy=False) + self.plan_inverse.update_arrays(data_in, data_out) + # execute.__call__ does both update_arrays() and normalization + self.plan_inverse( + ortho=self.fftw_norm_mode["ortho"], + normalise_idft=self.fftw_norm_mode["normalize"] + ) + self.plan_inverse.update_arrays(self.refs["data_out"], self.refs["data_in"]) + return data_out diff --git a/src/silx/math/fft/npfft.py b/src/silx/math/fft/npfft.py new file mode 100644 index 0000000..20351de --- /dev/null +++ b/src/silx/math/fft/npfft.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +import numpy as np + +from .basefft import BaseFFT + + +class NPFFT(BaseFFT): + """Initialize a numpy plan. + + Please see FFT class for parameters help. + """ + def __init__( + self, + shape=None, + dtype=None, + template=None, + shape_out=None, + axes=None, + normalize="rescale", + ): + super(NPFFT, self).__init__( + shape=shape, + dtype=dtype, + template=template, + shape_out=shape_out, + axes=axes, + normalize=normalize, + ) + self.backend = "numpy" + self.real_transform = False + if template is not None and np.isrealobj(template): + self.real_transform = True + # For numpy functions. + # TODO Issue warning if user wants ifft(fft(data)) = N*data ? + if normalize != "ortho": + self.normalize = None + self.set_fft_functions() + #~ self.allocate_arrays() # not needed for this backend + self.compute_plans() + + + def set_fft_functions(self): + # (fwd, inv) = _fft_functions[is_real][ndim] + self._fft_functions = { + True: { + 1: (np.fft.rfft, np.fft.irfft), + 2: (np.fft.rfft2, np.fft.irfft2), + 3: (np.fft.rfftn, np.fft.irfftn), + }, + False: { + 1: (np.fft.fft, np.fft.ifft), + 2: (np.fft.fft2, np.fft.ifft2), + 3: (np.fft.fftn, np.fft.ifftn), + } + } + + + def _allocate(self, shape, dtype): + return np.zeros(self.queue, shape, dtype=dtype) + + + def compute_plans(self): + ndim = len(self.shape) + funcs = self._fft_functions[self.real_transform][np.minimum(ndim, 3)] + if np.version.version[:4] in ["1.8.", "1.9."]: + # norm keyword was introduced in 1.10 and we support numpy >= 1.8 + self.numpy_args = {} + else: + self.numpy_args = {"norm": self.normalize} + # Batched transform + if (self.user_axes is not None) and len(self.user_axes) < ndim: + funcs = self._fft_functions[self.real_transform][np.minimum(ndim-1, 3)] + self.numpy_args["axes"] = self.user_axes + # Special case of batched 1D transform on 2D data + if ndim == 2: + assert len(self.user_axes) == 1 + self.numpy_args["axis"] = self.user_axes[0] + self.numpy_args.pop("axes") + self.numpy_funcs = funcs + + + def fft(self, array): + """ + Perform a (forward) Fast Fourier Transform. + + :param numpy.ndarray array: + Input data. Must be consistent with the current context. + """ + return self.numpy_funcs[0](array, **self.numpy_args) + + + def ifft(self, array): + """ + Perform a (inverse) Fast Fourier Transform. + + :param numpy.ndarray array: + Input data. Must be consistent with the current context. + """ + return self.numpy_funcs[1](array, **self.numpy_args) + diff --git a/src/silx/math/fft/setup.py b/src/silx/math/fft/setup.py new file mode 100644 index 0000000..76bb864 --- /dev/null +++ b/src/silx/math/fft/setup.py @@ -0,0 +1,41 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["P. Naudet"] +__license__ = "MIT" +__date__ = "12/12/2018" + +import numpy +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('fft', parent_package, top_path) + config.add_subpackage('test') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/src/silx/math/fft/test/__init__.py b/src/silx/math/fft/test/__init__.py new file mode 100644 index 0000000..ad9836c --- /dev/null +++ b/src/silx/math/fft/test/__init__.py @@ -0,0 +1,23 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ diff --git a/src/silx/math/fft/test/test_fft.py b/src/silx/math/fft/test/test_fft.py new file mode 100644 index 0000000..19becb8 --- /dev/null +++ b/src/silx/math/fft/test/test_fft.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test of the FFT module""" + +import numpy as np +import unittest +import logging +import pytest +try: + from scipy.misc import ascent + __have_scipy = True +except ImportError: + __have_scipy = False +from silx.utils.testutils import ParametricTestCase +from silx.math.fft.fft import FFT +from silx.math.fft.clfft import __have_clfft__ +from silx.math.fft.cufft import __have_cufft__ +from silx.math.fft.fftw import __have_fftw__ + + +logger = logging.getLogger(__name__) + + +class TransformInfos(object): + def __init__(self): + self.dimensions = [ + "1D", + "batched_1D", + "2D", + "batched_2D", + "3D", + ] + self.modes = { + "R2C": np.float32, + "R2C_double": np.float64, + "C2C": np.complex64, + "C2C_double": np.complex128, + } + self.sizes = { + "1D": [(128,), (127,)], + "2D": [(128, 128), (128, 127), (127, 128), (127, 127)], + "3D": [(64, 64, 64), (64, 64, 63), (64, 63, 64), (63, 64, 64), + (64, 63, 63), (63, 64, 63), (63, 63, 64), (63, 63, 63)] + } + self.axes = { + "1D": None, + "batched_1D": (-1,), + "2D": None, + "batched_2D": (-2, -1), + "3D": None, + } + self.sizes["batched_1D"] = self.sizes["2D"] + self.sizes["batched_2D"] = self.sizes["3D"] + + +class Data(object): + def __init__(self): + self.data = ascent().astype("float32") + self.data1d = self.data[:, 0] # non-contiguous data + self.data3d = np.tile(self.data[:64, :64], (64, 1, 1)) + self.data_refs = { + 1: self.data1d, + 2: self.data, + 3: self.data3d, + } + + +@unittest.skipUnless(__have_scipy, "scipy is missing") +@pytest.mark.usefixtures("test_options_class_attr") +class TestFFT(ParametricTestCase): + """Test cuda/opencl/fftw backends of FFT""" + + def setUp(self): + self.tol = { + np.dtype("float32"): 1e-3, + np.dtype("float64"): 1e-9, + np.dtype("complex64"): 1e-3, + np.dtype("complex128"): 1e-9, + } + self.transform_infos = TransformInfos() + self.test_data = Data() + + @staticmethod + def calc_mae(arr1, arr2): + """ + Compute the Max Absolute Error between two arrays + """ + return np.max(np.abs(arr1 - arr2)) + + @unittest.skipIf(not __have_cufft__, + "cuda back-end requires pycuda and scikit-cuda") + def test_cuda(self): + import pycuda.autoinit + + # Error is higher when using cuda. fast_math mode ? + self.tol[np.dtype("float32")] *= 2 + + self.__run_tests(backend="cuda") + + @unittest.skipIf(not __have_clfft__, + "opencl back-end requires pyopencl and gpyfft") + def test_opencl(self): + from silx.opencl.common import ocl + if ocl is not None: + self.__run_tests(backend="opencl", ctx=ocl.create_context()) + + @unittest.skipIf(not __have_fftw__, + "fftw back-end requires pyfftw") + def test_fftw(self): + self.__run_tests(backend="fftw") + + def __run_tests(self, backend, **extra_args): + """Run all tests with the given backend + + :param str backend: + :param dict extra_args: Additional arguments to provide to FFT + """ + for trdim in self.transform_infos.dimensions: + for mode in self.transform_infos.modes: + for size in self.transform_infos.sizes[trdim]: + with self.subTest(trdim=trdim, mode=mode, size=size): + self.__test(backend, trdim, mode, size, **extra_args) + + def __test(self, backend, trdim, mode, size, **extra_args): + """Compare given backend with numpy for given conditions""" + logger.debug("backend: %s, trdim: %s, mode: %s, size: %s", + backend, trdim, mode, str(size)) + if size == "3D" and self.test_options.TEST_LOW_MEM: + self.skipTest("low mem") + + ndim = len(size) + input_data = self.test_data.data_refs[ndim].astype( + self.transform_infos.modes[mode]) + tol = self.tol[np.dtype(input_data.dtype)] + if trdim == "3D": + tol *= 10 # Error is relatively high in high dimensions + # It seems that cuda has problems with C2D batched 1D + if trdim == "batched_1D" and backend == "cuda" and mode == "C2C": + tol *= 10 + + # Python < 3.5 does not want to mix **extra_args with existing kwargs + fft_args = { + "template": input_data, + "axes": self.transform_infos.axes[trdim], + "backend": backend, + } + fft_args.update(extra_args) + F = FFT( + **fft_args + ) + F_np = FFT( + template=input_data, + axes=self.transform_infos.axes[trdim], + backend="numpy" + ) + + # Forward FFT + res = F.fft(input_data) + res_np = F_np.fft(input_data) + mae = self.calc_mae(res, res_np) + all_close = np.allclose(res, res_np, atol=tol, rtol=tol), + self.assertTrue( + all_close, + "FFT %s:%s, MAE(%s, numpy) = %f (tol = %.2e)" % (mode, trdim, backend, mae, tol) + ) + + # Inverse FFT + res2 = F.ifft(res) + mae = self.calc_mae(res2, input_data) + self.assertTrue( + mae < tol, + "IFFT %s:%s, MAE(%s, numpy) = %f" % (mode, trdim, backend, mae) + ) + + +@unittest.skipUnless(__have_scipy, "scipy is missing") +class TestNumpyFFT(ParametricTestCase): + """ + Test the Numpy backend individually. + """ + + def setUp(self): + transforms = { + "1D": { + True: (np.fft.rfft, np.fft.irfft), + False: (np.fft.fft, np.fft.ifft), + }, + "2D": { + True: (np.fft.rfft2, np.fft.irfft2), + False: (np.fft.fft2, np.fft.ifft2), + }, + "3D": { + True: (np.fft.rfftn, np.fft.irfftn), + False: (np.fft.fftn, np.fft.ifftn), + }, + } + transforms["batched_1D"] = transforms["1D"] + transforms["batched_2D"] = transforms["2D"] + self.transforms = transforms + self.transform_infos = TransformInfos() + self.test_data = Data() + + def test(self): + """Test the numpy backend against native fft. + + Results should be exactly the same. + """ + for trdim in self.transform_infos.dimensions: + for mode in self.transform_infos.modes: + for size in self.transform_infos.sizes[trdim]: + with self.subTest(trdim=trdim, mode=mode, size=size): + self.__test(trdim, mode, size) + + def __test(self, trdim, mode, size): + logger.debug("trdim: %s, mode: %s, size: %s", trdim, mode, str(size)) + ndim = len(size) + input_data = self.test_data.data_refs[ndim].astype( + self.transform_infos.modes[mode]) + np_fft, np_ifft = self.transforms[trdim][np.isrealobj(input_data)] + + F = FFT( + template=input_data, + axes=self.transform_infos.axes[trdim], + backend="numpy" + ) + # Test FFT + res = F.fft(input_data) + ref = np_fft(input_data) + self.assertTrue(np.allclose(res, ref)) + + # Test IFFT + res2 = F.ifft(res) + ref2 = np_ifft(ref) + self.assertTrue(np.allclose(res2, ref2)) diff --git a/src/silx/math/fit/__init__.py b/src/silx/math/fit/__init__.py new file mode 100644 index 0000000..29e6a9e --- /dev/null +++ b/src/silx/math/fit/__init__.py @@ -0,0 +1,39 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "22/06/2016" + + +from .leastsq import leastsq, chisq_alpha_beta +from .leastsq import \ + CFREE, CPOSITIVE, CQUOTED, CFIXED, \ + CFACTOR, CDELTA, CSUM + +from .functions import * +from .filters import * +from .peaks import peak_search, guess_fwhm +from .fitmanager import FitManager +from .fittheory import FitTheory diff --git a/src/silx/math/fit/bgtheories.py b/src/silx/math/fit/bgtheories.py new file mode 100644 index 0000000..631c43e --- /dev/null +++ b/src/silx/math/fit/bgtheories.py @@ -0,0 +1,440 @@ +# coding: utf-8 +#/*########################################################################## +# +# Copyright (c) 2004-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +########################################################################### */ +"""This modules defines a set of background model functions and associated +estimation functions in a format that can be imported into a +:class:`silx.math.fit.FitManager` object. + +A background function is a function that you want to add to a regular fit +function prior to fitting the sum of both functions. This is useful, for +instance, if you need to fit multiple gaussian peaks in an array of +measured data points when the measurement is polluted by a background signal. + +The models include common background models such as a constant value or a +linear background. + +It also includes background computation filters - *strip* and *snip* - that +can extract a more complex low-curvature background signal from a signal with +peaks having higher curvatures. + +The source code of this module can serve as a template for defining your +own fit background theories. The minimal skeleton of such a theory definition +file is:: + + from silx.math.fit.fittheory import FitTheory + + def bgfunction1(x, y0, …): + bg_signal = … + return bg_signal + + def estimation_function1(x, y): + … + estimated_params = … + constraints = … + return estimated_params, constraints + + THEORY = { + 'bg_theory_name1': FitTheory( + description='Description of theory 1', + function=bgfunction1, + parameters=('param name 1', 'param name 2', …), + estimate=estimation_function1, + configure=configuration_function1, + derivative=derivative_function1, + is_background=True), + 'theory_name_2': …, + } +""" +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "16/01/2017" + +from collections import OrderedDict +import numpy +from silx.math.fit.filters import strip, snip1d,\ + savitsky_golay +from silx.math.fit.fittheory import FitTheory + +CONFIG = { + "SmoothingFlag": False, + "SmoothingWidth": 5, + "AnchorsFlag": False, + "AnchorsList": [], + "StripWidth": 2, + "StripIterations": 5000, + "StripThresholdFactor": 1.0, + "SnipWidth": 16, + "EstimatePolyOnStrip": True +} + +# to avoid costly computations when parameters stay the same +_BG_STRIP_OLDY = numpy.array([]) +_BG_STRIP_OLDPARS = [0, 0] +_BG_STRIP_OLDBG = numpy.array([]) + +_BG_SNIP_OLDY = numpy.array([]) +_BG_SNIP_OLDWIDTH = None +_BG_SNIP_OLDBG = numpy.array([]) + + +_BG_OLD_ANCHORS = [] +_BG_OLD_ANCHORS_FLAG = None + +_BG_SMOOTH_OLDWIDTH = None +_BG_SMOOTH_OLDFLAG = None + + +def _convert_anchors_to_indices(x): + """Anchors stored in CONFIG["AnchorsList"] are abscissa. + Convert then to indices (take first index where x >= anchor), + then return the list of indices. + + :param x: Original array of abscissa + :return: List of indices of anchors in x array. + If CONFIG['AnchorsFlag'] is False or None, or if the list + of indices is empty, return None. + """ + # convert anchor X abscissa to index + if CONFIG['AnchorsFlag'] and CONFIG['AnchorsList'] is not None: + anchors_indices = [] + for anchor_x in CONFIG['AnchorsList']: + if anchor_x <= x[0]: + continue + # take the first index where x > anchor_x + indices = numpy.nonzero(x >= anchor_x)[0] + if len(indices): + anchors_indices.append(min(indices)) + if not len(anchors_indices): + anchors_indices = None + else: + anchors_indices = None + + return anchors_indices + + +def strip_bg(x, y0, width, niter): + """Extract and return the strip bg from y0. + + Use anchors coordinates in CONFIG["AnchorsList"] if flag + CONFIG["AnchorsFlag"] is True. Convert anchors from x coordinate + to array index prior to passing it to silx.math.fit.filters.strip + + :param x: Abscissa array + :param x: Ordinate array (data values at x positions) + :param width: strip width + :param niter: strip niter + """ + global _BG_STRIP_OLDY + global _BG_STRIP_OLDPARS + global _BG_STRIP_OLDBG + global _BG_SMOOTH_OLDWIDTH + global _BG_SMOOTH_OLDFLAG + global _BG_OLD_ANCHORS + global _BG_OLD_ANCHORS_FLAG + + parameters_changed =\ + _BG_STRIP_OLDPARS != [width, niter] or\ + _BG_SMOOTH_OLDWIDTH != CONFIG["SmoothingWidth"] or\ + _BG_SMOOTH_OLDFLAG != CONFIG["SmoothingFlag"] or\ + _BG_OLD_ANCHORS_FLAG != CONFIG["AnchorsFlag"] or\ + _BG_OLD_ANCHORS != CONFIG["AnchorsList"] + + # same parameters + if not parameters_changed: + # same data + if numpy.array_equal(_BG_STRIP_OLDY, y0): + # same result + return _BG_STRIP_OLDBG + + _BG_STRIP_OLDY = y0 + _BG_STRIP_OLDPARS = [width, niter] + _BG_SMOOTH_OLDWIDTH = CONFIG["SmoothingWidth"] + _BG_SMOOTH_OLDFLAG = CONFIG["SmoothingFlag"] + _BG_OLD_ANCHORS = CONFIG["AnchorsList"] + _BG_OLD_ANCHORS_FLAG = CONFIG["AnchorsFlag"] + + y1 = savitsky_golay(y0, CONFIG["SmoothingWidth"]) if CONFIG["SmoothingFlag"] else y0 + + anchors_indices = _convert_anchors_to_indices(x) + + background = strip(y1, + w=width, + niterations=niter, + factor=CONFIG["StripThresholdFactor"], + anchors=anchors_indices) + + _BG_STRIP_OLDBG = background + + return background + + +def snip_bg(x, y0, width): + """Compute the snip bg for y0""" + global _BG_SNIP_OLDY + global _BG_SNIP_OLDWIDTH + global _BG_SNIP_OLDBG + global _BG_SMOOTH_OLDWIDTH + global _BG_SMOOTH_OLDFLAG + global _BG_OLD_ANCHORS + global _BG_OLD_ANCHORS_FLAG + + parameters_changed =\ + _BG_SNIP_OLDWIDTH != width or\ + _BG_SMOOTH_OLDWIDTH != CONFIG["SmoothingWidth"] or\ + _BG_SMOOTH_OLDFLAG != CONFIG["SmoothingFlag"] or\ + _BG_OLD_ANCHORS_FLAG != CONFIG["AnchorsFlag"] or\ + _BG_OLD_ANCHORS != CONFIG["AnchorsList"] + + # same parameters + if not parameters_changed: + # same data + if numpy.sum(_BG_SNIP_OLDY == y0) == len(y0): + # same result + return _BG_SNIP_OLDBG + + _BG_SNIP_OLDY = y0 + _BG_SNIP_OLDWIDTH = width + _BG_SMOOTH_OLDWIDTH = CONFIG["SmoothingWidth"] + _BG_SMOOTH_OLDFLAG = CONFIG["SmoothingFlag"] + _BG_OLD_ANCHORS = CONFIG["AnchorsList"] + _BG_OLD_ANCHORS_FLAG = CONFIG["AnchorsFlag"] + + y1 = savitsky_golay(y0, CONFIG["SmoothingWidth"]) if CONFIG["SmoothingFlag"] else y0 + + anchors_indices = _convert_anchors_to_indices(x) + + if anchors_indices is None or not len(anchors_indices): + anchors_indices = [0, len(y1) - 1] + + background = numpy.zeros_like(y1) + previous_anchor = 0 + for anchor_index in anchors_indices: + if (anchor_index > previous_anchor) and (anchor_index < len(y1)): + background[previous_anchor:anchor_index] =\ + snip1d(y1[previous_anchor:anchor_index], + width) + previous_anchor = anchor_index + + if previous_anchor < len(y1): + background[previous_anchor:] = snip1d(y1[previous_anchor:], + width) + + _BG_SNIP_OLDBG = background + + return background + + +def estimate_linear(x, y): + """ + Estimate the linear parameters (constant, slope) of a y signal. + + Strip peaks, then perform a linear regression. + """ + bg = strip_bg(x, y, + width=CONFIG["StripWidth"], + niter=CONFIG["StripIterations"]) + n = float(len(bg)) + Sy = numpy.sum(bg) + Sx = float(numpy.sum(x)) + Sxx = float(numpy.sum(x * x)) + Sxy = float(numpy.sum(x * bg)) + + deno = n * Sxx - (Sx * Sx) + if deno != 0: + bg = (Sxx * Sy - Sx * Sxy) / deno + slope = (n * Sxy - Sx * Sy) / deno + else: + bg = 0.0 + slope = 0.0 + estimated_par = [bg, slope] + # code = 0: FREE + constraints = [[0, 0, 0], [0, 0, 0]] + return estimated_par, constraints + + +def estimate_strip(x, y): + """Estimation function for strip parameters. + + Return parameters as defined in CONFIG dict, + set constraints to FIXED. + """ + estimated_par = [CONFIG["StripWidth"], + CONFIG["StripIterations"]] + constraints = numpy.zeros((len(estimated_par), 3), numpy.float64) + # code = 3: FIXED + constraints[0][0] = 3 + constraints[1][0] = 3 + return estimated_par, constraints + + +def estimate_snip(x, y): + """Estimation function for snip parameters. + + Return parameters as defined in CONFIG dict, + set constraints to FIXED. + """ + estimated_par = [CONFIG["SnipWidth"]] + constraints = numpy.zeros((len(estimated_par), 3), numpy.float64) + # code = 3: FIXED + constraints[0][0] = 3 + return estimated_par, constraints + + +def poly(x, y, *pars): + """Order n polynomial. + The order of the polynomial is defined by the number of + coefficients (``*pars``). + + """ + p = numpy.poly1d(pars) + return p(x) + + +def estimate_poly(x, y, deg=2): + """Estimate polynomial coefficients. + + """ + # extract bg signal with strip, to estimate polynomial on background + if CONFIG["EstimatePolyOnStrip"]: + y = strip_bg(x, y, + CONFIG["StripWidth"], + CONFIG["StripIterations"]) + pcoeffs = numpy.polyfit(x, y, deg) + cons = numpy.zeros((deg + 1, 3), numpy.float64) + return pcoeffs, cons + + +def estimate_quadratic_poly(x, y): + """Estimate quadratic polynomial coefficients. + """ + return estimate_poly(x, y, deg=2) + + +def estimate_cubic_poly(x, y): + """Estimate cubic polynomial coefficients. + """ + return estimate_poly(x, y, deg=3) + + +def estimate_quartic_poly(x, y): + """Estimate degree 4 polynomial coefficients. + """ + return estimate_poly(x, y, deg=4) + + +def estimate_quintic_poly(x, y): + """Estimate degree 5 polynomial coefficients. + """ + return estimate_poly(x, y, deg=5) + + +def configure(**kw): + """Update the CONFIG dict + """ + # inspect **kw to find known keys, update them in CONFIG + for key in CONFIG: + if key in kw: + CONFIG[key] = kw[key] + + return CONFIG + + +THEORY = OrderedDict( + (('No Background', + FitTheory( + description="No background function", + function=lambda x, y0: numpy.zeros_like(x), + parameters=[], + is_background=True)), + ('Constant', + FitTheory( + description='Constant background', + function=lambda x, y0, c: c * numpy.ones_like(x), + parameters=['Constant', ], + estimate=lambda x, y: ([min(y)], [[0, 0, 0]]), + is_background=True)), + ('Linear', + FitTheory( + description="Linear background, parameters 'Constant' and" + " 'Slope'", + function=lambda x, y0, a, b: a + b * x, + parameters=['Constant', 'Slope'], + estimate=estimate_linear, + configure=configure, + is_background=True)), + ('Strip', + FitTheory( + description="Compute background using a strip filter\n" + "Parameters 'StripWidth', 'StripIterations'", + function=strip_bg, + parameters=['StripWidth', 'StripIterations'], + estimate=estimate_strip, + configure=configure, + is_background=True)), + ('Snip', + FitTheory( + description="Compute background using a snip filter\n" + "Parameter 'SnipWidth'", + function=snip_bg, + parameters=['SnipWidth'], + estimate=estimate_snip, + configure=configure, + is_background=True)), + ('Degree 2 Polynomial', + FitTheory( + description="Quadratic polynomial background, Parameters " + "'a', 'b' and 'c'\ny = a*x^2 + b*x +c", + function=poly, + parameters=['a', 'b', 'c'], + estimate=estimate_quadratic_poly, + configure=configure, + is_background=True)), + ('Degree 3 Polynomial', + FitTheory( + description="Cubic polynomial background, Parameters " + "'a', 'b', 'c' and 'd'\n" + "y = a*x^3 + b*x^2 + c*x + d", + function=poly, + parameters=['a', 'b', 'c', 'd'], + estimate=estimate_cubic_poly, + configure=configure, + is_background=True)), + ('Degree 4 Polynomial', + FitTheory( + description="Quartic polynomial background\n" + "y = a*x^4 + b*x^3 + c*x^2 + d*x + e", + function=poly, + parameters=['a', 'b', 'c', 'd', 'e'], + estimate=estimate_quartic_poly, + configure=configure, + is_background=True)), + ('Degree 5 Polynomial', + FitTheory( + description="Quaintic polynomial background\n" + "y = a*x^5 + b*x^4 + c*x^3 + d*x^2 + e*x + f", + function=poly, + parameters=['a', 'b', 'c', 'd', 'e', 'f'], + estimate=estimate_quintic_poly, + configure=configure, + is_background=True)))) diff --git a/src/silx/math/fit/filters.pyx b/src/silx/math/fit/filters.pyx new file mode 100644 index 0000000..da1f6f5 --- /dev/null +++ b/src/silx/math/fit/filters.pyx @@ -0,0 +1,416 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +"""This module provides background extraction functions and smoothing +functions. These functions are extracted from PyMca module SpecFitFuns. + +Index of background extraction functions: +------------------------------------------ + + - :func:`strip` + - :func:`snip1d` + - :func:`snip2d` + - :func:`snip3d` + +Smoothing functions: +-------------------- + + - :func:`savitsky_golay` + - :func:`smooth1d` + - :func:`smooth2d` + - :func:`smooth3d` + +References: +----------- + +.. [Morhac97] Miroslav Morháč et al. + Background elimination methods for multidimensional coincidence γ-ray spectra. + Nucl. Instruments and Methods in Physics Research A401 (1997) 113-132. + https://doi.org/10.1016/S0168-9002(97)01023-1 + +.. [Ryan88] C.G. Ryan et al. + SNIP, a statistics-sensitive background treatment for the quantitative analysis of PIXE spectra in geoscience applications. + Nucl. Instruments and Methods in Physics Research B34 (1988) 396-402*. + https://doi.org/10.1016/0168-583X(88)90063-8 + +API documentation: +------------------- + +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "15/05/2017" + +import logging +import numpy + +_logger = logging.getLogger(__name__) + +cimport cython +cimport silx.math.fit.filters_wrapper as filters_wrapper + + +def strip(data, w=1, niterations=1000, factor=1.0, anchors=None): + """Extract background from data using the strip algorithm, as explained at + http://pymca.sourceforge.net/stripbackground.html. + + In its simplest implementation it is just as an iterative procedure + depending on two parameters. These parameters are the strip background + width ``w``, and the number of iterations. At each iteration, if the + contents of channel ``i``, ``y(i)``, is above the average of the contents + of the channels at ``w`` channels of distance, ``y(i-w)`` and + ``y(i+w)``, ``y(i)`` is replaced by the average. + At the end of the process we are left with something that resembles a spectrum + in which the peaks have been stripped. + + :param data: Data array + :type data: numpy.ndarray + :param w: Strip width + :param niterations: number of iterations + :param factor: scaling factor applied to the average of ``y(i-w)`` and + ``y(i+w)`` before comparing to ``y(i)`` + :param anchors: Array of anchors, indices of points that will not be + modified during the stripping procedure. + :return: Data with peaks stripped away + """ + cdef: + double[::1] input_c + double[::1] output + long[::1] anchors_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__"): + raise TypeError("data must be a sequence (list, tuple) " + + "or a numpy array") + data_shape = (len(data), ) + else: + data_shape = data.shape + + input_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + output = numpy.empty(shape=(input_c.size,), + dtype=numpy.float64) + + if anchors is not None and len(anchors): + # numpy.int_ is the same as C long (http://docs.scipy.org/doc/numpy/user/basics.types.html) + anchors_c = numpy.array(anchors, + copy=False, + dtype=numpy.int_, + order='C') + len_anchors = anchors_c.size + else: + # Make a dummy length-1 array, because if I use shape=(0,) I get the error + # IndexError: Out of bounds on buffer access (axis 0) + anchors_c = numpy.empty(shape=(1,), + dtype=numpy.int_) + len_anchors = 0 + + + status = filters_wrapper.strip(&input_c[0], input_c.size, + factor, niterations, w, + &anchors_c[0], len_anchors, &output[0]) + + return numpy.asarray(output).reshape(data_shape) + + +def snip1d(data, snip_width): + """Estimate the baseline (background) of a 1D data vector by clipping peaks. + + Implementation of the algorithm SNIP in 1D is described in [Morhac97]_. + The original idea for 1D and the low-statistics-digital-filter (lsdf) comes + from [Ryan88]_. + + :param data: Data array, preferably 1D and of type *numpy.float64*. + Else, the data array will be flattened and converted to + *dtype=numpy.float64* prior to applying the snip filter. + :type data: numpy.ndarray + :param snip_width: Width of the snip operator, in number of samples. + A sample will be iteratively compared to it's neighbors up to a + distance of ``snip_width`` samples. This parameters has a direct + influence on the speed of the algorithm. + :type width: int + :return: Baseline of the input array, as an array of the same shape. + :rtype: numpy.ndarray + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__"): + raise TypeError("data must be a sequence (list, tuple) " + + "or a numpy array") + data_shape = (len(data), ) + else: + data_shape = data.shape + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.snip1d(&data_c[0], data_c.size, snip_width) + + return numpy.asarray(data_c).reshape(data_shape) + + +def snip2d(data, snip_width): + """Estimate the baseline (background) of a 2D data signal by clipping peaks. + + Implementation of the algorithm SNIP in 2D described in [Morhac97]_. + + :param data: 2D array + :type data: numpy.ndarray + :param width: Width of the snip operator, in number of samples. A wider + snip operator will result in a smoother result (lower frequency peaks + will be clipped), and a longer computation time. + :type width: int + :return: Baseline of the input array, as an array of the same shape. + :rtype: numpy.ndarray + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__") or not hasattr(data[0], "__len__"): + raise TypeError("data must be a 2D sequence (list, tuple) " + + "or a 2D numpy array") + nrows = len(data) + ncolumns = len(data[0]) + data_shape = (len(data), len(data[0])) + + else: + data_shape = data.shape + nrows = data_shape[0] + if len(data_shape) == 2: + ncolumns = data_shape[1] + else: + raise TypeError("data array must be 2-dimensional") + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.snip2d(&data_c[0], nrows, ncolumns, snip_width) + + return numpy.asarray(data_c).reshape(data_shape) + + +def snip3d(data, snip_width): + """Estimate the baseline (background) of a 3D data signal by clipping peaks. + + Implementation of the algorithm SNIP in 3D described in [Morhac97]_. + + :param data: 3D array + :type data: numpy.ndarray + :param width: Width of the snip operator, in number of samples. A wider + snip operator will result in a smoother result (lower frequency peaks + will be clipped), and a longer computation time. + :type width: int + + :return: Baseline of the input array, as an array of the same shape. + :rtype: numpy.ndarray + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__") or not hasattr(data[0], "__len__") or\ + not hasattr(data[0][0], "__len__"): + raise TypeError("data must be a 3D sequence (list, tuple) " + + "or a 3D numpy array") + nx = len(data) + ny = len(data[0]) + nz = len(data[0][0]) + data_shape = (len(data), len(data[0]), len(data[0][0])) + else: + data_shape = data.shape + nrows = data_shape[0] + if len(data_shape) == 3: + nx = data_shape[0] + ny = data_shape[1] + nz = data_shape[2] + else: + raise TypeError("data array must be 3-dimensional") + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.snip3d(&data_c[0], nx, ny, nz, snip_width) + + return numpy.asarray(data_c).reshape(data_shape) + + +def savitsky_golay(data, npoints=5): + """Smooth a curve using a Savitsky-Golay filter. + + :param data: Input data + :type data: 1D numpy array + :param npoints: Size of the smoothing operator in number of samples + Must be between 3 and 100. + :return: Smoothed data + """ + cdef: + double[::1] data_c + double[::1] output + + data_c = numpy.array(data, + dtype=numpy.float64, + order='C').reshape(-1) + + output = numpy.empty(shape=(data_c.size,), + dtype=numpy.float64) + + status = filters_wrapper.SavitskyGolay(&data_c[0], data_c.size, + npoints, &output[0]) + + if status: + _logger.error("Smoothing failed. Check that npoints is greater " + + "than 3 and smaller than 100.") + + return numpy.asarray(output).reshape(data.shape) + + +def smooth1d(data): + """Simple smoothing for 1D data. + + For a data array :math:`y` of length :math:`n`, the smoothed array + :math:`ys` is calculated as a weighted average of neighboring samples: + + :math:`ys_0 = 0.75 y_0 + 0.25 y_1` + + :math:`ys_i = 0.25 (y_{i-1} + 2 y_i + y_{i+1})` for :math:`0 < i < n-1` + + :math:`ys_{n-1} = 0.25 y_{n-2} + 0.75 y_{n-1}` + + + :param data: 1D data array + :type data: numpy.ndarray + :return: Smoothed data + :rtype: numpy.ndarray(dtype=numpy.float64) + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__"): + raise TypeError("data must be a sequence (list, tuple) " + + "or a numpy array") + data_shape = (len(data), ) + else: + data_shape = data.shape + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.smooth1d(&data_c[0], data_c.size) + + return numpy.asarray(data_c).reshape(data_shape) + + +def smooth2d(data): + """Simple smoothing for 2D data: + :func:`smooth1d` is applied succesively along both axis + + :param data: 2D data array + :type data: numpy.ndarray + :return: Smoothed data + :rtype: numpy.ndarray(dtype=numpy.float64) + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__") or not hasattr(data[0], "__len__"): + raise TypeError("data must be a 2D sequence (list, tuple) " + + "or a 2D numpy array") + nrows = len(data) + ncolumns = len(data[0]) + data_shape = (len(data), len(data[0])) + + else: + data_shape = data.shape + nrows = data_shape[0] + if len(data_shape) == 2: + ncolumns = data_shape[1] + else: + raise TypeError("data array must be 2-dimensional") + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.smooth2d(&data_c[0], nrows, ncolumns) + + return numpy.asarray(data_c).reshape(data_shape) + + +def smooth3d(data): + """Simple smoothing for 3D data: + :func:`smooth2d` is applied on each 2D slice of the data volume along all + 3 axis + + :param data: 2D data array + :type data: numpy.ndarray + :return: Smoothed data + :rtype: numpy.ndarray(dtype=numpy.float64) + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__") or not hasattr(data[0], "__len__") or\ + not hasattr(data[0][0], "__len__"): + raise TypeError("data must be a 3D sequence (list, tuple) " + + "or a 3D numpy array") + nx = len(data) + ny = len(data[0]) + nz = len(data[0][0]) + data_shape = (len(data), len(data[0]), len(data[0][0])) + else: + data_shape = data.shape + nrows = data_shape[0] + if len(data_shape) == 3: + nx = data_shape[0] + ny = data_shape[1] + nz = data_shape[2] + else: + raise TypeError("data array must be 3-dimensional") + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.smooth3d(&data_c[0], nx, ny, nz) + + return numpy.asarray(data_c).reshape(data_shape) diff --git a/src/silx/math/fit/filters/include/filters.h b/src/silx/math/fit/filters/include/filters.h new file mode 100644 index 0000000..1ee9a95 --- /dev/null +++ b/src/silx/math/fit/filters/include/filters.h @@ -0,0 +1,45 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#ifndef FITFILTERS_H +#define FITFILTERS_H + +/* Background functions */ +void snip1d(double *data, int size, int width); +//void snip1d_multiple(double *data, int n_channels, int snip_width, int n_spectra); +void snip2d(double *data, int nrows, int ncolumns, int width); +void snip3d(double *data, int nx, int ny, int nz, int width); + +int strip(double* input, long len_input, double c, long niter, int deltai, + long* anchors, long len_anchors, double* output); + +/* Smoothing functions */ + +int SavitskyGolay(double* input, long len_input, int npoints, double* output); + +void smooth1d(double *data, int size); +void smooth2d(double *data, int size0, int size1); +void smooth3d(double *data, int size0, int size1, int size2); + + +#endif /* #define FITFILTERS_H */ diff --git a/src/silx/math/fit/filters/src/smoothnd.c b/src/silx/math/fit/filters/src/smoothnd.c new file mode 100644 index 0000000..cb96961 --- /dev/null +++ b/src/silx/math/fit/filters/src/smoothnd.c @@ -0,0 +1,317 @@ +#/*########################################################################## +# +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +#include <stdlib.h> +#include <string.h> +#include <math.h> +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +#define MAX_SAVITSKY_GOLAY_WIDTH 101 +#define MIN_SAVITSKY_GOLAY_WIDTH 3 + +/* Wrapped functions */ +void smooth1d(double *data, int size); +void smooth2d(double *data, int size0, int size1); +void smooth3d(double *data, int size0, int size1, int size2); +int SavitskyGolay(double* input, long len_input, int npoints, double* output); + +/* Internal functions */ +long index2d(long row_idx, long col_idx, long ncols); +long index3d(long x_idx, long y_idx, long z_idx, long ny, long nz); +void smooth1d_rows(double *data, long nrows, long ncols); +void smooth1d_cols(double *data, long nrows, long ncols); +void smooth1d_x(double *data, long nx, long ny, long nz); +void smooth1d_y(double *data, long nx, long ny, long nz); +void smooth1d_z(double *data, long nx, long ny, long nz); +void smooth2d_yzslice(double *data, long nx, long ny, long nz); +void smooth2d_xzslice(double *data, long nx, long ny, long nz); +void smooth2d_xyslice(double *data, long nx, long ny, long nz); + + +/* Simple smoothing of a 1D array */ +void smooth1d(double *data, int size) +{ + long i; + double prev_sample; + double next_sample; + + if (size < 3) + { + return; + } + prev_sample = data[0]; + for (i=0; i<(size-1); i++) + { + next_sample = 0.25 * (prev_sample + 2 * data[i] + data[i+1]); + prev_sample = data[i]; + data[i] = next_sample; + } + data[size-1] = 0.25 * prev_sample + 0.75 * data[size-1]; + return; +} + +/* Smoothing of a 2D array*/ +void smooth2d(double *data, int nrows, int ncols) +{ + /* smooth the first dimension (rows) */ + smooth1d_rows(data, nrows, ncols); + + /* smooth the 2nd dimension */ + smooth1d_cols(data, nrows, ncols); +} + +/* Smoothing of a 3D array */ +void smooth3d(double *data, int nx, int ny, int nz) +{ + smooth2d_xyslice(data, nx, ny, nz); + smooth2d_xzslice(data, nx, ny, nz); + smooth2d_yzslice(data, nx, ny, nz); +} + +/* 1D Savitsky-Golay smoothing */ +int SavitskyGolay(double* input, long len_input, int npoints, double* output) +{ + + //double dpoints = 5.; + double coeff[MAX_SAVITSKY_GOLAY_WIDTH]; + int i, j, m; + double dhelp, den; + double *data; + + memcpy(output, input, len_input * sizeof(double)); + + if (!(npoints % 2)) npoints +=1; + + if((npoints < MIN_SAVITSKY_GOLAY_WIDTH) || (len_input < npoints) || \ + (npoints > MAX_SAVITSKY_GOLAY_WIDTH)) + { + /* do not smooth data */ + return 1; + } + + /* calculate the coefficients */ + m = (int) (npoints/2); + den = (double) ((2*m-1) * (2*m+1) * (2*m + 3)); + for (i=0; i<= m; i++){ + coeff[m+i] = (double) (3 * (3*m*m + 3*m - 1 - 5*i*i )); + coeff[m-i] = coeff[m+i]; + } + + /* simple smoothing at the beginning */ + for (j=0; j<=(int)(npoints/3); j++) + { + smooth1d(output, m); + } + + /* simple smoothing at the end */ + for (j=0; j<=(int)(npoints/3); j++) + { + smooth1d((output+len_input-m-1), m); + } + + /*one does not need the whole spectrum buffer, but code is clearer */ + data = (double *) malloc(len_input * sizeof(double)); + memcpy(data, output, len_input * sizeof(double)); + + /* the actual SG smoothing in the middle */ + for (i=m; i<(len_input-m); i++){ + dhelp = 0; + for (j=-m;j<=m;j++) { + dhelp += coeff[m+j] * (*(data+i+j)); + } + if(dhelp > 0.0){ + *(output+i) = dhelp / den; + } + } + free(data); + return (0); +} + +/*********************/ +/* Utility functions */ +/*********************/ + +long index2d(long row_idx, long col_idx, long ncols) +{ + return (row_idx*ncols+col_idx); +} + +/* Apply smooth 1d on all rows in a 2D array*/ +void smooth1d_rows(double *data, long nrows, long ncols) +{ + long row_idx; + + for (row_idx=0; row_idx < nrows; row_idx++) + { + smooth1d(&data[row_idx * ncols], ncols); + } +} + +/* Apply smooth 1d on all columns in a 2D array*/ +void smooth1d_cols(double *data, long nrows, long ncols) +{ + long row_idx, col_idx; + long this_idx2d, next_idx2d; + double prev_sample; + double next_sample; + + for (col_idx=0; col_idx < ncols; col_idx++) + { + prev_sample = data[index2d(0, col_idx, ncols)]; + for (row_idx=0; row_idx<(nrows-1); row_idx++) + { + this_idx2d = index2d(row_idx, col_idx, ncols); + next_idx2d = index2d(row_idx+1, col_idx, ncols); + + next_sample = 0.25 * (prev_sample + \ + 2 * data[this_idx2d] + \ + data[next_idx2d]); + prev_sample = data[this_idx2d]; + data[this_idx2d] = next_sample; + } + + this_idx2d = index2d(nrows-1, col_idx, ncols); + data[this_idx2d] = 0.25 * prev_sample + 0.75 * data[this_idx2d]; + } +} + +long index3d(long x_idx, long y_idx, long z_idx, long ny, long nz) +{ + return ((x_idx*ny + y_idx) * nz + z_idx); +} + +/* Apply smooth 1d along first dimension in a 3D array*/ +void smooth1d_x(double *data, long nx, long ny, long nz) +{ + long x_idx, y_idx, z_idx; + long this_idx3d, next_idx3d; + double prev_sample; + double next_sample; + + for (y_idx=0; y_idx < ny; y_idx++) + { + for (z_idx=0; z_idx < nz; z_idx++) + { + prev_sample = data[index3d(0, y_idx, z_idx, ny, nz)]; + for (x_idx=0; x_idx<(nx-1); x_idx++) + { + this_idx3d = index3d(x_idx, y_idx, z_idx, ny, nz); + next_idx3d = index3d(x_idx+1, y_idx, z_idx, ny, nz); + + next_sample = 0.25 * (prev_sample + \ + 2 * data[this_idx3d] + \ + data[next_idx3d]); + prev_sample = data[this_idx3d]; + data[this_idx3d] = next_sample; + } + + this_idx3d = index3d(nx-1, y_idx, z_idx, ny, nz); + data[this_idx3d] = 0.25 * prev_sample + 0.75 * data[this_idx3d]; + } + } +} + +/* Apply smooth 1d along second dimension in a 3D array*/ +void smooth1d_y(double *data, long nx, long ny, long nz) +{ + long x_idx, y_idx, z_idx; + long this_idx3d, next_idx3d; + double prev_sample; + double next_sample; + + for (x_idx=0; x_idx < nx; x_idx++) + { + for (z_idx=0; z_idx < nz; z_idx++) + { + prev_sample = data[index3d(x_idx, 0, z_idx, ny, nz)]; + for (y_idx=0; y_idx<(ny-1); y_idx++) + { + this_idx3d = index3d(x_idx, y_idx, z_idx, ny, nz); + next_idx3d = index3d(x_idx, y_idx+1, z_idx, ny, nz); + + next_sample = 0.25 * (prev_sample + \ + 2 * data[this_idx3d] + \ + data[next_idx3d]); + prev_sample = data[this_idx3d]; + data[this_idx3d] = next_sample; + } + + this_idx3d = index3d(x_idx, ny-1, z_idx, ny, nz); + data[this_idx3d] = 0.25 * prev_sample + 0.75 * data[this_idx3d]; + } + } +} + +/* Apply smooth 1d along third dimension in a 3D array*/ +void smooth1d_z(double *data, long nx, long ny, long nz) +{ + long x_idx, y_idx; + long idx3d_first_sample; + + for (x_idx=0; x_idx < nx; x_idx++) + { + for (y_idx=0; y_idx < ny; y_idx++) + { + idx3d_first_sample = index3d(x_idx, y_idx, 0, ny, nz); + /*We can use regular 1D smoothing function because z samples + are contiguous in memory*/ + smooth1d(&data[idx3d_first_sample], nz); + } + } +} + +/* 2D smoothing of a YZ slice in a 3D volume*/ +void smooth2d_yzslice(double *data, long nx, long ny, long nz) +{ + long x_idx; + long slice_size = ny * nz; + + /* a YZ slice is a "normal" 2D array of memory-contiguous data*/ + for (x_idx=0; x_idx < nx; x_idx++) + { + smooth2d(&data[x_idx*slice_size], ny, nz); + } +} + +/* 2D smoothing of a XZ slice in a 3D volume*/ +void smooth2d_xzslice(double *data, long nx, long ny, long nz) +{ + + /* smooth along the first dimension */ + smooth1d_x(data, nx, ny, nz); + + /* smooth along the third dimension */ + smooth1d_z(data, nx, ny, nz); +} + +/* 2D smoothing of a XY slice in a 3D volume*/ +void smooth2d_xyslice(double *data, long nx, long ny, long nz) +{ + /* smooth along the first dimension */ + smooth1d_x(data, nx, ny, nz); + + /* smooth along the second dimension */ + smooth1d_y(data, nx, ny, nz); +} + diff --git a/src/silx/math/fit/filters/src/snip1d.c b/src/silx/math/fit/filters/src/snip1d.c new file mode 100644 index 0000000..994a272 --- /dev/null +++ b/src/silx/math/fit/filters/src/snip1d.c @@ -0,0 +1,149 @@ +#/*########################################################################## +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + Implementation of the algorithm SNIP in 1D described in + Miroslav Morhac et al. Nucl. Instruments and Methods in Physics Research A401 (1997) 113-132. + + The original idea for 1D and the low-statistics-digital-filter (lsdf) come from + C.G. Ryan et al. Nucl. Instruments and Methods in Physics Research B34 (1988) 396-402. +*/ +#include <stdlib.h> +#include <string.h> +#include <math.h> + +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +void lls(double *data, int size); +void lls_inv(double *data, int size); +void snip1d(double *data, int n_channels, int snip_width); +void snip1d_multiple(double *data, int n_channels, int snip_width, int n_spectra); +void lsdf(double *data, int size, int fwhm, double f, double A, double M, double ratio); + +void lls(double *data, int size) +{ + int i; + for (i=0; i< size; i++) + { + data[i] = log(log(sqrt(data[i]+1.0)+1.0)+1.0); + } +} + +void lls_inv(double *data, int size) +{ + int i; + double tmp; + for (i=0; i< size; i++) + { + /* slightly different than the published formula because + with the original formula: + + tmp = exp(exp(data[i]-1.0)-1.0); + data[i] = tmp * tmp - 1.0; + + one does not recover the original data */ + + tmp = exp(exp(data[i])-1.0)-1.0; + data[i] = tmp * tmp - 1.0; + } +} + +void lsdf(double *data, int size, int fwhm, double f, double A, double M, double ratio) +{ + int channel, i, j; + double L, R, S; + int width; + double dhelp; + + width = (int) (f * fwhm); + for (channel=width; channel<(size-width); channel++) + { + i = width; + while(i>0) + { + L=0; + R=0; + for(j=channel-i; j<channel; j++) + { + L += data[j]; + } + for(j=channel+1; j<channel+i; j++) + { + R += data[j]; + } + S = data[channel] + L + R; + if (S<M) + { + data[channel] = S /(2*i+1); + break; + } + dhelp = (R+1)/(L+1); + if ((dhelp < ratio) && (dhelp > (1/ratio))) + { + if (S<(A*sqrt(data[channel]))) + { + data[channel] = S /(2*i+1); + break; + } + } + i=i-1; + } + } +} + + +void snip1d(double *data, int n_channels, int snip_width) +{ + snip1d_multiple(data, n_channels, snip_width, 1); +} + +void snip1d_multiple(double *data, int n_channels, int snip_width, int n_spectra) +{ + int i; + int j; + int p; + int offset; + double *w; + + i = (int) (0.5 * snip_width); + /* lsdf(data, size, i, 1.5, 75., 10., 1.3); */ + + w = (double *) malloc(n_channels * sizeof(double)); + + for (j=0; j < n_spectra; j++) + { + offset = j * n_channels; + for (p = snip_width; p > 0; p--) + { + for (i=p; i<(n_channels - p); i++) + { + w[i] = MIN(data[i + offset], 0.5*(data[i + offset - p] + data[ i + offset + p])); + } + for (i=p; i<(n_channels - p); i++) + { + data[i+offset] = w[i]; + } + } + } + free(w); +} diff --git a/src/silx/math/fit/filters/src/snip2d.c b/src/silx/math/fit/filters/src/snip2d.c new file mode 100644 index 0000000..235759c --- /dev/null +++ b/src/silx/math/fit/filters/src/snip2d.c @@ -0,0 +1,96 @@ +#/*########################################################################## +# +# The PyMca X-Ray Fluorescence Toolkit +# +# Copyright (c) 2004-2014 European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + Implementation of the algorithm SNIP in 2D described in + Miroslav Morhac et al. Nucl. Instruments and Methods in Physics Research A401 (1997) 113-132. +*/ +#include <stdlib.h> +#include <string.h> +#include <math.h> +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +void lls(double *data, int size); +void lls_inv(double *data, int size); + +void snip2d(double *data, int nrows, int ncolumns, int width) +{ + int i, j; + int p; + int size; + double *w; + double P1, P2, P3, P4; + double S1, S2, S3, S4; + double dhelp; + int iminuspxncolumns; /* (i-p) * ncolumns */ + int ixncolumns; /* i * ncolumns */ + int ipluspxncolumns; /* (i+p) * ncolumns */ + + size = nrows * ncolumns; + w = (double *) malloc(size * sizeof(double)); + + for (p=width; p > 0; p--) + { + for (i=p; i<(nrows-p); i++) + { + iminuspxncolumns = (i-p) * ncolumns; + ixncolumns = i * ncolumns; + ipluspxncolumns = (i+p) * ncolumns; + for (j=p; j<(ncolumns-p); j++) + { + P4 = data[ iminuspxncolumns + (j-p)]; /* P4 = data[i-p][j-p] */ + S4 = data[ iminuspxncolumns + j]; /* S4 = data[i-p][j] */ + P2 = data[ iminuspxncolumns + (j+p)]; /* P2 = data[i-p][j+p] */ + S3 = data[ ixncolumns + (j-p)]; /* S3 = data[i][j-p] */ + S2 = data[ ixncolumns + (j+p)]; /* S2 = data[i][j+p] */ + P3 = data[ ipluspxncolumns + (j-p)]; /* P3 = data[i+p][j-p] */ + S1 = data[ ipluspxncolumns + j]; /* S1 = data[i+p][j] */ + P1 = data[ ipluspxncolumns + (j+p)]; /* P1 = data[i+p][j+p] */ + dhelp = 0.5*(P1+P3); + S1 = MAX(S1, dhelp) - dhelp; + dhelp = 0.5*(P1+P2); + S2 = MAX(S2, dhelp) - dhelp; + dhelp = 0.5*(P3+P4); + S3 = MAX(S3, dhelp) - dhelp; + dhelp = 0.5*(P2+P4); + S4 = MAX(S4, dhelp) - dhelp; + w[ixncolumns + j] = MIN(data[ixncolumns + j], 0.5 * (S1+S2+S3+S4) + 0.25 * (P1+P2+P3+P4)); + } + } + for (i=p; i<(nrows-p); i++) + { + ixncolumns = i * ncolumns; + for (j=p; j<(ncolumns-p); j++) + { + data[ixncolumns + j] = w[ixncolumns + j]; + } + } + } + free(w); +} diff --git a/src/silx/math/fit/filters/src/snip3d.c b/src/silx/math/fit/filters/src/snip3d.c new file mode 100644 index 0000000..cf48ee4 --- /dev/null +++ b/src/silx/math/fit/filters/src/snip3d.c @@ -0,0 +1,186 @@ +#/*########################################################################## +# +# The PyMca X-Ray Fluorescence Toolkit +# +# Copyright (c) 2004-2014 European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + Implementation of the algorithm SNIP in 3D described in + Miroslav Morhac et al. Nucl. Instruments and Methods in Physics Research A401 (1997) 113-132. +*/ + +#include <stdlib.h> +#include <string.h> +#include <math.h> +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +void lls(double *data, int size); +void lls_inv(double *data, int size); + +void snip3d(double *data, int nx, int ny, int nz, int width) +{ + int i, j, k; + int p; + int size; + double *w; + double P1, P2, P3, P4, P5, P6, P7, P8; + double R1, R2, R3, R4, R5, R6; + double S1, S2, S3, S4, S5, S6, S7, S8, S9, S10, S11, S12; + double dhelp; + long ioffset; + long iplus; + long imin; + long joffset; + long jplus; + long jmin; + + size = nx * ny * nz; + w = (double *) malloc(size * sizeof(double)); + + for (p=width; p > 0; p--) + { + for (i=p; i<(nx-p); i++) + { + ioffset = i * ny * nz; + iplus = (i + p) * ny * nz; + imin = (i - p) * ny * nz; + for (j=p; j<(ny-p); j++) + { + joffset = j * nz; + jplus = (j + p) * nz; + jmin = (j - p) * nz; + for (k=p; k<(nz-p); k++) + { + P1 = data[iplus + jplus + k-p]; /* P1 = data[i+p][j+p][k-p] */ + P2 = data[imin + jplus + k-p]; /* P2 = data[i-p][j+p][k-p] */ + P3 = data[iplus + jmin + k-p]; /* P3 = data[i+p][j-p][k-p] */ + P4 = data[imin + jmin + k-p]; /* P4 = data[i-p][j-p][k-p] */ + P5 = data[iplus + jplus + k+p]; /* P5 = data[i+p][j+p][k+p] */ + P6 = data[imin + jplus + k+p]; /* P6 = data[i-p][j+p][k+p] */ + P7 = data[imin + jmin + k+p]; /* P7 = data[i-p][j-p][k+p] */ + P8 = data[iplus + jmin + k+p]; /* P8 = data[i+p][j-p][k+p] */ + + S1 = data[iplus + joffset + k-p]; /* S1 = data[i+p][j][k-p] */ + S2 = data[ioffset + jmin + k-p]; /* S2 = data[i][j+p][k-p] */ + S3 = data[imin + joffset + k-p]; /* S3 = data[i-p][j][k-p] */ + S4 = data[ioffset + jmin + k-p]; /* S4 = data[i][j-p][k-p] */ + S5 = data[imin + joffset + k+p]; /* S5 = data[i-p][j][k+p] */ + S6 = data[ioffset + jplus + k+p]; /* S6 = data[i][j+p][k+p] */ + S7 = data[imin + joffset + k+p]; /* S7 = data[i-p][j][k+p] */ + S8 = data[ioffset + jmin + k+p]; /* S8 = data[i][j-p][k+p] */ + S9 = data[imin + jplus + k]; /* S9 = data[i-p][j+p][k] */ + S10 = data[imin + jmin + k]; /* S10 = data[i-p][j-p][k] */ + S11 = data[iplus + jmin + k]; /* S11 = data[i+p][j-p][k] */ + S12 = data[iplus + jplus + k]; /* S12 = data[i+p][j+p][k] */ + + R1 = data[ioffset + joffset + k-p]; /* R1 = data[i][j][k-p] */ + R2 = data[ioffset + joffset + k+p]; /* R2 = data[i][j][k+p] */ + R3 = data[imin + joffset + k]; /* R3 = data[i-p][j][k] */ + R4 = data[iplus + joffset + k]; /* R4 = data[i+p][j][k] */ + R5 = data[ioffset + jplus + k]; /* R5 = data[i][j+p][k] */ + R6 = data[ioffset + jmin + k]; /* R6 = data[i][j-p][k] */ + + dhelp = 0.5*(P1+P3); + S1 = MAX(S1, dhelp) - dhelp; + + dhelp = 0.5*(P1+P2); + S2 = MAX(S2, dhelp) - dhelp; + + dhelp = 0.5*(P2+P4); + S3 = MAX(S3, dhelp) - dhelp; + + dhelp = 0.5*(P3+P4); + S4 = MAX(S4, dhelp) - dhelp; + + dhelp = 0.5*(P5+P8); /* Different from paper (P5+P7) but according to drawing */ + S5 = MAX(S5, dhelp) - dhelp; + + dhelp = 0.5*(P5+P6); + S6 = MAX(S6, dhelp) - dhelp; + + dhelp = 0.5*(P6+P7); /* Different from paper (P6+P8) but according to drawing */ + S7 = MAX(S7, dhelp) - dhelp; + + dhelp = 0.5*(P7+P8); + S8 = MAX(S8, dhelp) - dhelp; + + dhelp = 0.5*(P2+P6); + S9 = MAX(S9, dhelp) - dhelp; + + dhelp = 0.5*(P4+P7); /* Different from paper (P4+P8) but according to drawing */ + S10 = MAX(S10, dhelp) - dhelp; + + dhelp = 0.5*(P3+P8); /* Different from paper (P1+P5) but according to drawing */ + S11 = MAX(S11, dhelp) - dhelp; + + dhelp = 0.5*(P1+P5); /* Different from paper (P3+P7) but according to drawing */ + S12 = MAX(S12, dhelp) - dhelp; + + /* The published formulae correspond to have: + P7 and P8 interchanged, and S11 and S12 interchanged + with respect to the published drawing */ + + dhelp = 0.5 * (S1+S2+S3+S4) + 0.25 * (P1+P2+P3+P4); + R1 = MAX(R1, dhelp) - dhelp; + + dhelp = 0.5 * (S5+S6+S7+S8) + 0.25 * (P5+P6+P7+P8); + R2 = MAX(R2, dhelp) - dhelp; + + dhelp = 0.5 * (S3+S7+S9+S10) + 0.25 * (P2+P4+P6+P7); /* Again same P7 and P8 change */ + R3 = MAX(R3, dhelp) - dhelp; + + dhelp = 0.5 * (S1+S5+S11+S12) + 0.25 * (P1+P3+P5+P8); /* Again same P7 and P8 change */ + R4 = MAX(R4, dhelp) - dhelp; + + dhelp = 0.5 * (S2+S6+S9+S12) + 0.25 * (P1+P2+P5+P6); /* Again same S11 and S12 change */ + R5 = MAX(R5, dhelp) - dhelp; + + dhelp = 0.5 * (S4+S8+S10+S11) + 0.25 * (P3+P4+P7+P8); /* Again same S11 and S12 change */ + R6 = MAX(R6, dhelp) - dhelp; + + dhelp = 0.5 * (R1 + R2 + R3 + R4 + R5 + R6) +\ + 0.25 * (S1 + S2 + S3 + S4 + S5 + S6) +\ + 0.25 * (S7 + S8 + S9 + S10 + S11 + S12) +\ + 0.125 * (P1 + P2 + P3 + P4 + P5 + P6 + P7 + P8); + w[ioffset + joffset + k] = MIN(data[ioffset + joffset + k], dhelp); + } + } + } + for (i=p; i<(nx-p); i++) + { + ioffset = i * ny * nz; + for (j=p; j<(ny-p); j++) + { + joffset = j * nz; + for (k=p; k<(nz-p); j++) + { + data[ioffset + joffset + k] = w[ioffset + joffset + k]; + } + } + } + } + free(w); +} diff --git a/src/silx/math/fit/filters/src/strip.c b/src/silx/math/fit/filters/src/strip.c new file mode 100644 index 0000000..dec0742 --- /dev/null +++ b/src/silx/math/fit/filters/src/strip.c @@ -0,0 +1,118 @@ +#/*########################################################################## +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + This file provides a background strip function, to isolate low frequency + background signal from a spectrum (and later substact it from the signal + to be left only with the peaks to be fitted). + + It is adapted from PyMca source file "SpecFitFuns.c". The main difference + with the original code is that this code does not handle the python + wrapping, which is done elsewhere using cython. + + Authors: V.A. Sole, P. Knobel + License: MIT + Last modified: 17/06/2016 +*/ + +#include <string.h> + +#include <stdio.h> + +/* strip(double* input, double c, long niter, double* output) + + The strip background is probably PyMca's most popular background model. + + In its simplest implementation it is just as an iterative procedure depending + on two parameters. These parameters are the strip background width w, and the + strip background number of iterations. At each iteration, if the contents of + channel i, y(i), is above the average of the contents of the channels at w + channels of distance, y(i-w) and y(i+w), y(i) is replaced by the average. + At the end of the process we are left with something that resembles a spectrum + in which the peaks have been "stripped". + + Parameters: + + - input: Input data array + - c: scaling factor applied to the average of y(i-w) and y(i+w) before + comparing to y(i) + - niter: number of iterations + - deltai: operator width (in number of channels) + - anchors: Array of anchors, indices of points that will not be + modified during the stripping procedure. + - output: output array + +*/ +int strip(double* input, long len_input, + double c, long niter, int deltai, + long* anchors, long len_anchors, + double* output) +{ + long iter_index, array_index, anchor_index, anchor; + int anchor_nearby_flag; + double t_mean; + + memcpy(output, input, len_input * sizeof(double)); + + if (deltai <=0) deltai = 1; + + if (len_input < (2*deltai+1)) return(-1); + + if (len_anchors > 0) { + for (iter_index = 0; iter_index < niter; iter_index++) { + for (array_index = deltai; array_index < len_input - deltai; array_index++) { + /* if index is within +- deltai of an anchor, don't do anything */ + anchor_nearby_flag = 0; + for (anchor_index=0; anchor_index<len_anchors; anchor_index++) + { + anchor = anchors[anchor_index]; + if (array_index > (anchor - deltai) && array_index < (anchor + deltai)) + { + anchor_nearby_flag = 1; + break; + } + } + /* skip this array_index index */ + if (anchor_nearby_flag) { + continue; + } + + t_mean = 0.5 * (input[array_index-deltai] + input[array_index+deltai]); + if (input[array_index] > (t_mean * c)) + output[array_index] = t_mean; + } + memcpy(input, output, len_input * sizeof(double)); + } + } + else { + for (iter_index = 0; iter_index < niter; iter_index++) { + for (array_index=deltai; array_index < len_input - deltai; array_index++) { + t_mean = 0.5 * (input[array_index-deltai] + input[array_index+deltai]); + + if (input[array_index] > (t_mean * c)) + output[array_index] = t_mean; + } + memcpy(input, output, len_input * sizeof(double)); + } + } + return(0); +} diff --git a/src/silx/math/fit/filters_wrapper.pxd b/src/silx/math/fit/filters_wrapper.pxd new file mode 100644 index 0000000..e4f7c72 --- /dev/null +++ b/src/silx/math/fit/filters_wrapper.pxd @@ -0,0 +1,71 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "22/06/2016" + +cimport cython + +cdef extern from "filters.h": + void snip1d(double *data, + int size, + int width) + + void snip2d(double *data, + int nrows, + int ncolumns, + int width) + + void snip3d(double *data, + int nx, + int ny, + int nz, + int width) + + int strip(double* input, + long len_input, + double c, + long niter, + int deltai, + long* anchors, + long len_anchors, + double* output) + + int SavitskyGolay(double* input, + long len_input, + int npoints, + double* output) + + void smooth1d(double *data, + int size) + + void smooth2d(double *data, + int size0, + int size1) + + void smooth3d(double *data, + int size0, + int size1, + int size2) diff --git a/src/silx/math/fit/fitmanager.py b/src/silx/math/fit/fitmanager.py new file mode 100644 index 0000000..226e047 --- /dev/null +++ b/src/silx/math/fit/fitmanager.py @@ -0,0 +1,1087 @@ +# coding: utf-8 +# /*######################################################################### +# +# Copyright (c) 2004-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ##########################################################################*/ +""" +This module provides a tool to perform advanced fitting. The actual fit relies +on :func:`silx.math.fit.leastsq`. + +This module deals with: + + - handling of the model functions (using a set of default functions or + loading custom user functions) + - handling of estimation function, that are used to determine the number + of parameters to be fitted for functions with unknown number of + parameters (such as the sum of a variable number of gaussian curves), + and find reasonable initial parameters for input to the iterative + fitting algorithm + - handling of custom derivative functions that can be passed as a + parameter to :func:`silx.math.fit.leastsq` + - providing different background models + +""" +from collections import OrderedDict +import logging +import numpy +from numpy.linalg.linalg import LinAlgError +import os +import sys + +from .filters import strip, smooth1d +from .leastsq import leastsq +from .fittheory import FitTheory +from . import bgtheories + + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "16/01/2017" + +_logger = logging.getLogger(__name__) + + +class FitManager(object): + """ + Fit functions manager + + :param x: Abscissa data. If ``None``, :attr:`xdata` is set to + ``numpy.array([0.0, 1.0, 2.0, ..., len(y)-1])`` + :type x: Sequence or numpy array or None + :param y: The dependant data ``y = f(x)``. ``y`` must have the same + shape as ``x`` if ``x`` is not ``None``. + :type y: Sequence or numpy array or None + :param sigmay: The uncertainties in the ``ydata`` array. These can be + used as weights in the least-squares problem, if ``weight_flag`` + is ``True``. + If ``None``, the uncertainties are assumed to be 1, unless + ``weight_flag`` is ``True``, in which case the square-root + of ``y`` is used. + :type sigmay: Sequence or numpy array or None + :param weight_flag: If this parameter is ``True`` and ``sigmay`` + uncertainties are not specified, the square root of ``y`` is used + as weights in the least-squares problem. If ``False``, the + uncertainties are set to 1. + :type weight_flag: boolean + """ + def __init__(self, x=None, y=None, sigmay=None, weight_flag=False): + """ + """ + self.fitconfig = { + 'WeightFlag': weight_flag, + 'fitbkg': 'No Background', + 'fittheory': None, + # Next few parameters are defined for compatibility with legacy theories + # which take the background as argument for their estimation function + 'StripWidth': 2, + 'StripIterations': 5000, + 'StripThresholdFactor': 1.0, + 'SmoothingFlag': False + } + """Dictionary of fit configuration parameters. + These parameters can be modified using the :meth:`configure` method. + + Keys are: + + - 'fitbkg': name of the function used for fitting a low frequency + background signal + - 'FwhmPoints': default full width at half maximum value for the + peaks'. + - 'Sensitivity': Sensitivity parameter for the peak detection + algorithm (:func:`silx.math.fit.peak_search`) + """ + + self.theories = OrderedDict() + """Dictionary of fit theories, defining functions to be fitted + to individual peaks. + + Keys are descriptive theory names (e.g "Gaussians" or "Step up"). + Values are :class:`silx.math.fit.fittheory.FitTheory` objects with + the following attributes: + + - *"function"* is the fit function for an individual peak + - *"parameters"* is a sequence of parameter names + - *"estimate"* is the parameter estimation function + - *"configure"* is the function returning the configuration dict + for the theory in the format described in the :attr:` fitconfig` + documentation + - *"derivative"* (optional) is a custom derivative function, whose + signature is described in the documentation of + :func:`silx.math.fit.leastsq.leastsq` + (``model_deriv(xdata, parameters, index)``). + - *"description"* is a description string + """ + + self.selectedtheory = None + """Name of currently selected theory. This name matches a key in + :attr:`theories`.""" + + self.bgtheories = OrderedDict() + """Dictionary of background theories. + + See :attr:`theories` for documentation on theories. + """ + + # Load default theories (constant, linear, strip) + self.loadbgtheories(bgtheories) + + self.selectedbg = 'No Background' + """Name of currently selected background theory. This name must be + an existing key in :attr:`bgtheories`.""" + + self.fit_results = [] + """This list stores detailed information about all fit parameters. + It is initialized in :meth:`estimate` and completed with final fit + values in :meth:`runfit`. + + Each fit parameter is stored as a dictionary with following fields: + + - 'name': Parameter name. + - 'estimation': Estimated value. + - 'group': Group number. Group 0 corresponds to the background + function parameters. Group ``n`` (for ``n>0``) corresponds to + the fit function parameters for the n-th peak. + - 'code': Constraint code + + - 0 - FREE + - 1 - POSITIVE + - 2 - QUOTED + - 3 - FIXED + - 4 - FACTOR + - 5 - DELTA + - 6 - SUM + + - 'cons1': + + - Ignored if 'code' is FREE, POSITIVE or FIXED. + - Min value of the parameter if code is QUOTED + - Index of fitted parameter to which 'cons2' is related + if code is FACTOR, DELTA or SUM. + + - 'cons2': + + - Ignored if 'code' is FREE, POSITIVE or FIXED. + - Max value of the parameter if QUOTED + - Factor to apply to related parameter with index 'cons1' if + 'code' is FACTOR + - Difference with parameter with index 'cons1' if + 'code' is DELTA + - Sum obtained when adding parameter with index 'cons1' if + 'code' is SUM + + - 'fitresult': Fitted value. + - 'sigma': Standard deviation for the parameter estimate + - 'xmin': Lower limit of the ``x`` data range on which the fit + was performed + - 'xmax': Upeer limit of the ``x`` data range on which the fit + was performed + """ + + self.parameter_names = [] + """This list stores all fit parameter names: background function + parameters and fit function parameters for every peak. It is filled + in :meth:`estimate`. + + It is the responsibility of the estimate function defined in + :attr:`theories` to determine how many parameters are needed, + based on how many peaks are detected and how many parameters are needed + to fit an individual peak. + """ + + self.setdata(x, y, sigmay) + + ################## + # Public methods # + ################## + def addbackground(self, bgname, bgtheory): + """Add a new background theory to dictionary :attr:`bgtheories`. + + :param bgname: String with the name describing the function + :param bgtheory: :class:`FitTheory` object + :type bgtheory: :class:`silx.math.fit.fittheory.FitTheory` + """ + self.bgtheories[bgname] = bgtheory + + def addtheory(self, name, theory=None, + function=None, parameters=None, + estimate=None, configure=None, derivative=None, + description=None, pymca_legacy=False): + """Add a new theory to dictionary :attr:`theories`. + + You can pass a name and a :class:`FitTheory` object as arguments, or + alternatively provide all arguments necessary to instantiate a new + :class:`FitTheory` object. + + See :meth:`loadtheories` for more information on estimation functions, + configuration functions and custom derivative functions. + + :param name: String with the name describing the function + :param theory: :class:`FitTheory` object, defining a fit function and + associated information (estimation function, description…). + If this parameter is provided, all other parameters, except for + ``name``, are ignored. + :type theory: :class:`silx.math.fit.fittheory.FitTheory` + :param callable function: Mandatory argument if ``theory`` is not provided. + See documentation for :attr:`silx.math.fit.fittheory.FitTheory.function`. + :param List[str] parameters: Mandatory argument if ``theory`` is not provided. + See documentation for :attr:`silx.math.fit.fittheory.FitTheory.parameters`. + :param callable estimate: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.estimate` + :param callable configure: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.configure` + :param callable derivative: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.derivative` + :param str description: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.description` + :param config_widget: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.config_widget` + :param bool pymca_legacy: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.pymca_legacy` + """ + if theory is not None: + self.theories[name] = theory + + elif function is not None and parameters is not None: + self.theories[name] = FitTheory( + description=description, + function=function, + parameters=parameters, + estimate=estimate, + configure=configure, + derivative=derivative, + pymca_legacy=pymca_legacy + ) + + else: + raise TypeError("You must supply a FitTheory object or define " + + "a fit function and its parameters.") + + def addbgtheory(self, name, theory=None, + function=None, parameters=None, + estimate=None, configure=None, + derivative=None, description=None): + """Add a new theory to dictionary :attr:`bgtheories`. + + You can pass a name and a :class:`FitTheory` object as arguments, or + alternatively provide all arguments necessary to instantiate a new + :class:`FitTheory` object. + + :param name: String with the name describing the function + :param theory: :class:`FitTheory` object, defining a fit function and + associated information (estimation function, description…). + If this parameter is provided, all other parameters, except for + ``name``, are ignored. + :type theory: :class:`silx.math.fit.fittheory.FitTheory` + :param function function: Mandatory argument if ``theory`` is not provided. + See documentation for :attr:`silx.math.fit.fittheory.FitTheory.function`. + :param list[str] parameters: Mandatory argument if ``theory`` is not provided. + See documentation for :attr:`silx.math.fit.fittheory.FitTheory.parameters`. + :param function estimate: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.estimate` + :param function configure: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.configure` + :param function derivative: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.derivative` + :param str description: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.description` + """ + if theory is not None: + self.bgtheories[name] = theory + + elif function is not None and parameters is not None: + self.bgtheories[name] = FitTheory( + description=description, + function=function, + parameters=parameters, + estimate=estimate, + configure=configure, + derivative=derivative, + is_background=True + ) + + else: + raise TypeError("You must supply a FitTheory object or define " + + "a background function and its parameters.") + + def configure(self, **kw): + """Configure the current theory by filling or updating the + :attr:`fitconfig` dictionary. + Call the custom configuration function, if any. This allows the user + to modify the behavior of the custom fit function or the custom + estimate function. + + This methods accepts only named parameters. All ``**kw`` parameters + are expected to be fields of :attr:`fitconfig` to be updated, unless + they have a special meaning for the custom configuration function + of the currently selected theory.. + + This method returns the modified config dictionary returned by the + custom configuration function. + """ + # inspect **kw to find known keys, update them in self.fitconfig + for key in self.fitconfig: + if key in kw: + self.fitconfig[key] = kw[key] + + # initialize dict with existing config dict + result = {} + result.update(self.fitconfig) + + if "WeightFlag" in kw: + if kw["WeightFlag"]: + self.enableweight() + else: + self.disableweight() + + if self.selectedtheory is None: + return result + + # Apply custom configuration function + custom_config_fun = self.theories[self.selectedtheory].configure + if custom_config_fun is not None: + result.update(custom_config_fun(**kw)) + + custom_bg_config_fun = self.bgtheories[self.selectedbg].configure + if custom_bg_config_fun is not None: + result.update(custom_bg_config_fun(**kw)) + + # Update self.fitconfig with custom config + for key in self.fitconfig: + if key in result: + self.fitconfig[key] = result[key] + + result.update(self.fitconfig) + return result + + def estimate(self, callback=None): + """ + Fill :attr:`fit_results` with an estimation of the fit parameters. + + At first, the background parameters are estimated, if a background + model has been specified. + Then, a custom estimation function related to the model function is + called. + + This process determines the number of needed fit parameters and + provides an initial estimation for them, to serve as an input for the + actual iterative fitting performed in :meth:`runfit`. + + :param callback: Optional callback function, conforming to the + signature ``callback(data)`` with ``data`` being a dictionary. + This callback function is called before and after the estimation + process, and is given a dictionary containing the values of + :attr:`state` (``'Estimate in progress'`` or ``'Ready to Fit'``) + and :attr:`chisq`. + This is used for instance in :mod:`silx.gui.fit.FitWidget` to + update a widget displaying a status message. + :return: Estimated parameters + """ + self.state = 'Estimate in progress' + self.chisq = None + + if callback is not None: + callback(data={'chisq': self.chisq, + 'status': self.state}) + + CONS = {0: 'FREE', + 1: 'POSITIVE', + 2: 'QUOTED', + 3: 'FIXED', + 4: 'FACTOR', + 5: 'DELTA', + 6: 'SUM', + 7: 'IGNORE'} + + # Filter-out not finite data + xwork = self.xdata[self._finite_mask] + ywork = self.ydata[self._finite_mask] + + # estimate the background + bg_params, bg_constraints = self.estimate_bkg(xwork, ywork) + + # estimate the function + try: + fun_params, fun_constraints = self.estimate_fun(xwork, ywork) + except LinAlgError: + self.state = 'Estimate failed' + if callback is not None: + callback(data={'status': self.state}) + raise + + # build the names + self.parameter_names = [] + + for bg_param_name in self.bgtheories[self.selectedbg].parameters: + self.parameter_names.append(bg_param_name) + + fun_param_names = self.theories[self.selectedtheory].parameters + param_index, peak_index = 0, 0 + while param_index < len(fun_params): + peak_index += 1 + for fun_param_name in fun_param_names: + self.parameter_names.append(fun_param_name + "%d" % peak_index) + param_index += 1 + + self.fit_results = [] + nb_fun_params_per_group = len(fun_param_names) + group_number = 0 + xmin = min(xwork) + xmax = max(xwork) + nb_bg_params = len(bg_params) + for (pindex, pname) in enumerate(self.parameter_names): + # First come background parameters + if pindex < nb_bg_params: + estimation_value = bg_params[pindex] + constraint_code = CONS[int(bg_constraints[pindex][0])] + cons1 = bg_constraints[pindex][1] + cons2 = bg_constraints[pindex][2] + # then come peak function parameters + else: + fun_param_index = pindex - nb_bg_params + + # increment group_number for each new fitted peak + if (fun_param_index % nb_fun_params_per_group) == 0: + group_number += 1 + + estimation_value = fun_params[fun_param_index] + constraint_code = CONS[int(fun_constraints[fun_param_index][0])] + # cons1 is the index of another fit parameter. In the global + # fit_results, we must adjust the index to account for the bg + # params added to the start of the list. + cons1 = fun_constraints[fun_param_index][1] + if constraint_code in ["FACTOR", "DELTA", "SUM"]: + cons1 += nb_bg_params + cons2 = fun_constraints[fun_param_index][2] + + self.fit_results.append({'name': pname, + 'estimation': estimation_value, + 'group': group_number, + 'code': constraint_code, + 'cons1': cons1, + 'cons2': cons2, + 'fitresult': 0.0, + 'sigma': 0.0, + 'xmin': xmin, + 'xmax': xmax}) + + self.state = 'Ready to Fit' + self.chisq = None + self.niter = 0 + + if callback is not None: + callback(data={'chisq': self.chisq, + 'status': self.state}) + return numpy.append(bg_params, fun_params) + + def fit(self): + """Convenience method to call :meth:`estimate` followed by :meth:`runfit`. + + :return: Output of :meth:`runfit`""" + self.estimate() + return self.runfit() + + def gendata(self, x=None, paramlist=None, estimated=False): + """Return a data array using the currently selected fit function + and the fitted parameters. + + :param x: Independent variable where the function is calculated. + If ``None``, use :attr:`xdata`. + :param paramlist: List of dictionaries, each dictionary item being a + fit parameter. The dictionary's format is documented in + :attr:`fit_results`. + If ``None`` (default), use parameters from :attr:`fit_results`. + :param estimated: If *True*, use estimated parameters. + :return: :meth:`fitfunction` calculated for parameters whose code is + not set to ``"IGNORE"``. + + This calculates :meth:`fitfunction` on `x` data using fit parameters + from a list of parameter dictionaries, if field ``code`` is not set + to ``"IGNORE"``. + """ + x = self.xdata if x is None else numpy.array(x, copy=False) + + if paramlist is None: + paramlist = self.fit_results + active_params = [] + for param in paramlist: + if param['code'] not in ['IGNORE', 7]: + if not estimated: + active_params.append(param['fitresult']) + else: + active_params.append(param['estimation']) + + # Mask x with not finite (support nD x) + finite_mask = numpy.all(numpy.isfinite(x), axis=tuple(range(1, x.ndim))) + + if numpy.all(finite_mask): # All values are finite: fast path + return self.fitfunction(numpy.array(x, copy=True), *active_params) + + else: # Only run fitfunction on finite data and complete result with NaNs + # Create result with same number as elements as x, filling holes with NaNs + result = numpy.full((x.shape[0],), numpy.nan, dtype=numpy.float64) + result[finite_mask] = self.fitfunction( + numpy.array(x[finite_mask], copy=True), *active_params) + return result + + def get_estimation(self): + """Return the list of fit parameter names.""" + if self.state not in ["Ready to fit", "Fit in progress", "Ready"]: + _logger.warning("get_estimation() called before estimate() completed") + return [param["estimation"] for param in self.fit_results] + + def get_names(self): + """Return the list of fit parameter estimations.""" + if self.state not in ["Ready to fit", "Fit in progress", "Ready"]: + msg = "get_names() called before estimate() completed, " + msg += "names are not populated at this stage" + _logger.warning(msg) + return [param["name"] for param in self.fit_results] + + def get_fitted_parameters(self): + """Return the list of fitted parameters.""" + if self.state not in ["Ready"]: + msg = "get_fitted_parameters() called before runfit() completed, " + msg += "results are not available a this stage" + _logger.warning(msg) + return [param["fitresult"] for param in self.fit_results] + + def loadtheories(self, theories): + """Import user defined fit functions defined in an external Python + source file, and save them in :attr:`theories`. + + An example of such a file can be found in the sources of + :mod:`silx.math.fit.fittheories`. It must contain a + dictionary named ``THEORY`` with the following structure:: + + THEORY = { + 'theory_name_1': + FitTheory(description='Description of theory 1', + function=fitfunction1, + parameters=('param name 1', 'param name 2', …), + estimate=estimation_function1, + configure=configuration_function1, + derivative=derivative_function1), + 'theory_name_2': + FitTheory(…), + } + + See documentation of :mod:`silx.math.fit.fittheories` and + :mod:`silx.math.fit.fittheory` for more + information on designing your fit functions file. + + This method can also load user defined functions in the legacy + format used in *PyMca*. + + :param theories: Name of python source file, or module containing the + definition of fit functions. + :raise: ImportError if theories cannot be imported + """ + from types import ModuleType + if isinstance(theories, ModuleType): + theories_module = theories + else: + # if theories is not a module, it must be a string + string_types = (basestring,) if sys.version_info[0] == 2 else (str,) # noqa + if not isinstance(theories, string_types): + raise ImportError("theory must be a python module, a module" + + "name or a python filename") + # if theories is a filename + if os.path.isfile(theories): + sys.path.append(os.path.dirname(theories)) + f = os.path.basename(os.path.splitext(theories)[0]) + theories_module = __import__(f) + # if theories is a module name + else: + theories_module = __import__(theories) + + if hasattr(theories_module, "INIT"): + theories.INIT() + + if not hasattr(theories_module, "THEORY"): + msg = "File %s does not contain a THEORY dictionary" % theories + raise ImportError(msg) + + elif isinstance(theories_module.THEORY, dict): + # silx format for theory definition + for theory_name, fittheory in list(theories_module.THEORY.items()): + self.addtheory(theory_name, fittheory) + else: + self._load_legacy_theories(theories_module) + + def loadbgtheories(self, theories): + """Import user defined background functions defined in an external Python + module (source file), and save them in :attr:`theories`. + + An example of such a file can be found in the sources of + :mod:`silx.math.fit.fittheories`. It must contain a + dictionary named ``THEORY`` with the following structure:: + + THEORY = { + 'theory_name_1': + FitTheory(description='Description of theory 1', + function=fitfunction1, + parameters=('param name 1', 'param name 2', …), + estimate=estimation_function1, + configure=configuration_function1, + 'theory_name_2': + FitTheory(…), + } + + See documentation of :mod:`silx.math.fit.bgtheories` and + :mod:`silx.math.fit.fittheory` for more + information on designing your background functions file. + + :param theories: Module or name of python source file containing the + definition of background functions. + :raise: ImportError if theories cannot be imported + """ + from types import ModuleType + if isinstance(theories, ModuleType): + theories_module = theories + else: + # if theories is not a module, it must be a string + string_types = (basestring,) if sys.version_info[0] == 2 else (str,) # noqa + if not isinstance(theories, string_types): + raise ImportError("theory must be a python module, a module" + + "name or a python filename") + # if theories is a filename + if os.path.isfile(theories): + sys.path.append(os.path.dirname(theories)) + f = os.path.basename(os.path.splitext(theories)[0]) + theories_module = __import__(f) + # if theories is a module name + else: + theories_module = __import__(theories) + + if hasattr(theories_module, "INIT"): + theories.INIT() + + if not hasattr(theories_module, "THEORY"): + msg = "File %s does not contain a THEORY dictionary" % theories + raise ImportError(msg) + + elif isinstance(theories_module.THEORY, dict): + # silx format for theory definition + for theory_name, fittheory in list(theories_module.THEORY.items()): + self.addbgtheory(theory_name, fittheory) + + def setbackground(self, theory): + """Choose a background type from within :attr:`bgtheories`. + + This updates :attr:`selectedbg`. + + :param theory: The name of the background to be used. + :raise: KeyError if ``theory`` is not a key of :attr:`bgtheories``. + """ + if theory in self.bgtheories: + self.selectedbg = theory + else: + msg = "No theory with name %s in bgtheories.\n" % theory + msg += "Available theories: %s\n" % self.bgtheories.keys() + raise KeyError(msg) + + # run configure to apply our fitconfig to the selected theory + # through its custom config function + self.configure(**self.fitconfig) + + def setdata(self, x, y, sigmay=None, xmin=None, xmax=None): + """Set data attributes: + + - ``xdata0``, ``ydata0`` and ``sigmay0`` store the initial data + and uncertainties. These attributes are not modified after + initialization. + - ``xdata``, ``ydata`` and ``sigmay`` store the data after + removing values where ``xdata < xmin`` or ``xdata > xmax``. + These attributes may be modified at a latter stage by filters. + + :param x: Abscissa data. If ``None``, :attr:`xdata`` is set to + ``numpy.array([0.0, 1.0, 2.0, ..., len(y)-1])`` + :type x: Sequence or numpy array or None + :param y: The dependant data ``y = f(x)``. ``y`` must have the same + shape as ``x`` if ``x`` is not ``None``. + :type y: Sequence or numpy array or None + :param sigmay: The uncertainties in the ``ydata`` array. These are + used as weights in the least-squares problem. + If ``None``, the uncertainties are assumed to be 1. + :type sigmay: Sequence or numpy array or None + :param xmin: Lower value of x values to use for fitting + :param xmax: Upper value of x values to use for fitting + """ + if y is None: + self.xdata0 = numpy.array([], numpy.float64) + self.ydata0 = numpy.array([], numpy.float64) + # self.sigmay0 = numpy.array([], numpy.float64) + self.xdata = numpy.array([], numpy.float64) + self.ydata = numpy.array([], numpy.float64) + # self.sigmay = numpy.array([], numpy.float64) + + else: + self.ydata0 = numpy.array(y) + self.ydata = numpy.array(y) + if x is None: + self.xdata0 = numpy.arange(len(self.ydata0)) + self.xdata = numpy.arange(len(self.ydata0)) + else: + self.xdata0 = numpy.array(x) + self.xdata = numpy.array(x) + + # default weight + if sigmay is None: + self.sigmay0 = None + self.sigmay = numpy.sqrt(self.ydata) if self.fitconfig["WeightFlag"] else None + else: + self.sigmay0 = numpy.array(sigmay) + self.sigmay = numpy.array(sigmay) if self.fitconfig["WeightFlag"] else None + + # take the data between limits, using boolean array indexing + if (xmin is not None or xmax is not None) and len(self.xdata): + xmin = xmin if xmin is not None else min(self.xdata) + xmax = xmax if xmax is not None else max(self.xdata) + bool_array = (self.xdata >= xmin) & (self.xdata <= xmax) + self.xdata = self.xdata[bool_array] + self.ydata = self.ydata[bool_array] + self.sigmay = self.sigmay[bool_array] if sigmay is not None else None + + self._finite_mask = numpy.logical_and( + numpy.all(numpy.isfinite(self.xdata), axis=tuple(range(1, self.xdata.ndim))), + numpy.isfinite(self.ydata)) + + def enableweight(self): + """This method can be called to set :attr:`sigmay`. If :attr:`sigmay0` was filled with + actual uncertainties in :meth:`setdata`, use these values. + Else, use ``sqrt(self.ydata)``. + """ + if self.sigmay0 is None: + self.sigmay = numpy.sqrt(self.ydata) if self.fitconfig["WeightFlag"] else None + else: + self.sigmay = self.sigmay0 + + def disableweight(self): + """This method can be called to set :attr:`sigmay` equal to ``None``. + As a result, :func:`leastsq` will consider that the weights in the + least square problem are 1 for all samples.""" + self.sigmay = None + + def settheory(self, theory): + """Pick a theory from :attr:`theories`. + + :param theory: Name of the theory to be used. + :raise: KeyError if ``theory`` is not a key of :attr:`theories`. + """ + if theory is None: + self.selectedtheory = None + elif theory in self.theories: + self.selectedtheory = theory + else: + msg = "No theory with name %s in theories.\n" % theory + msg += "Available theories: %s\n" % self.theories.keys() + raise KeyError(msg) + + # run configure to apply our fitconfig to the selected theory + # through its custom config function + self.configure(**self.fitconfig) + + def runfit(self, callback=None): + """Run the actual fitting and fill :attr:`fit_results` with fit results. + + Before running this method, :attr:`fit_results` must already be + populated with a list of all parameters and their estimated values. + For this, run :meth:`estimate` beforehand. + + :param callback: Optional callback function, conforming to the + signature ``callback(data)`` with ``data`` being a dictionary. + This callback function is called before and after the estimation + process, and is given a dictionary containing the values of + :attr:`state` (``'Fit in progress'`` or ``'Ready'``) + and :attr:`chisq`. + This is used for instance in :mod:`silx.gui.fit.FitWidget` to + update a widget displaying a status message. + :return: Tuple ``(fitted parameters, uncertainties, infodict)``. + *infodict* is the dictionary returned by + :func:`silx.math.fit.leastsq` when called with option + ``full_output=True``. Uncertainties is a sequence of uncertainty + values associated with each fitted parameter. + """ + # self.dataupdate() + + self.state = 'Fit in progress' + self.chisq = None + + if callback is not None: + callback(data={'chisq': self.chisq, + 'status': self.state}) + + param_val = [] + param_constraints = [] + # Initial values are set to the ones computed in estimate() + for param in self.fit_results: + param_val.append(param['estimation']) + param_constraints.append([param['code'], param['cons1'], param['cons2']]) + + # Filter-out not finite data + ywork = self.ydata[self._finite_mask] + xwork = self.xdata[self._finite_mask] + + try: + params, covariance_matrix, infodict = leastsq( + self.fitfunction, # bg + actual model function + xwork, ywork, param_val, + sigma=self.sigmay, + constraints=param_constraints, + model_deriv=self.theories[self.selectedtheory].derivative, + full_output=True, left_derivative=True) + except LinAlgError: + self.state = 'Fit failed' + callback(data={'status': self.state}) + raise + + sigmas = infodict['uncertainties'] + + for i, param in enumerate(self.fit_results): + if param['code'] != 'IGNORE': + param['fitresult'] = params[i] + param['sigma'] = sigmas[i] + + self.chisq = infodict["reduced_chisq"] + self.niter = infodict["niter"] + self.state = 'Ready' + + if callback is not None: + callback(data={'chisq': self.chisq, + 'status': self.state}) + + return params, sigmas, infodict + + ################### + # Private methods # + ################### + def fitfunction(self, x, *pars): + """Function to be fitted. + + This is the sum of the selected background function plus + the selected fit model function. + + :param x: Independent variable where the function is calculated. + :param pars: Sequence of all fit parameters. The first few parameters + are background parameters, then come the peak function parameters. + :return: Output of the fit function with ``x`` as input and ``pars`` + as fit parameters. + """ + result = numpy.zeros(numpy.shape(x), numpy.float64) + + if self.selectedbg is not None: + bg_pars_list = self.bgtheories[self.selectedbg].parameters + nb_bg_pars = len(bg_pars_list) + + bgfun = self.bgtheories[self.selectedbg].function + result += bgfun(x, self.ydata, *pars[0:nb_bg_pars]) + else: + nb_bg_pars = 0 + + selectedfun = self.theories[self.selectedtheory].function + result += selectedfun(x, *pars[nb_bg_pars:]) + + return result + + def estimate_bkg(self, x, y): + """Estimate background parameters using the function defined in + the current fit configuration. + + To change the selected background model, attribute :attr:`selectdbg` + must be changed using method :meth:`setbackground`. + + The actual background function to be used is + referenced in :attr:`bgtheories` + + :param x: Sequence of x data + :param y: sequence of y data + :return: Tuple of two sequences and one data array + ``(estimated_param, constraints, bg_data)``: + + - ``estimated_param`` is a list of estimated values for each + background parameter. + - ``constraints`` is a 2D sequence of dimension ``(n_parameters, 3)`` + + - ``constraints[i][0]``: Constraint code. + See explanation about codes in :attr:`fit_results` + + - ``constraints[i][1]`` + See explanation about 'cons1' in :attr:`fit_results` + documentation. + + - ``constraints[i][2]`` + See explanation about 'cons2' in :attr:`fit_results` + documentation. + """ + background_estimate_function = self.bgtheories[self.selectedbg].estimate + if background_estimate_function is not None: + return background_estimate_function(x, y) + else: + return [], [] + + def estimate_fun(self, x, y): + """Estimate fit parameters using the function defined in + the current fit configuration. + + :param x: Sequence of x data + :param y: sequence of y data + :param bg: Background signal, to be subtracted from ``y`` before fitting. + :return: Tuple of two sequences ``(estimated_param, constraints)``: + + - ``estimated_param`` is a list of estimated values for each + background parameter. + - ``constraints`` is a 2D sequence of dimension (n_parameters, 3) + + - ``constraints[i][0]``: Constraint code. + See explanation about codes in :attr:`fit_results` + + - ``constraints[i][1]`` + See explanation about 'cons1' in :attr:`fit_results` + documentation. + + - ``constraints[i][2]`` + See explanation about 'cons2' in :attr:`fit_results` + documentation. + :raise: ``TypeError`` if estimation function is not callable + + """ + estimatefunction = self.theories[self.selectedtheory].estimate + if hasattr(estimatefunction, '__call__'): + if not self.theories[self.selectedtheory].pymca_legacy: + return estimatefunction(x, y) + else: + # legacy pymca estimate functions have a different signature + if self.fitconfig["fitbkg"] == "No Background": + bg = numpy.zeros_like(y) + else: + if self.fitconfig["SmoothingFlag"]: + y = smooth1d(y) + bg = strip(y, + w=self.fitconfig["StripWidth"], + niterations=self.fitconfig["StripIterations"], + factor=self.fitconfig["StripThresholdFactor"]) + # fitconfig can be filled by user defined config function + xscaling = self.fitconfig.get('Xscaling', 1.0) + yscaling = self.fitconfig.get('Yscaling', 1.0) + return estimatefunction(x, y, bg, xscaling, yscaling) + else: + raise TypeError("Estimation function in attribute " + + "theories[%s]" % self.selectedtheory + + " must be callable.") + + def _load_legacy_theories(self, theories_module): + """Load theories from a custom module in the old PyMca format. + + See PyMca5.PyMcaMath.fitting.SpecfitFunctions for an example. + """ + mandatory_attributes = ["THEORY", "PARAMETERS", + "FUNCTION", "ESTIMATE"] + err_msg = "Custom fit function file must define: " + err_msg += ", ".join(mandatory_attributes) + for attr in mandatory_attributes: + if not hasattr(theories_module, attr): + raise ImportError(err_msg) + + derivative = theories_module.DERIVATIVE if hasattr(theories_module, "DERIVATIVE") else None + configure = theories_module.CONFIGURE if hasattr(theories_module, "CONFIGURE") else None + estimate = theories_module.ESTIMATE if hasattr(theories_module, "ESTIMATE") else None + if isinstance(theories_module.THEORY, (list, tuple)): + # multiple fit functions + for i in range(len(theories_module.THEORY)): + deriv = derivative[i] if derivative is not None else None + config = configure[i] if configure is not None else None + estim = estimate[i] if estimate is not None else None + self.addtheory(theories_module.THEORY[i], + FitTheory( + theories_module.FUNCTION[i], + theories_module.PARAMETERS[i], + estim, + config, + deriv, + pymca_legacy=True)) + else: + # single fit function + self.addtheory(theories_module.THEORY, + FitTheory( + theories_module.FUNCTION, + theories_module.PARAMETERS, + estimate, + configure, + derivative, + pymca_legacy=True)) + + +def test(): + from .functions import sum_gauss + from . import fittheories + from . import bgtheories + + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(1000).astype(numpy.float64) + + p = [1000, 100., 250, + 255, 690., 45, + 1500, 800.5, 95] + y = 0.5 * x + 13 + sum_gauss(x, *p) + + # Fitting + fit = FitManager() + # more sensitivity necessary to resolve + # overlapping peaks at x=690 and x=800.5 + fit.setdata(x=x, y=y) + fit.loadtheories(fittheories) + fit.settheory('Gaussians') + fit.loadbgtheories(bgtheories) + fit.setbackground('Linear') + fit.estimate() + fit.runfit() + + print("Searched parameters = ", p) + print("Obtained parameters : ") + dummy_list = [] + for param in fit.fit_results: + print(param['name'], ' = ', param['fitresult']) + dummy_list.append(param['fitresult']) + print("chisq = ", fit.chisq) + + # Plot + constant, slope = dummy_list[:2] + p1 = dummy_list[2:] + print(p1) + y2 = slope * x + constant + sum_gauss(x, *p1) + + try: + from silx.gui import qt + from silx.gui.plot.PlotWindow import PlotWindow + app = qt.QApplication([]) + pw = PlotWindow(control=True) + pw.addCurve(x, y, "Original") + pw.addCurve(x, y2, "Fit result") + pw.legendsDockWidget.show() + pw.show() + app.exec() + except ImportError: + _logger.warning("Could not import qt to display fit result as curve") + + +if __name__ == "__main__": + test() diff --git a/src/silx/math/fit/fittheories.py b/src/silx/math/fit/fittheories.py new file mode 100644 index 0000000..5461416 --- /dev/null +++ b/src/silx/math/fit/fittheories.py @@ -0,0 +1,1374 @@ +# coding: utf-8 +#/*########################################################################## +# +# Copyright (c) 2004-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +########################################################################### */ +"""This modules provides a set of fit functions and associated +estimation functions in a format that can be imported into a +:class:`silx.math.fit.FitManager` instance. + +These functions are well suited for fitting multiple gaussian shaped peaks +typically found in spectroscopy data. The estimation functions are designed +to detect how many peaks are present in the data, and provide an initial +estimate for their height, their center location and their full-width +at half maximum (fwhm). + +The limitation of these estimation algorithms is that only gaussians having a +similar fwhm can be detected by the peak search algorithm. +This *search fwhm* can be defined by the user, if +he knows the characteristics of his data, or can be automatically estimated +based on the fwhm of the largest peak in the data. + +The source code of this module can serve as template for defining your own +fit functions. + +The functions to be imported by :meth:`FitManager.loadtheories` are defined by +a dictionary :const:`THEORY`: with the following structure:: + + from silx.math.fit.fittheory import FitTheory + + THEORY = { + 'theory_name_1': FitTheory( + description='Description of theory 1', + function=fitfunction1, + parameters=('param name 1', 'param name 2', …), + estimate=estimation_function1, + configure=configuration_function1, + derivative=derivative_function1), + + 'theory_name_2': FitTheory(…), + } + +.. note:: + + Consider using an OrderedDict instead of a regular dictionary, when + defining your own theory dictionary, if the order matters to you. + This will likely be the case if you intend to load a selection of + functions in a GUI such as :class:`silx.gui.fit.FitManager`. + +Theory names can be customized (e.g. ``gauss, lorentz, splitgauss``…). + +The mandatory parameters for :class:`FitTheory` are ``function`` and +``parameters``. + +You can also define an ``INIT`` function that will be executed by +:meth:`FitManager.loadtheories`. + +See the documentation of :class:`silx.math.fit.fittheory.FitTheory` +for more information. + +Module members: +--------------- +""" +import numpy +from collections import OrderedDict +import logging + +from silx.math.fit import functions +from silx.math.fit.peaks import peak_search, guess_fwhm +from silx.math.fit.filters import strip, savitsky_golay +from silx.math.fit.leastsq import leastsq +from silx.math.fit.fittheory import FitTheory + +_logger = logging.getLogger(__name__) + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "15/05/2017" + + +DEFAULT_CONFIG = { + 'NoConstraintsFlag': False, + 'PositiveFwhmFlag': True, + 'PositiveHeightAreaFlag': True, + 'SameFwhmFlag': False, + 'QuotedPositionFlag': False, # peak not outside data range + 'QuotedEtaFlag': False, # force 0 < eta < 1 + # Peak detection + 'AutoScaling': False, + 'Yscaling': 1.0, + 'FwhmPoints': 8, + 'AutoFwhm': True, + 'Sensitivity': 2.5, + 'ForcePeakPresence': True, + # Hypermet + 'HypermetTails': 15, + 'QuotedFwhmFlag': 0, + 'MaxFwhm2InputRatio': 1.5, + 'MinFwhm2InputRatio': 0.4, + # short tail parameters + 'MinGaussArea4ShortTail': 50000., + 'InitialShortTailAreaRatio': 0.050, + 'MaxShortTailAreaRatio': 0.100, + 'MinShortTailAreaRatio': 0.0010, + 'InitialShortTailSlopeRatio': 0.70, + 'MaxShortTailSlopeRatio': 2.00, + 'MinShortTailSlopeRatio': 0.50, + # long tail parameters + 'MinGaussArea4LongTail': 1000.0, + 'InitialLongTailAreaRatio': 0.050, + 'MaxLongTailAreaRatio': 0.300, + 'MinLongTailAreaRatio': 0.010, + 'InitialLongTailSlopeRatio': 20.0, + 'MaxLongTailSlopeRatio': 50.0, + 'MinLongTailSlopeRatio': 5.0, + # step tail + 'MinGaussHeight4StepTail': 5000., + 'InitialStepTailHeightRatio': 0.002, + 'MaxStepTailHeightRatio': 0.0100, + 'MinStepTailHeightRatio': 0.0001, + # Hypermet constraints + # position in range [estimated position +- estimated fwhm/2] + 'HypermetQuotedPositionFlag': True, + 'DeltaPositionFwhmUnits': 0.5, + 'SameSlopeRatioFlag': 1, + 'SameAreaRatioFlag': 1, + # Strip bg removal + 'StripBackgroundFlag': True, + 'SmoothingFlag': True, + 'SmoothingWidth': 5, + 'StripWidth': 2, + 'StripIterations': 5000, + 'StripThresholdFactor': 1.0} +"""This dictionary defines default configuration parameters that have effects +on fit functions and estimation functions, mainly on fit constraints. +This dictionary is accessible as attribute :attr:`FitTheories.config`, +which can be modified by configuration functions defined in +:const:`CONFIGURE`. +""" + +CFREE = 0 +CPOSITIVE = 1 +CQUOTED = 2 +CFIXED = 3 +CFACTOR = 4 +CDELTA = 5 +CSUM = 6 +CIGNORED = 7 + + +class FitTheories(object): + """Class wrapping functions from :class:`silx.math.fit.functions` + and providing estimate functions for all of these fit functions.""" + def __init__(self, config=None): + if config is None: + self.config = DEFAULT_CONFIG + else: + self.config = config + + def ahypermet(self, x, *pars): + """ + Wrapping of :func:`silx.math.fit.functions.sum_ahypermet` without + the tail flags in the function signature. + + Depending on the value of `self.config['HypermetTails']`, one can + activate or deactivate the various terms of the hypermet function. + + `self.config['HypermetTails']` must be an integer between 0 and 15. + It is a set of 4 binary flags, one for activating each one of the + hypermet terms: *gaussian function, short tail, long tail, step*. + + For example, 15 can be expressed as ``1111`` in base 2, so a flag of + 15 means all terms are active. + """ + g_term = self.config['HypermetTails'] & 1 + st_term = (self.config['HypermetTails'] >> 1) & 1 + lt_term = (self.config['HypermetTails'] >> 2) & 1 + step_term = (self.config['HypermetTails'] >> 3) & 1 + return functions.sum_ahypermet(x, *pars, + gaussian_term=g_term, st_term=st_term, + lt_term=lt_term, step_term=step_term) + + def poly(self, x, *pars): + """Order n polynomial. + The order of the polynomial is defined by the number of + coefficients (``*pars``). + + """ + p = numpy.poly1d(pars) + return p(x) + + @staticmethod + def estimate_poly(x, y, n=2): + """Estimate polynomial coefficients for a degree n polynomial. + + """ + pcoeffs = numpy.polyfit(x, y, n) + constraints = numpy.zeros((n + 1, 3), numpy.float64) + return pcoeffs, constraints + + def estimate_quadratic(self, x, y): + """Estimate quadratic coefficients + + """ + return self.estimate_poly(x, y, n=2) + + def estimate_cubic(self, x, y): + """Estimate coefficients for a degree 3 polynomial + + """ + return self.estimate_poly(x, y, n=3) + + def estimate_quartic(self, x, y): + """Estimate coefficients for a degree 4 polynomial + + """ + return self.estimate_poly(x, y, n=4) + + def estimate_quintic(self, x, y): + """Estimate coefficients for a degree 5 polynomial + + """ + return self.estimate_poly(x, y, n=5) + + def strip_bg(self, y): + """Return the strip background of y, using parameters from + :attr:`config` dictionary (*StripBackgroundFlag, StripWidth, + StripIterations, StripThresholdFactor*)""" + remove_strip_bg = self.config.get('StripBackgroundFlag', False) + if remove_strip_bg: + if self.config['SmoothingFlag']: + y = savitsky_golay(y, self.config['SmoothingWidth']) + strip_width = self.config['StripWidth'] + strip_niterations = self.config['StripIterations'] + strip_thr_factor = self.config['StripThresholdFactor'] + return strip(y, w=strip_width, + niterations=strip_niterations, + factor=strip_thr_factor) + else: + return numpy.zeros_like(y) + + def guess_yscaling(self, y): + """Estimate scaling for y prior to peak search. + A smoothing filter is applied to y to estimate the noise level + (chi-squared) + + :param y: Data array + :return: Scaling factor + """ + # ensure y is an array + yy = numpy.array(y, copy=False) + + # smooth + convolution_kernel = numpy.ones(shape=(3,)) / 3. + ysmooth = numpy.convolve(y, convolution_kernel, mode="same") + + # remove zeros + idx_array = numpy.fabs(y) > 0.0 + yy = yy[idx_array] + ysmooth = ysmooth[idx_array] + + # compute scaling factor + chisq = numpy.mean((yy - ysmooth)**2 / numpy.fabs(yy)) + if chisq > 0: + return 1. / chisq + else: + return 1.0 + + def peak_search(self, y, fwhm, sensitivity): + """Search for peaks in y array, after padding the array and + multiplying its value by a scaling factor. + + :param y: 1-D data array + :param int fwhm: Typical full width at half maximum for peaks, + in number of points. This parameter is used for to discriminate between + true peaks and background fluctuations. + :param float sensitivity: Sensitivity parameter. This is a threshold factor + for peak detection. Only peaks larger than the standard deviation + of the noise multiplied by this sensitivity parameter are detected. + :return: List of peak indices + """ + # add padding + ysearch = numpy.ones((len(y) + 2 * fwhm,), numpy.float64) + ysearch[0:fwhm] = y[0] + ysearch[-1:-fwhm - 1:-1] = y[len(y)-1] + ysearch[fwhm:fwhm + len(y)] = y[:] + + scaling = self.guess_yscaling(y) if self.config["AutoScaling"] else self.config["Yscaling"] + + if len(ysearch) > 1.5 * fwhm: + peaks = peak_search(scaling * ysearch, + fwhm=fwhm, sensitivity=sensitivity) + return [peak_index - fwhm for peak_index in peaks + if 0 <= peak_index - fwhm < len(y)] + else: + return [] + + def estimate_height_position_fwhm(self, x, y): + """Estimation of *Height, Position, FWHM* of peaks, for gaussian-like + curves. + + This functions finds how many parameters are needed, based on the + number of peaks detected. Then it estimates the fit parameters + with a few iterations of fitting gaussian functions. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Height, Position, FWHM*. + Fit constraints depend on :attr:`config`. + """ + fittedpar = [] + + bg = self.strip_bg(y) + + if self.config['AutoFwhm']: + search_fwhm = guess_fwhm(y) + else: + search_fwhm = int(float(self.config['FwhmPoints'])) + search_sens = float(self.config['Sensitivity']) + + if search_fwhm < 3: + _logger.warning("Setting peak fwhm to 3 (lower limit)") + search_fwhm = 3 + self.config['FwhmPoints'] = 3 + + if search_sens < 1: + _logger.warning("Setting peak search sensitivity to 1. " + + "(lower limit to filter out noise peaks)") + search_sens = 1 + self.config['Sensitivity'] = 1 + + npoints = len(y) + + # Find indices of peaks in data array + peaks = self.peak_search(y, + fwhm=search_fwhm, + sensitivity=search_sens) + + if not len(peaks): + forcepeak = int(float(self.config.get('ForcePeakPresence', 0))) + if forcepeak: + delta = y - bg + # get index of global maximum + # (first one if several samples are equal to this value) + peaks = [numpy.nonzero(delta == delta.max())[0][0]] + + # Find index of largest peak in peaks array + index_largest_peak = 0 + if len(peaks) > 0: + # estimate fwhm as 5 * sampling interval + sig = 5 * abs(x[npoints - 1] - x[0]) / npoints + peakpos = x[int(peaks[0])] + if abs(peakpos) < 1.0e-16: + peakpos = 0.0 + param = numpy.array( + [y[int(peaks[0])] - bg[int(peaks[0])], peakpos, sig]) + height_largest_peak = param[0] + peak_index = 1 + for i in peaks[1:]: + param2 = numpy.array( + [y[int(i)] - bg[int(i)], x[int(i)], sig]) + param = numpy.concatenate((param, param2)) + if param2[0] > height_largest_peak: + height_largest_peak = param2[0] + index_largest_peak = peak_index + peak_index += 1 + + # Subtract background + xw = x + yw = y - bg + + cons = numpy.zeros((len(param), 3), numpy.float64) + + # peak height must be positive + cons[0:len(param):3, 0] = CPOSITIVE + # force peaks to stay around their position + cons[1:len(param):3, 0] = CQUOTED + + # set possible peak range to estimated peak +- guessed fwhm + if len(xw) > search_fwhm: + fwhmx = numpy.fabs(xw[int(search_fwhm)] - xw[0]) + cons[1:len(param):3, 1] = param[1:len(param):3] - 0.5 * fwhmx + cons[1:len(param):3, 2] = param[1:len(param):3] + 0.5 * fwhmx + else: + shape = [max(1, int(x)) for x in (param[1:len(param):3])] + cons[1:len(param):3, 1] = min(xw) * numpy.ones( + shape, + numpy.float64) + cons[1:len(param):3, 2] = max(xw) * numpy.ones( + shape, + numpy.float64) + + # ensure fwhm is positive + cons[2:len(param):3, 0] = CPOSITIVE + + # run a quick iterative fit (4 iterations) to improve + # estimations + fittedpar, _, _ = leastsq(functions.sum_gauss, xw, yw, param, + max_iter=4, constraints=cons.tolist(), + full_output=True) + + # set final constraints based on config parameters + cons = numpy.zeros((len(fittedpar), 3), numpy.float64) + peak_index = 0 + for i in range(len(peaks)): + # Setup height area constrains + if not self.config['NoConstraintsFlag']: + if self.config['PositiveHeightAreaFlag']: + cons[peak_index, 0] = CPOSITIVE + cons[peak_index, 1] = 0 + cons[peak_index, 2] = 0 + peak_index += 1 + + # Setup position constrains + if not self.config['NoConstraintsFlag']: + if self.config['QuotedPositionFlag']: + cons[peak_index, 0] = CQUOTED + cons[peak_index, 1] = min(x) + cons[peak_index, 2] = max(x) + peak_index += 1 + + # Setup positive FWHM constrains + if not self.config['NoConstraintsFlag']: + if self.config['PositiveFwhmFlag']: + cons[peak_index, 0] = CPOSITIVE + cons[peak_index, 1] = 0 + cons[peak_index, 2] = 0 + if self.config['SameFwhmFlag']: + if i != index_largest_peak: + cons[peak_index, 0] = CFACTOR + cons[peak_index, 1] = 3 * index_largest_peak + 2 + cons[peak_index, 2] = 1.0 + peak_index += 1 + + return fittedpar, cons + + def estimate_agauss(self, x, y): + """Estimation of *Area, Position, FWHM* of peaks, for gaussian-like + curves. + + This functions uses :meth:`estimate_height_position_fwhm`, then + converts the height parameters to area under the curve with the + formula ``area = sqrt(2*pi) * height * fwhm / (2 * sqrt(2 * log(2))`` + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Area, Position, FWHM*. + Fit constraints depend on :attr:`config`. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + # get the number of found peaks + npeaks = len(fittedpar) // 3 + for i in range(npeaks): + height = fittedpar[3 * i] + fwhm = fittedpar[3 * i + 2] + # Replace height with area in fittedpar + fittedpar[3 * i] = numpy.sqrt(2 * numpy.pi) * height * fwhm / ( + 2.0 * numpy.sqrt(2 * numpy.log(2))) + return fittedpar, cons + + def estimate_alorentz(self, x, y): + """Estimation of *Area, Position, FWHM* of peaks, for Lorentzian + curves. + + This functions uses :meth:`estimate_height_position_fwhm`, then + converts the height parameters to area under the curve with the + formula ``area = height * fwhm * 0.5 * pi`` + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Area, Position, FWHM*. + Fit constraints depend on :attr:`config`. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + # get the number of found peaks + npeaks = len(fittedpar) // 3 + for i in range(npeaks): + height = fittedpar[3 * i] + fwhm = fittedpar[3 * i + 2] + # Replace height with area in fittedpar + fittedpar[3 * i] = (height * fwhm * 0.5 * numpy.pi) + return fittedpar, cons + + def estimate_splitgauss(self, x, y): + """Estimation of *Height, Position, FWHM1, FWHM2* of peaks, for + asymmetric gaussian-like curves. + + This functions uses :meth:`estimate_height_position_fwhm`, then + adds a second (identical) estimation of FWHM to the fit parameters + for each peak, and the corresponding constraint. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Height, Position, FWHM1, FWHM2*. + Fit constraints depend on :attr:`config`. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + # get the number of found peaks + npeaks = len(fittedpar) // 3 + estimated_parameters = [] + estimated_constraints = numpy.zeros((4 * npeaks, 3), numpy.float64) + for i in range(npeaks): + for j in range(3): + estimated_parameters.append(fittedpar[3 * i + j]) + # fwhm2 estimate = fwhm1 + estimated_parameters.append(fittedpar[3 * i + 2]) + # height + estimated_constraints[4 * i, 0] = cons[3 * i, 0] + estimated_constraints[4 * i, 1] = cons[3 * i, 1] + estimated_constraints[4 * i, 2] = cons[3 * i, 2] + # position + estimated_constraints[4 * i + 1, 0] = cons[3 * i + 1, 0] + estimated_constraints[4 * i + 1, 1] = cons[3 * i + 1, 1] + estimated_constraints[4 * i + 1, 2] = cons[3 * i + 1, 2] + # fwhm1 + estimated_constraints[4 * i + 2, 0] = cons[3 * i + 2, 0] + estimated_constraints[4 * i + 2, 1] = cons[3 * i + 2, 1] + estimated_constraints[4 * i + 2, 2] = cons[3 * i + 2, 2] + # fwhm2 + estimated_constraints[4 * i + 3, 0] = cons[3 * i + 2, 0] + estimated_constraints[4 * i + 3, 1] = cons[3 * i + 2, 1] + estimated_constraints[4 * i + 3, 2] = cons[3 * i + 2, 2] + if cons[3 * i + 2, 0] == CFACTOR: + # convert indices of related parameters + # (this happens if SameFwhmFlag == True) + estimated_constraints[4 * i + 2, 1] = \ + int(cons[3 * i + 2, 1] / 3) * 4 + 2 + estimated_constraints[4 * i + 3, 1] = \ + int(cons[3 * i + 2, 1] / 3) * 4 + 3 + return estimated_parameters, estimated_constraints + + def estimate_pvoigt(self, x, y): + """Estimation of *Height, Position, FWHM, eta* of peaks, for + pseudo-Voigt curves. + + Pseudo-Voigt are a sum of a gaussian curve *G(x)* and a lorentzian + curve *L(x)* with the same height, center, fwhm parameters: + ``y(x) = eta * G(x) + (1-eta) * L(x)`` + + This functions uses :meth:`estimate_height_position_fwhm`, then + adds a constant estimation of *eta* (0.5) to the fit parameters + for each peak, and the corresponding constraint. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Height, Position, FWHM, eta*. + Constraint for the eta parameter can be set to QUOTED (0.--1.) + by setting :attr:`config`['QuotedEtaFlag'] to ``True``. + If this is not the case, the constraint code is set to FREE. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + npeaks = len(fittedpar) // 3 + newpar = [] + newcons = numpy.zeros((4 * npeaks, 3), numpy.float64) + # find out related parameters proper index + if not self.config['NoConstraintsFlag']: + if self.config['SameFwhmFlag']: + j = 0 + # get the index of the free FWHM + for i in range(npeaks): + if cons[3 * i + 2, 0] != 4: + j = i + for i in range(npeaks): + if i != j: + cons[3 * i + 2, 1] = 4 * j + 2 + for i in range(npeaks): + newpar.append(fittedpar[3 * i]) + newpar.append(fittedpar[3 * i + 1]) + newpar.append(fittedpar[3 * i + 2]) + newpar.append(0.5) + # height + newcons[4 * i, 0] = cons[3 * i, 0] + newcons[4 * i, 1] = cons[3 * i, 1] + newcons[4 * i, 2] = cons[3 * i, 2] + # position + newcons[4 * i + 1, 0] = cons[3 * i + 1, 0] + newcons[4 * i + 1, 1] = cons[3 * i + 1, 1] + newcons[4 * i + 1, 2] = cons[3 * i + 1, 2] + # fwhm + newcons[4 * i + 2, 0] = cons[3 * i + 2, 0] + newcons[4 * i + 2, 1] = cons[3 * i + 2, 1] + newcons[4 * i + 2, 2] = cons[3 * i + 2, 2] + # Eta constrains + newcons[4 * i + 3, 0] = CFREE + newcons[4 * i + 3, 1] = 0 + newcons[4 * i + 3, 2] = 0 + if self.config['QuotedEtaFlag']: + newcons[4 * i + 3, 0] = CQUOTED + newcons[4 * i + 3, 1] = 0.0 + newcons[4 * i + 3, 2] = 1.0 + return newpar, newcons + + def estimate_splitpvoigt(self, x, y): + """Estimation of *Height, Position, FWHM1, FWHM2, eta* of peaks, for + asymmetric pseudo-Voigt curves. + + This functions uses :meth:`estimate_height_position_fwhm`, then + adds an identical FWHM2 parameter and a constant estimation of + *eta* (0.5) to the fit parameters for each peak, and the corresponding + constraints. + + Constraint for the eta parameter can be set to QUOTED (0.--1.) + by setting :attr:`config`['QuotedEtaFlag'] to ``True``. + If this is not the case, the constraint code is set to FREE. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Height, Position, FWHM1, FWHM2, eta*. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + npeaks = len(fittedpar) // 3 + newpar = [] + newcons = numpy.zeros((5 * npeaks, 3), numpy.float64) + # find out related parameters proper index + if not self.config['NoConstraintsFlag']: + if self.config['SameFwhmFlag']: + j = 0 + # get the index of the free FWHM + for i in range(npeaks): + if cons[3 * i + 2, 0] != 4: + j = i + for i in range(npeaks): + if i != j: + cons[3 * i + 2, 1] = 4 * j + 2 + for i in range(npeaks): + # height + newpar.append(fittedpar[3 * i]) + # position + newpar.append(fittedpar[3 * i + 1]) + # fwhm1 + newpar.append(fittedpar[3 * i + 2]) + # fwhm2 estimate equal to fwhm1 + newpar.append(fittedpar[3 * i + 2]) + # eta + newpar.append(0.5) + # constraint codes + # ---------------- + # height + newcons[5 * i, 0] = cons[3 * i, 0] + # position + newcons[5 * i + 1, 0] = cons[3 * i + 1, 0] + # fwhm1 + newcons[5 * i + 2, 0] = cons[3 * i + 2, 0] + # fwhm2 + newcons[5 * i + 3, 0] = cons[3 * i + 2, 0] + # cons 1 + # ------ + newcons[5 * i, 1] = cons[3 * i, 1] + newcons[5 * i + 1, 1] = cons[3 * i + 1, 1] + newcons[5 * i + 2, 1] = cons[3 * i + 2, 1] + newcons[5 * i + 3, 1] = cons[3 * i + 2, 1] + # cons 2 + # ------ + newcons[5 * i, 2] = cons[3 * i, 2] + newcons[5 * i + 1, 2] = cons[3 * i + 1, 2] + newcons[5 * i + 2, 2] = cons[3 * i + 2, 2] + newcons[5 * i + 3, 2] = cons[3 * i + 2, 2] + + if cons[3 * i + 2, 0] == CFACTOR: + # fwhm2 connstraint depends on fwhm1 + newcons[5 * i + 3, 1] = newcons[5 * i + 2, 1] + 1 + # eta constraints + newcons[5 * i + 4, 0] = CFREE + newcons[5 * i + 4, 1] = 0 + newcons[5 * i + 4, 2] = 0 + if self.config['QuotedEtaFlag']: + newcons[5 * i + 4, 0] = CQUOTED + newcons[5 * i + 4, 1] = 0.0 + newcons[5 * i + 4, 2] = 1.0 + return newpar, newcons + + def estimate_apvoigt(self, x, y): + """Estimation of *Area, Position, FWHM1, eta* of peaks, for + pseudo-Voigt curves. + + This functions uses :meth:`estimate_pvoigt`, then converts the height + parameter to area. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Area, Position, FWHM, eta*. + """ + fittedpar, cons = self.estimate_pvoigt(x, y) + npeaks = len(fittedpar) // 4 + # Assume 50% of the area is determined by the gaussian and 50% by + # the Lorentzian. + for i in range(npeaks): + height = fittedpar[4 * i] + fwhm = fittedpar[4 * i + 2] + fittedpar[4 * i] = 0.5 * (height * fwhm * 0.5 * numpy.pi) +\ + 0.5 * (height * fwhm / (2.0 * numpy.sqrt(2 * numpy.log(2))) + ) * numpy.sqrt(2 * numpy.pi) + return fittedpar, cons + + def estimate_ahypermet(self, x, y): + """Estimation of *area, position, fwhm, st_area_r, st_slope_r, + lt_area_r, lt_slope_r, step_height_r* of peaks, for hypermet curves. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *area, position, fwhm, st_area_r, st_slope_r, + lt_area_r, lt_slope_r, step_height_r* . + """ + yscaling = self.config.get('Yscaling', 1.0) + if yscaling == 0: + yscaling = 1.0 + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + npeaks = len(fittedpar) // 3 + newpar = [] + newcons = numpy.zeros((8 * npeaks, 3), numpy.float64) + main_peak = 0 + # find out related parameters proper index + if not self.config['NoConstraintsFlag']: + if self.config['SameFwhmFlag']: + j = 0 + # get the index of the free FWHM + for i in range(npeaks): + if cons[3 * i + 2, 0] != 4: + j = i + for i in range(npeaks): + if i != j: + cons[3 * i + 2, 1] = 8 * j + 2 + main_peak = j + for i in range(npeaks): + if fittedpar[3 * i] > fittedpar[3 * main_peak]: + main_peak = i + + for i in range(npeaks): + height = fittedpar[3 * i] + position = fittedpar[3 * i + 1] + fwhm = fittedpar[3 * i + 2] + area = (height * fwhm / (2.0 * numpy.sqrt(2 * numpy.log(2))) + ) * numpy.sqrt(2 * numpy.pi) + # the gaussian parameters + newpar.append(area) + newpar.append(position) + newpar.append(fwhm) + # print "area, pos , fwhm = ",area,position,fwhm + # Avoid zero derivatives because of not calculating contribution + g_term = 1 + st_term = 1 + lt_term = 1 + step_term = 1 + if self.config['HypermetTails'] != 0: + g_term = self.config['HypermetTails'] & 1 + st_term = (self.config['HypermetTails'] >> 1) & 1 + lt_term = (self.config['HypermetTails'] >> 2) & 1 + step_term = (self.config['HypermetTails'] >> 3) & 1 + if g_term == 0: + # fix the gaussian parameters + newcons[8 * i, 0] = CFIXED + newcons[8 * i + 1, 0] = CFIXED + newcons[8 * i + 2, 0] = CFIXED + # the short tail parameters + if ((area * yscaling) < + self.config['MinGaussArea4ShortTail']) | \ + (st_term == 0): + newpar.append(0.0) + newpar.append(0.0) + newcons[8 * i + 3, 0] = CFIXED + newcons[8 * i + 3, 1] = 0.0 + newcons[8 * i + 3, 2] = 0.0 + newcons[8 * i + 4, 0] = CFIXED + newcons[8 * i + 4, 1] = 0.0 + newcons[8 * i + 4, 2] = 0.0 + else: + newpar.append(self.config['InitialShortTailAreaRatio']) + newpar.append(self.config['InitialShortTailSlopeRatio']) + newcons[8 * i + 3, 0] = CQUOTED + newcons[8 * i + 3, 1] = self.config['MinShortTailAreaRatio'] + newcons[8 * i + 3, 2] = self.config['MaxShortTailAreaRatio'] + newcons[8 * i + 4, 0] = CQUOTED + newcons[8 * i + 4, 1] = self.config['MinShortTailSlopeRatio'] + newcons[8 * i + 4, 2] = self.config['MaxShortTailSlopeRatio'] + # the long tail parameters + if ((area * yscaling) < + self.config['MinGaussArea4LongTail']) | \ + (lt_term == 0): + newpar.append(0.0) + newpar.append(0.0) + newcons[8 * i + 5, 0] = CFIXED + newcons[8 * i + 5, 1] = 0.0 + newcons[8 * i + 5, 2] = 0.0 + newcons[8 * i + 6, 0] = CFIXED + newcons[8 * i + 6, 1] = 0.0 + newcons[8 * i + 6, 2] = 0.0 + else: + newpar.append(self.config['InitialLongTailAreaRatio']) + newpar.append(self.config['InitialLongTailSlopeRatio']) + newcons[8 * i + 5, 0] = CQUOTED + newcons[8 * i + 5, 1] = self.config['MinLongTailAreaRatio'] + newcons[8 * i + 5, 2] = self.config['MaxLongTailAreaRatio'] + newcons[8 * i + 6, 0] = CQUOTED + newcons[8 * i + 6, 1] = self.config['MinLongTailSlopeRatio'] + newcons[8 * i + 6, 2] = self.config['MaxLongTailSlopeRatio'] + # the step parameters + if ((height * yscaling) < + self.config['MinGaussHeight4StepTail']) | \ + (step_term == 0): + newpar.append(0.0) + newcons[8 * i + 7, 0] = CFIXED + newcons[8 * i + 7, 1] = 0.0 + newcons[8 * i + 7, 2] = 0.0 + else: + newpar.append(self.config['InitialStepTailHeightRatio']) + newcons[8 * i + 7, 0] = CQUOTED + newcons[8 * i + 7, 1] = self.config['MinStepTailHeightRatio'] + newcons[8 * i + 7, 2] = self.config['MaxStepTailHeightRatio'] + # if self.config['NoConstraintsFlag'] == 1: + # newcons=numpy.zeros((8*npeaks, 3),numpy.float64) + if npeaks > 0: + if g_term: + if self.config['PositiveHeightAreaFlag']: + for i in range(npeaks): + newcons[8 * i, 0] = CPOSITIVE + if self.config['PositiveFwhmFlag']: + for i in range(npeaks): + newcons[8 * i + 2, 0] = CPOSITIVE + if self.config['SameFwhmFlag']: + for i in range(npeaks): + if i != main_peak: + newcons[8 * i + 2, 0] = CFACTOR + newcons[8 * i + 2, 1] = 8 * main_peak + 2 + newcons[8 * i + 2, 2] = 1.0 + if self.config['HypermetQuotedPositionFlag']: + for i in range(npeaks): + delta = self.config['DeltaPositionFwhmUnits'] * fwhm + newcons[8 * i + 1, 0] = CQUOTED + newcons[8 * i + 1, 1] = newpar[8 * i + 1] - delta + newcons[8 * i + 1, 2] = newpar[8 * i + 1] + delta + if self.config['SameSlopeRatioFlag']: + for i in range(npeaks): + if i != main_peak: + newcons[8 * i + 4, 0] = CFACTOR + newcons[8 * i + 4, 1] = 8 * main_peak + 4 + newcons[8 * i + 4, 2] = 1.0 + newcons[8 * i + 6, 0] = CFACTOR + newcons[8 * i + 6, 1] = 8 * main_peak + 6 + newcons[8 * i + 6, 2] = 1.0 + if self.config['SameAreaRatioFlag']: + for i in range(npeaks): + if i != main_peak: + newcons[8 * i + 3, 0] = CFACTOR + newcons[8 * i + 3, 1] = 8 * main_peak + 3 + newcons[8 * i + 3, 2] = 1.0 + newcons[8 * i + 5, 0] = CFACTOR + newcons[8 * i + 5, 1] = 8 * main_peak + 5 + newcons[8 * i + 5, 2] = 1.0 + return newpar, newcons + + def estimate_stepdown(self, x, y): + """Estimation of parameters for stepdown curves. + + The functions estimates gaussian parameters for the derivative of + the data, takes the largest gaussian peak and uses its estimated + parameters to define the center of the step and its fwhm. The + estimated amplitude returned is simply ``max(y) - min(y)``. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit newconstraints. + Parameters to be estimated for each stepdown are: + *height, centroid, fwhm* . + """ + crappyfilter = [-0.25, -0.75, 0.0, 0.75, 0.25] + cutoff = len(crappyfilter) // 2 + y_deriv = numpy.convolve(y, + crappyfilter, + mode="valid") + + # make the derivative's peak have the same amplitude as the step + if max(y_deriv) > 0: + y_deriv = y_deriv * max(y) / max(y_deriv) + + fittedpar, newcons = self.estimate_height_position_fwhm( + x[cutoff:-cutoff], y_deriv) + + data_amplitude = max(y) - min(y) + + # use parameters from largest gaussian found + if len(fittedpar): + npeaks = len(fittedpar) // 3 + largest_index = 0 + largest = [data_amplitude, + fittedpar[3 * largest_index + 1], + fittedpar[3 * largest_index + 2]] + for i in range(npeaks): + if fittedpar[3 * i] > largest[0]: + largest_index = i + largest = [data_amplitude, + fittedpar[3 * largest_index + 1], + fittedpar[3 * largest_index + 2]] + else: + # no peak was found + largest = [data_amplitude, # height + x[len(x)//2], # center: middle of x range + self.config["FwhmPoints"] * (x[1] - x[0])] # fwhm: default value + + # Setup constrains + newcons = numpy.zeros((3, 3), numpy.float64) + if not self.config['NoConstraintsFlag']: + # Setup height constrains + if self.config['PositiveHeightAreaFlag']: + newcons[0, 0] = CPOSITIVE + newcons[0, 1] = 0 + newcons[0, 2] = 0 + + # Setup position constrains + if self.config['QuotedPositionFlag']: + newcons[1, 0] = CQUOTED + newcons[1, 1] = min(x) + newcons[1, 2] = max(x) + + # Setup positive FWHM constrains + if self.config['PositiveFwhmFlag']: + newcons[2, 0] = CPOSITIVE + newcons[2, 1] = 0 + newcons[2, 2] = 0 + + return largest, newcons + + def estimate_slit(self, x, y): + """Estimation of parameters for slit curves. + + The functions estimates stepup and stepdown parameters for the largest + steps, and uses them for calculating the center (middle between stepup + and stepdown), the height (maximum amplitude in data), the fwhm + (distance between the up- and down-step centers) and the beamfwhm + (average of FWHM for up- and down-step). + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each slit are: + *height, position, fwhm, beamfwhm* . + """ + largestup, cons = self.estimate_stepup(x, y) + largestdown, cons = self.estimate_stepdown(x, y) + fwhm = numpy.fabs(largestdown[1] - largestup[1]) + beamfwhm = 0.5 * (largestup[2] + largestdown[1]) + beamfwhm = min(beamfwhm, fwhm / 10.0) + beamfwhm = max(beamfwhm, (max(x) - min(x)) * 3.0 / len(x)) + + y_minus_bg = y - self.strip_bg(y) + height = max(y_minus_bg) + + i1 = numpy.nonzero(y_minus_bg >= 0.5 * height)[0] + xx = numpy.take(x, i1) + position = (xx[0] + xx[-1]) / 2.0 + fwhm = xx[-1] - xx[0] + largest = [height, position, fwhm, beamfwhm] + cons = numpy.zeros((4, 3), numpy.float64) + # Setup constrains + if not self.config['NoConstraintsFlag']: + # Setup height constrains + if self.config['PositiveHeightAreaFlag']: + cons[0, 0] = CPOSITIVE + cons[0, 1] = 0 + cons[0, 2] = 0 + + # Setup position constrains + if self.config['QuotedPositionFlag']: + cons[1, 0] = CQUOTED + cons[1, 1] = min(x) + cons[1, 2] = max(x) + + # Setup positive FWHM constrains + if self.config['PositiveFwhmFlag']: + cons[2, 0] = CPOSITIVE + cons[2, 1] = 0 + cons[2, 2] = 0 + + # Setup positive FWHM constrains + if self.config['PositiveFwhmFlag']: + cons[3, 0] = CPOSITIVE + cons[3, 1] = 0 + cons[3, 2] = 0 + return largest, cons + + def estimate_stepup(self, x, y): + """Estimation of parameters for a single step up curve. + + The functions estimates gaussian parameters for the derivative of + the data, takes the largest gaussian peak and uses its estimated + parameters to define the center of the step and its fwhm. The + estimated amplitude returned is simply ``max(y) - min(y)``. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each stepup are: + *height, centroid, fwhm* . + """ + crappyfilter = [0.25, 0.75, 0.0, -0.75, -0.25] + cutoff = len(crappyfilter) // 2 + y_deriv = numpy.convolve(y, crappyfilter, mode="valid") + if max(y_deriv) > 0: + y_deriv = y_deriv * max(y) / max(y_deriv) + + fittedpar, cons = self.estimate_height_position_fwhm( + x[cutoff:-cutoff], y_deriv) + + # for height, use the data amplitude after removing the background + data_amplitude = max(y) - min(y) + + # find params of the largest gaussian found + if len(fittedpar): + npeaks = len(fittedpar) // 3 + largest_index = 0 + largest = [data_amplitude, + fittedpar[3 * largest_index + 1], + fittedpar[3 * largest_index + 2]] + for i in range(npeaks): + if fittedpar[3 * i] > largest[0]: + largest_index = i + largest = [fittedpar[3 * largest_index], + fittedpar[3 * largest_index + 1], + fittedpar[3 * largest_index + 2]] + else: + # no peak was found + largest = [data_amplitude, # height + x[len(x)//2], # center: middle of x range + self.config["FwhmPoints"] * (x[1] - x[0])] # fwhm: default value + + newcons = numpy.zeros((3, 3), numpy.float64) + # Setup constrains + if not self.config['NoConstraintsFlag']: + # Setup height constraints + if self.config['PositiveHeightAreaFlag']: + newcons[0, 0] = CPOSITIVE + newcons[0, 1] = 0 + newcons[0, 2] = 0 + + # Setup position constraints + if self.config['QuotedPositionFlag']: + newcons[1, 0] = CQUOTED + newcons[1, 1] = min(x) + newcons[1, 2] = max(x) + + # Setup positive FWHM constraints + if self.config['PositiveFwhmFlag']: + newcons[2, 0] = CPOSITIVE + newcons[2, 1] = 0 + newcons[2, 2] = 0 + + return largest, newcons + + def estimate_periodic_gauss(self, x, y): + """Estimation of parameters for periodic gaussian curves: + *number of peaks, distance between peaks, height, position of the + first peak, fwhm* + + The functions detects all peaks, then computes the parameters the + following way: + + - *distance*: average of distances between detected peaks + - *height*: average height of detected peaks + - *fwhm*: fwhm of the highest peak (in number of samples) if + field ``'AutoFwhm'`` in :attr:`config` is ``True``, else take + the default value (field ``'FwhmPoints'`` in :attr:`config`) + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + """ + yscaling = self.config.get('Yscaling', 1.0) + if yscaling == 0: + yscaling = 1.0 + + bg = self.strip_bg(y) + + if self.config['AutoFwhm']: + search_fwhm = guess_fwhm(y) + else: + search_fwhm = int(float(self.config['FwhmPoints'])) + search_sens = float(self.config['Sensitivity']) + + if search_fwhm < 3: + search_fwhm = 3 + + if search_sens < 1: + search_sens = 1 + + if len(y) > 1.5 * search_fwhm: + peaks = peak_search(yscaling * y, fwhm=search_fwhm, + sensitivity=search_sens) + else: + peaks = [] + npeaks = len(peaks) + if not npeaks: + fittedpar = [] + cons = numpy.zeros((len(fittedpar), 3), numpy.float64) + return fittedpar, cons + + fittedpar = [0.0, 0.0, 0.0, 0.0, 0.0] + + # The number of peaks + fittedpar[0] = npeaks + + # The separation between peaks in x units + delta = 0.0 + height = 0.0 + for i in range(npeaks): + height += y[int(peaks[i])] - bg[int(peaks[i])] + if i != npeaks - 1: + delta += (x[int(peaks[i + 1])] - x[int(peaks[i])]) + + # delta between peaks + if npeaks > 1: + fittedpar[1] = delta / (npeaks - 1) + + # starting height + fittedpar[2] = height / npeaks + + # position of the first peak + fittedpar[3] = x[int(peaks[0])] + + # Estimate the fwhm + fittedpar[4] = search_fwhm + + # setup constraints + cons = numpy.zeros((5, 3), numpy.float64) + cons[0, 0] = CFIXED # the number of gaussians + if npeaks == 1: + cons[1, 0] = CFIXED # the delta between peaks + else: + cons[1, 0] = CFREE + j = 2 + # Setup height area constrains + if not self.config['NoConstraintsFlag']: + if self.config['PositiveHeightAreaFlag']: + # POSITIVE = 1 + cons[j, 0] = CPOSITIVE + cons[j, 1] = 0 + cons[j, 2] = 0 + j += 1 + + # Setup position constrains + if not self.config['NoConstraintsFlag']: + if self.config['QuotedPositionFlag']: + # QUOTED = 2 + cons[j, 0] = CQUOTED + cons[j, 1] = min(x) + cons[j, 2] = max(x) + j += 1 + + # Setup positive FWHM constrains + if not self.config['NoConstraintsFlag']: + if self.config['PositiveFwhmFlag']: + # POSITIVE=1 + cons[j, 0] = CPOSITIVE + cons[j, 1] = 0 + cons[j, 2] = 0 + j += 1 + return fittedpar, cons + + def configure(self, **kw): + """Add new / unknown keyword arguments to :attr:`config`, + update entries in :attr:`config` if the parameter name is a existing + key. + + :param kw: Dictionary of keyword arguments. + :return: Configuration dictionary :attr:`config` + """ + if not kw.keys(): + return self.config + for key in kw.keys(): + notdone = 1 + # take care of lower / upper case problems ... + for config_key in self.config.keys(): + if config_key.lower() == key.lower(): + self.config[config_key] = kw[key] + notdone = 0 + if notdone: + self.config[key] = kw[key] + return self.config + +fitfuns = FitTheories() + +THEORY = OrderedDict(( + ('Gaussians', + FitTheory(description='Gaussian functions', + function=functions.sum_gauss, + parameters=('Height', 'Position', 'FWHM'), + estimate=fitfuns.estimate_height_position_fwhm, + configure=fitfuns.configure)), + ('Lorentz', + FitTheory(description='Lorentzian functions', + function=functions.sum_lorentz, + parameters=('Height', 'Position', 'FWHM'), + estimate=fitfuns.estimate_height_position_fwhm, + configure=fitfuns.configure)), + ('Area Gaussians', + FitTheory(description='Gaussian functions (area)', + function=functions.sum_agauss, + parameters=('Area', 'Position', 'FWHM'), + estimate=fitfuns.estimate_agauss, + configure=fitfuns.configure)), + ('Area Lorentz', + FitTheory(description='Lorentzian functions (area)', + function=functions.sum_alorentz, + parameters=('Area', 'Position', 'FWHM'), + estimate=fitfuns.estimate_alorentz, + configure=fitfuns.configure)), + ('Pseudo-Voigt Line', + FitTheory(description='Pseudo-Voigt functions', + function=functions.sum_pvoigt, + parameters=('Height', 'Position', 'FWHM', 'Eta'), + estimate=fitfuns.estimate_pvoigt, + configure=fitfuns.configure)), + ('Area Pseudo-Voigt', + FitTheory(description='Pseudo-Voigt functions (area)', + function=functions.sum_apvoigt, + parameters=('Area', 'Position', 'FWHM', 'Eta'), + estimate=fitfuns.estimate_apvoigt, + configure=fitfuns.configure)), + ('Split Gaussian', + FitTheory(description='Asymmetric gaussian functions', + function=functions.sum_splitgauss, + parameters=('Height', 'Position', 'LowFWHM', + 'HighFWHM'), + estimate=fitfuns.estimate_splitgauss, + configure=fitfuns.configure)), + ('Split Lorentz', + FitTheory(description='Asymmetric lorentzian functions', + function=functions.sum_splitlorentz, + parameters=('Height', 'Position', 'LowFWHM', 'HighFWHM'), + estimate=fitfuns.estimate_splitgauss, + configure=fitfuns.configure)), + ('Split Pseudo-Voigt', + FitTheory(description='Asymmetric pseudo-Voigt functions', + function=functions.sum_splitpvoigt, + parameters=('Height', 'Position', 'LowFWHM', + 'HighFWHM', 'Eta'), + estimate=fitfuns.estimate_splitpvoigt, + configure=fitfuns.configure)), + ('Step Down', + FitTheory(description='Step down function', + function=functions.sum_stepdown, + parameters=('Height', 'Position', 'FWHM'), + estimate=fitfuns.estimate_stepdown, + configure=fitfuns.configure)), + ('Step Up', + FitTheory(description='Step up function', + function=functions.sum_stepup, + parameters=('Height', 'Position', 'FWHM'), + estimate=fitfuns.estimate_stepup, + configure=fitfuns.configure)), + ('Slit', + FitTheory(description='Slit function', + function=functions.sum_slit, + parameters=('Height', 'Position', 'FWHM', 'BeamFWHM'), + estimate=fitfuns.estimate_slit, + configure=fitfuns.configure)), + ('Atan', + FitTheory(description='Arctan step up function', + function=functions.atan_stepup, + parameters=('Height', 'Position', 'Width'), + estimate=fitfuns.estimate_stepup, + configure=fitfuns.configure)), + ('Hypermet', + FitTheory(description='Hypermet functions', + function=fitfuns.ahypermet, # customized version of functions.sum_ahypermet + parameters=('G_Area', 'Position', 'FWHM', 'ST_Area', + 'ST_Slope', 'LT_Area', 'LT_Slope', 'Step_H'), + estimate=fitfuns.estimate_ahypermet, + configure=fitfuns.configure)), + # ('Periodic Gaussians', + # FitTheory(description='Periodic gaussian functions', + # function=functions.periodic_gauss, + # parameters=('N', 'Delta', 'Height', 'Position', 'FWHM'), + # estimate=fitfuns.estimate_periodic_gauss, + # configure=fitfuns.configure)) + ('Degree 2 Polynomial', + FitTheory(description='Degree 2 polynomial' + '\ny = a*x^2 + b*x +c', + function=fitfuns.poly, + parameters=['a', 'b', 'c'], + estimate=fitfuns.estimate_quadratic)), + ('Degree 3 Polynomial', + FitTheory(description='Degree 3 polynomial' + '\ny = a*x^3 + b*x^2 + c*x + d', + function=fitfuns.poly, + parameters=['a', 'b', 'c', 'd'], + estimate=fitfuns.estimate_cubic)), + ('Degree 4 Polynomial', + FitTheory(description='Degree 4 polynomial' + '\ny = a*x^4 + b*x^3 + c*x^2 + d*x + e', + function=fitfuns.poly, + parameters=['a', 'b', 'c', 'd', 'e'], + estimate=fitfuns.estimate_quartic)), + ('Degree 5 Polynomial', + FitTheory(description='Degree 5 polynomial' + '\ny = a*x^5 + b*x^4 + c*x^3 + d*x^2 + e*x + f', + function=fitfuns.poly, + parameters=['a', 'b', 'c', 'd', 'e', 'f'], + estimate=fitfuns.estimate_quintic)), +)) +"""Dictionary of fit theories: fit functions and their associated estimation +function, parameters list, configuration function and description. +""" + + +def test(a): + from silx.math.fit import fitmanager + x = numpy.arange(1000).astype(numpy.float64) + p = [1500, 100., 50.0, + 1500, 700., 50.0] + y_synthetic = functions.sum_gauss(x, *p) + 1 + + fit = fitmanager.FitManager(x, y_synthetic) + fit.addtheory('Gaussians', functions.sum_gauss, ['Height', 'Position', 'FWHM'], + a.estimate_height_position_fwhm) + fit.settheory('Gaussians') + fit.setbackground('Linear') + + fit.estimate() + fit.runfit() + + y_fit = fit.gendata() + + print("Fit parameter names: %s" % str(fit.get_names())) + print("Theoretical parameters: %s" % str(numpy.append([1, 0], p))) + print("Fitted parameters: %s" % str(fit.get_fitted_parameters())) + + try: + from silx.gui import qt + from silx.gui.plot import plot1D + app = qt.QApplication([]) + + # Offset of 1 to see the difference in log scale + plot1D(x, (y_synthetic + 1, y_fit), "Input data + 1, Fit") + + app.exec() + except ImportError: + _logger.warning("Unable to load qt binding, can't plot results.") + + +if __name__ == "__main__": + test(fitfuns) diff --git a/src/silx/math/fit/fittheory.py b/src/silx/math/fit/fittheory.py new file mode 100644 index 0000000..fa42e6b --- /dev/null +++ b/src/silx/math/fit/fittheory.py @@ -0,0 +1,161 @@ +# coding: utf-8 +#/*########################################################################## +# +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +########################################################################### */ +""" +This module defines the :class:`FitTheory` object that is used by +:class:`silx.math.fit.FitManager` to define fit functions and background +models. +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "09/08/2016" + + +class FitTheory(object): + """This class defines a fit theory, which consists of: + + - a model function, the actual function to be fitted + - parameters names + - an estimation function, that return the estimated initial parameters + that serve as input for :func:`silx.math.fit.leastsq` + - an optional configuration function, that can be used to modify + configuration parameters to alter the behavior of the fit function + and the estimation function + - an optional derivative function, that replaces the default model + derivative used in :func:`silx.math.fit.leastsq` + """ + def __init__(self, function, parameters, + estimate=None, configure=None, derivative=None, + description=None, pymca_legacy=False, is_background=False): + """ + :param function function: Actual function. See documentation for + :attr:`function`. + :param list[str] parameters: List of parameter names for the function. + See documentation for :attr:`parameters`. + :param function estimate: Optional estimation function. + See documentation for :attr:`estimate` + :param function configure: Optional configuration function. + See documentation for :attr:`configure` + :param function derivative: Optional custom derivative function. + See documentation for :attr:`derivative` + :param str description: Optional description string. + See documentation for :attr:`description` + :param bool pymca_legacy: Flag to indicate that the theory is a PyMca + legacy theory. See documentation for :attr:`pymca_legacy` + :param bool is_background: Flag to indicate that the theory is a + background theory. This has implications regarding the function's + signature, as explained in the documentation for :attr:`function`. + """ + self.function = function + """Regular fit functions must have the signature ``f(x, *params) -> y``, + where *x* is a 1D array of values for the independent variable, + *params* are the parameters to be fitted and *y* is the output array + that we want to have the best fit to a series of data points. + + Background functions used by :class:`FitManager` must have a slightly + different signature: ``f(x, y0, *params) -> bg``, where *y0* is the + array of original data points and *bg* is the background signal that + we want to subtract from the data array prior to fitting the regular + fit function. + + The number of parameters must be the same as in :attr:`parameters`, or + a multiple of this number if the function is defined as a sum of a + variable number of base functions and if :attr:`estimate` is designed + to be able to estimate the number of needed base functions. + """ + + self.parameters = parameters + """List of parameters names. + + This list can contain the minimum number of parameters, if the + function takes a variable number of parameters, + and if the estimation function is responsible for finding the number + of required parameters """ + + self.estimate = estimate + """The estimation function should have the following signature:: + + f(x, y) -> (estimated_param, constraints) + + Parameters: + + - ``x`` is a sequence of values for the independent variable + - ``y`` is a sequence of the same length as ``x`` containing the + data to be fitted + + Return values: + + - ``estimated_param`` is a sequence of estimated fit parameters to + be used as initial values for an iterative fit. + - ``constraints`` is a sequence of shape *(n, 3)*, where *n* is the + number of estimated parameters, containing the constraints for each + parameter to be fitted. See :func:`silx.math.fit.leastsq` for more + explanations about constraints.""" + if estimate is None: + self.estimate = self.default_estimate + + self.configure = configure + """The optional configuration function must conform to the signature + ``f(**kw) -> dict`` (i.e it must accept any named argument and + return a dictionary). + It can be used to modify configuration parameters to alter the + behavior of the fit function and the estimation function.""" + + self.derivative = derivative + """The optional derivative function must conform to the signature + ``model_deriv(xdata, parameters, index)``, where parameters is a + sequence with the current values of the fitting parameters, index is + the fitting parameter index for which the the derivative has to be + provided in the supplied array of xdata points.""" + + self.description = description + """Optional description string for this particular fit theory.""" + + self.pymca_legacy = pymca_legacy + """This attribute can be set to *True* to indicate that the theory + is a PyMca legacy theory. + + This tells :mod:`silx.math.fit.fitmanager` that the signature of + the estimate function is:: + + f(x, y, bg, xscaling, yscaling) -> (estimated_param, constraints) + """ + + self.is_background = is_background + """Flag to indicate that the theory is background theory. + + A background function is an secondary function that needs to be added + to the main fit function to better fit the original data. + If this flag is set to *True*, modules using this theory are informed + that :attr:`function` has the signature ``f(x, y0, *params) -> bg``, + instead of the usual fit function signature.""" + + def default_estimate(self, x=None, y=None, bg=None): + """Default estimate function. Return an array of *ones* as the + initial estimated parameters, and set all constraints to zero + (FREE)""" + estimated_parameters = [1. for _ in self.parameters] + estimated_constraints = [[0, 0, 0] for _ in self.parameters] + return estimated_parameters, estimated_constraints diff --git a/src/silx/math/fit/functions.pyx b/src/silx/math/fit/functions.pyx new file mode 100644 index 0000000..1f78563 --- /dev/null +++ b/src/silx/math/fit/functions.pyx @@ -0,0 +1,985 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +"""This module provides fit functions. + +List of fit functions: +----------------------- + + - :func:`sum_gauss` + - :func:`sum_agauss` + - :func:`sum_splitgauss` + - :func:`sum_fastagauss` + + - :func:`sum_apvoigt` + - :func:`sum_pvoigt` + - :func:`sum_splitpvoigt` + + - :func:`sum_lorentz` + - :func:`sum_alorentz` + - :func:`sum_splitlorentz` + + - :func:`sum_stepdown` + - :func:`sum_stepup` + - :func:`sum_slit` + + - :func:`sum_ahypermet` + - :func:`sum_fastahypermet` + +Full documentation: +------------------- + +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "16/08/2017" + +import logging +import numpy + +_logger = logging.getLogger(__name__) + +cimport cython +cimport silx.math.fit.functions_wrapper as functions_wrapper + + +def erf(x): + """Return the gaussian error function + + :param x: Independent variable where the gaussian error function is + calculated + :type x: numpy.ndarray or scalar + :return: Gaussian error function ``y=erf(x)`` + :raise: IndexError if ``x`` is an empty array + """ + cdef: + double[::1] x_c + double[::1] y_c + + + # force list into numpy array + if not hasattr(x, "shape"): + x = numpy.asarray(x) + + for len_dim in x.shape: + if len_dim == 0: + raise IndexError("Cannot compute erf for an empty array") + + x_c = numpy.array(x, copy=False, dtype=numpy.float64, order='C').reshape(-1) + y_c = numpy.empty(shape=(x_c.size,), dtype=numpy.float64) + + status = functions_wrapper.erf_array(&x_c[0], x_c.size, &y_c[0]) + + return numpy.asarray(y_c).reshape(x.shape) + + +def erfc(x): + """Return the gaussian complementary error function + + :param x: Independent variable where the gaussian complementary error + function is calculated + :type x: numpy.ndarray or scalar + :return: Gaussian complementary error function ``y=erfc(x)`` + :type rtype: numpy.ndarray + :raise: IndexError if ``x`` is an empty array + """ + cdef: + double[::1] x_c + double[::1] y_c + + # force list into numpy array + if not hasattr(x, "shape"): + x = numpy.asarray(x) + + for len_dim in x.shape: + if len_dim == 0: + raise IndexError("Cannot compute erfc for an empty array") + + x_c = numpy.array(x, copy=False, dtype=numpy.float64, order='C').reshape(-1) + y_c = numpy.empty(shape=(x_c.size,), dtype=numpy.float64) + + status = functions_wrapper.erfc_array(&x_c[0], x_c.size, &y_c[0]) + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_gauss(x, *params): + """Return a sum of gaussian functions defined by *(height, centroid, fwhm)*, + where: + + - *height* is the peak amplitude + - *centroid* is the peak x-coordinate + - *fwhm* is the full-width at half maximum + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of gaussian parameters (length must be a multiple + of 3): + *(height1, centroid1, fwhm1, height2, centroid2, fwhm2,...)* + :return: Array of sum of gaussian functions at each ``x`` coordinate. + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No gaussian parameters specified. " + + "At least 3 parameters are required.") + + # ensure float64 (double) type and 1D contiguous data layout in memory + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_gauss( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + # reshape y_c to match original, possibly unusual, data shape + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_agauss(x, *params): + """Return a sum of gaussian functions defined by *(area, centroid, fwhm)*, + where: + + - *area* is the area underneath the peak + - *centroid* is the peak x-coordinate + - *fwhm* is the full-width at half maximum + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of gaussian parameters (length must be a multiple + of 3): + *(area1, centroid1, fwhm1, area2, centroid2, fwhm2,...)* + :return: Array of sum of gaussian functions at each ``x`` coordinate. + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No gaussian parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_agauss( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_fastagauss(x, *params): + """Return a sum of gaussian functions defined by *(area, centroid, fwhm)*, + where: + + - *area* is the area underneath the peak + - *centroid* is the peak x-coordinate + - *fwhm* is the full-width at half maximum + + This implementation differs from :func:`sum_agauss` by the usage of a + lookup table with precalculated exponential values. This might speed up + the computation for large numbers of individual gaussian functions. + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of gaussian parameters (length must be a multiple + of 3): + *(area1, centroid1, fwhm1, area2, centroid2, fwhm2,...)* + :return: Array of sum of gaussian functions at each ``x`` coordinate. + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No gaussian parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_fastagauss( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_splitgauss(x, *params): + """Return a sum of gaussian functions defined by *(area, centroid, fwhm1, fwhm2)*, + where: + + - *height* is the peak amplitude + - *centroid* is the peak x-coordinate + - *fwhm1* is the full-width at half maximum for the distribution + when ``x < centroid`` + - *fwhm2* is the full-width at half maximum for the distribution + when ``x > centroid`` + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of gaussian parameters (length must be a multiple + of 4): + *(height1, centroid1, fwhm11, fwhm21, height2, centroid2, fwhm12, fwhm22,...)* + :return: Array of sum of split gaussian functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No gaussian parameters specified. " + + "At least 4 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_splitgauss( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_apvoigt(x, *params): + """Return a sum of pseudo-Voigt functions, defined by *(area, centroid, fwhm, + eta)*. + + The pseudo-Voigt profile ``PV(x)`` is an approximation of the Voigt + profile using a linear combination of a Gaussian curve ``G(x)`` and a + Lorentzian curve ``L(x)`` instead of their convolution. + + - *area* is the area underneath both G(x) and L(x) + - *centroid* is the peak x-coordinate for both functions + - *fwhm* is the full-width at half maximum of both functions + - *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of pseudo-Voigt parameters (length must be a multiple + of 4): + *(area1, centroid1, fwhm1, eta1, area2, centroid2, fwhm2, eta2,...)* + :return: Array of sum of pseudo-Voigt functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 4 parameters are required.") + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_apvoigt( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_pvoigt(x, *params): + """Return a sum of pseudo-Voigt functions, defined by *(height, centroid, + fwhm, eta)*. + + The pseudo-Voigt profile ``PV(x)`` is an approximation of the Voigt + profile using a linear combination of a Gaussian curve ``G(x)`` and a + Lorentzian curve ``L(x)`` instead of their convolution. + + - *height* is the peak amplitude of G(x) and L(x) + - *centroid* is the peak x-coordinate for both functions + - *fwhm* is the full-width at half maximum of both functions + - *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of pseudo-Voigt parameters (length must be a multiple + of 4): + *(height1, centroid1, fwhm1, eta1, height2, centroid2, fwhm2, eta2,...)* + :return: Array of sum of pseudo-Voigt functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 4 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_pvoigt( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_splitpvoigt(x, *params): + """Return a sum of split pseudo-Voigt functions, defined by *(height, + centroid, fwhm1, fwhm2, eta)*. + + The pseudo-Voigt profile ``PV(x)`` is an approximation of the Voigt + profile using a linear combination of a Gaussian curve ``G(x)`` and a + Lorentzian curve ``L(x)`` instead of their convolution. + + - *height* is the peak amplitudefor G(x) and L(x) + - *centroid* is the peak x-coordinate for both functions + - *fwhm1* is the full-width at half maximum of both functions + when ``x < centroid`` + - *fwhm2* is the full-width at half maximum of both functions + when ``x > centroid`` + - *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of pseudo-Voigt parameters (length must be a multiple + of 5): + *(height1, centroid1, fwhm11, fwhm21, eta1,...)* + :return: Array of sum of split pseudo-Voigt functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 5 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_splitpvoigt( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_lorentz(x, *params): + """Return a sum of Lorentz distributions, also known as Cauchy distribution, + defined by *(height, centroid, fwhm)*. + + - *height* is the peak amplitude + - *centroid* is the peak x-coordinate + - *fwhm* is the full-width at half maximum + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of Lorentz parameters (length must be a multiple + of 3): + *(height1, centroid1, fwhm1,...)* + :return: Array of sum Lorentz functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_lorentz( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_alorentz(x, *params): + """Return a sum of Lorentz distributions, also known as Cauchy distribution, + defined by *(area, centroid, fwhm)*. + + - *area* is the area underneath the peak + - *centroid* is the peak x-coordinate for both functions + - *fwhm* is the full-width at half maximum + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of Lorentz parameters (length must be a multiple + of 3): + *(area1, centroid1, fwhm1,...)* + :return: Array of sum of Lorentz functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_alorentz( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_splitlorentz(x, *params): + """Return a sum of split Lorentz distributions, + defined by *(height, centroid, fwhm1, fwhm2)*. + + - *height* is the peak amplitude + - *centroid* is the peak x-coordinate for both functions + - *fwhm1* is the full-width at half maximum for ``x < centroid`` + - *fwhm2* is the full-width at half maximum for ``x > centroid`` + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of Lorentz parameters (length must be a multiple + of 4): + *(height1, centroid1, fwhm11, fwhm21...)* + :return: Array of sum of Lorentz functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 4 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_splitlorentz( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_stepdown(x, *params): + """Return a sum of stepdown functions. + defined by *(height, centroid, fwhm)*. + + - *height* is the step's amplitude + - *centroid* is the step's x-coordinate + - *fwhm* is the full-width at half maximum for the derivative, + which is a measure of the *sharpness* of the step-down's edge + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of stepdown parameters (length must be a multiple + of 3): + *(height1, centroid1, fwhm1,...)* + :return: Array of sum of stepdown functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 3 parameters are required.") + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_stepdown(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_stepup(x, *params): + """Return a sum of stepup functions. + defined by *(height, centroid, fwhm)*. + + - *height* is the step's amplitude + - *centroid* is the step's x-coordinate + - *fwhm* is the full-width at half maximum for the derivative, + which is a measure of the *sharpness* of the step-up's edge + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of stepup parameters (length must be a multiple + of 3): + *(height1, centroid1, fwhm1,...)* + :return: Array of sum of stepup functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_stepup(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_slit(x, *params): + """Return a sum of slit functions. + defined by *(height, position, fwhm, beamfwhm)*. + + - *height* is the slit's amplitude + - *position* is the center of the slit's x-coordinate + - *fwhm* is the full-width at half maximum of the slit + - *beamfwhm* is the full-width at half maximum of the + derivative, which is a measure of the *sharpness* + of the edges of the slit + + :param x: Independent variable where the slits are calculated + :type x: numpy.ndarray + :param params: Array of slit parameters (length must be a multiple + of 4): + *(height1, centroid1, fwhm1, beamfwhm1,...)* + :return: Array of sum of slit functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 4 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_slit(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_ahypermet(x, *params, + gaussian_term=True, st_term=True, lt_term=True, step_term=True): + """Return a sum of ahypermet functions. + defined by *(area, position, fwhm, st_area_r, st_slope_r, lt_area_r, + lt_slope_r, step_height_r)*. + + - *area* is the area underneath the gaussian peak + - *position* is the center of the various peaks and the position of + the step down + - *fwhm* is the full-width at half maximum of the terms + - *st_area_r* is factor between the gaussian area and the area of the + short tail term + - *st_slope_r* is a ratio related to the slope of the short tail + in the low ``x`` values (the lower, the steeper) + - *lt_area_r* is ratio between the gaussian area and the area of the + long tail term + - *lt_slope_r* is a ratio related to the slope of the long tail + in the low ``x`` values (the lower, the steeper) + - *step_height_r* is the ratio between the height of the step down + and the gaussian height + + A hypermet function is a sum of four functions (terms): + + - a gaussian term + - a long tail term + - a short tail term + - a step down term + + :param x: Independent variable where the hypermets are calculated + :type x: numpy.ndarray + :param params: Array of hypermet parameters (length must be a multiple + of 8): + *(area1, position1, fwhm1, st_area_r1, st_slope_r1, lt_area_r1, + lt_slope_r1, step_height_r1...)* + :param gaussian_term: If ``True``, enable gaussian term. Default ``True`` + :param st_term: If ``True``, enable gaussian term. Default ``True`` + :param lt_term: If ``True``, enable gaussian term. Default ``True`` + :param step_term: If ``True``, enable gaussian term. Default ``True`` + :return: Array of sum of hypermet functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 8 parameters are required.") + + # Sum binary flags to activate various terms of the equation + tail_flags = 1 if gaussian_term else 0 + if st_term: + tail_flags += 2 + if lt_term: + tail_flags += 4 + if step_term: + tail_flags += 8 + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_ahypermet(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0], + tail_flags) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_fastahypermet(x, *params, + gaussian_term=True, st_term=True, + lt_term=True, step_term=True): + """Return a sum of hypermet functions defined by *(area, position, fwhm, + st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r)*. + + - *area* is the area underneath the gaussian peak + - *position* is the center of the various peaks and the position of + the step down + - *fwhm* is the full-width at half maximum of the terms + - *st_area_r* is factor between the gaussian area and the area of the + short tail term + - *st_slope_r* is a parameter related to the slope of the short tail + in the low ``x`` values (the lower, the steeper) + - *lt_area_r* is factor between the gaussian area and the area of the + long tail term + - *lt_slope_r* is a parameter related to the slope of the long tail + in the low ``x`` values (the lower, the steeper) + - *step_height_r* is the factor between the height of the step down + and the gaussian height + + A hypermet function is a sum of four functions (terms): + + - a gaussian term + - a long tail term + - a short tail term + - a step down term + + This function differs from :func:`sum_ahypermet` by the use of a lookup + table for calculating exponentials. This offers better performance when + calculating many functions for large ``x`` arrays. + + :param x: Independent variable where the hypermets are calculated + :type x: numpy.ndarray + :param params: Array of hypermet parameters (length must be a multiple + of 8): + *(area1, position1, fwhm1, st_area_r1, st_slope_r1, lt_area_r1, + lt_slope_r1, step_height_r1...)* + :param gaussian_term: If ``True``, enable gaussian term. Default ``True`` + :param st_term: If ``True``, enable gaussian term. Default ``True`` + :param lt_term: If ``True``, enable gaussian term. Default ``True`` + :param step_term: If ``True``, enable gaussian term. Default ``True`` + :return: Array of sum of hypermet functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 8 parameters are required.") + + # Sum binary flags to activate various terms of the equation + tail_flags = 1 if gaussian_term else 0 + if st_term: + tail_flags += 2 + if lt_term: + tail_flags += 4 + if step_term: + tail_flags += 8 + + # TODO (maybe): + # Set flags according to params, to move conditional + # branches out of the C code. + # E.g., set st_term = False if any of the st_slope_r params + # (params[8*i + 4]) is 0, to prevent division by 0. Same thing for + # lt_slope_r (params[8*i + 6]) and lt_term. + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_fastahypermet(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0], + tail_flags) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def atan_stepup(x, a, b, c): + """ + Step up function using an inverse tangent. + + :param x: Independent variable where the function is calculated + :type x: numpy array + :param a: Height of the step up + :param b: Center of the step up + :param c: Parameter related to the slope of the step. A lower ``c`` + value yields a sharper step. + :return: ``a * (0.5 + (arctan((x - b) / c) / pi))`` + :rtype: numpy array + """ + if not hasattr(x, "shape"): + x = numpy.array(x) + return a * (0.5 + (numpy.arctan((1.0 * x - b) / c) / numpy.pi)) + + +def periodic_gauss(x, *pars): + """ + Return a sum of gaussian functions defined by + *(npeaks, delta, height, centroid, fwhm)*, + where: + + - *npeaks* is the number of gaussians peaks + - *delta* is the constant distance between 2 peaks + - *height* is the peak amplitude of all the gaussians + - *centroid* is the peak x-coordinate of the first gaussian + - *fwhm* is the full-width at half maximum for all the gaussians + + :param x: Independent variable where the function is calculated + :param pars: *(npeaks, delta, height, centroid, fwhm)* + :return: Sum of ``npeaks`` gaussians + """ + + if not len(pars): + raise IndexError("No parameters specified. " + + "At least 5 parameters are required.") + + newpars = numpy.zeros((pars[0], 3), numpy.float64) + for i in range(int(pars[0])): + newpars[i, 0] = pars[2] + newpars[i, 1] = pars[3] + i * pars[1] + newpars[:, 2] = pars[4] + return sum_gauss(x, newpars) diff --git a/src/silx/math/fit/functions/include/functions.h b/src/silx/math/fit/functions/include/functions.h new file mode 100644 index 0000000..de4209b --- /dev/null +++ b/src/silx/math/fit/functions/include/functions.h @@ -0,0 +1,68 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#ifndef FITFUNCTIONS_H +#define FITFUNCTIONS_H + +/* Helper functions */ +int test_params(int len_params, int len_params_one_function, char* fun_name, char* param_names); +double myerfc(double x); +double myerf(double x); +int erfc_array(double* x, int len_x, double* y); +int erf_array(double* x, int len_x, double* y); + +/* Background functions */ +void snip1d(double *data, int size, int width); +//void snip1d_multiple(double *data, int n_channels, int snip_width, int n_spectra); +void snip2d(double *data, int nrows, int ncolumns, int width); +void snip3d(double *data, int nx, int ny, int nz, int width); + +int strip(double* input, long len_input, double c, long niter, int deltai, + long* anchors, long len_anchors, double* output); + +/* Smoothing functions */ + +int SavitskyGolay(double* input, long len_input, int npoints, double* output); + +/* Fit functions */ +int sum_gauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y); +int sum_agauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y); +int sum_fastagauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y); +int sum_splitgauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y); + +int sum_apvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y); +int sum_pvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y); +int sum_splitpvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y); + +int sum_lorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y); +int sum_alorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y); +int sum_splitlorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y); + +int sum_stepdown(double* x, int len_x, double* pdstep, int len_pdstep, double* y); +int sum_stepup(double* x, int len_x, double* pustep, int len_pustep, double* y); +int sum_slit(double* x, int len_x, double* pslit, int len_pslit, double* y); + +int sum_ahypermet(double* x, int len_x, double* phypermet, int len_phypermet, double* y, int tail_flags); +int sum_fastahypermet(double* x, int len_x, double* phypermet, int len_phypermet, double* y, int tail_flags); + +#endif /* #define FITFUNCTIONS_H */ diff --git a/src/silx/math/fit/functions/src/funs.c b/src/silx/math/fit/functions/src/funs.c new file mode 100644 index 0000000..aae173f --- /dev/null +++ b/src/silx/math/fit/functions/src/funs.c @@ -0,0 +1,1265 @@ +#/*########################################################################## +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + This file provides fit functions. + + It is adapted from PyMca source file "SpecFitFuns.c". The main difference + with the original code is that this code does not handle the python + wrapping, which is done elsewhere using cython. + + Authors: V.A. Sole, P. Knobel + License: MIT + Last modified: 17/06/2016 +*/ +#include <math.h> +#include <stdlib.h> +#include <stdio.h> +#include "functions.h" + +#ifndef M_PI +#define M_PI 3.1415926535 +#endif + +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +#if defined(_WIN32) +#define erf myerf +#define erfc myerfc +#endif + +#define LOG2 0.69314718055994529 + + +int test_params(int len_params, + int len_params_one_function, + char* fun_name, + char* param_names) +{ + if (len_params % len_params_one_function) { + printf("[%s]Error: Number of parameters must be a multiple of %d.", + fun_name, len_params_one_function); + printf("\nParameters expected for %s: %s\n", + fun_name, param_names); + return(1); + } + if (len_params == 0) { + printf("[%s]Error: No parameters specified.", fun_name); + printf("\nParameters expected for %s: %s\n", + fun_name, param_names); + return(1); + } + return(0); +} + +/* Complementary error function for a single value*/ +double myerfc(double x) +{ + double z; + double t; + double r; + + z=fabs(x); + t=1.0/(1.0+0.5*z); + r=t * exp(-z * z - 1.26551223 + t * (1.00002368 + t * (0.3740916 + + t * (0.09678418 + t * (-0.18628806 + t * (0.27886807 + t * (-1.13520398 + + t * (1.48851587 + t * (-0.82215223+t*0.17087277))))))))); + if (x<0) + r=2.0-r; + return (r); +} + +/* Gauss error function for a single value*/ +double myerf(double x) +{ + return (1.0 - myerfc(x)); +} + +/* Gauss error function for an array + y[i]=erf(x[i]) + returns status code 0 +*/ +int erf_array(double* x, int len_x, double* y) +{ + int j; + for (j=0; j<len_x; j++) { + y[j] = erf(x[j]); + } + return(0); +} + +/* Complementary error function for an array + y[i]=erfc(x[i]) + returns status code 0*/ +int erfc_array(double* x, int len_x, double* y) +{ + int j; + for (j=0; j<len_x; j++) { + y[j] = erfc(x[j]); + } + return(0); +} + +/* Use lookup table for fast exp computation */ +double fastexp(double x) +{ + int expindex; + static double EXP[5000] = {0.0}; + int i; + +/*initialize */ + if (EXP[0] < 1){ + for (i=0;i<5000;i++){ + EXP[i] = exp(-0.01 * i); + } + } +/*calculate*/ + if (x < 0){ + x = -x; + if (x < 50){ + expindex = (int) (x * 100); + return EXP[expindex]*(1.0 - (x - 0.01 * expindex)) ; + }else if (x < 100) { + expindex = (int) (x * 10); + return pow(EXP[expindex]*(1.0 - (x - 0.1 * expindex)),10) ; + }else if (x < 1000){ + expindex = (int) x; + return pow(EXP[expindex]*(1.0 - (x - expindex)),20) ; + }else if (x < 10000){ + expindex = (int) (x * 0.1); + return pow(EXP[expindex]*(1.0 - (x - 10.0 * expindex)),30) ; + }else{ + return 0; + } + }else{ + if (x < 50){ + expindex = (int) (x * 100); + return 1.0/EXP[expindex]*(1.0 - (x - 0.01 * expindex)) ; + }else if (x < 100) { + expindex = (int) (x * 10); + return pow(EXP[expindex]*(1.0 - (x - 0.1 * expindex)),-10) ; + }else{ + return exp(x); + } + } +} + + +/* sum_gauss + Sum of gaussian functions, defined by (height, centroid, fwhm) + + *height* is the peak amplitude + *centroid* is the peak x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pvoigt: Array of gaussian parameters: + (height1, centroid1, fwhm1, height2, centroid2, fwhm2,...) + - len_pgauss: Number of elements in the pgauss array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_gauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, sigma; + double fwhm, centroid, height; + + if (test_params(len_pgauss, 3, "sum_gauss", "height, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pgauss/3; i++) { + height = pgauss[3*i]; + centroid = pgauss[3*i+1]; + fwhm = pgauss[3*i+2]; + + sigma = fwhm * inv_two_sqrt_two_log2; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid) / sigma; + if (dhelp <= 20) { + y[j] += height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + +/* sum_agauss + Sum of gaussian functions defined by (area, centroid, fwhm) + + *area* is the area underneath the peak + *centroid* is the peak x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pgauss: Array of gaussian parameters: + (area1, centroid1, fwhm1, area2, centroid2, fwhm2,...) + - len_pgauss: Number of elements in the pgauss array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_agauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y) +{ + int i, j; + double dhelp, height, sqrt2PI, sigma, inv_two_sqrt_two_log2; + double fwhm, centroid, area; + + if (test_params(len_pgauss, 3, "sum_agauss", "area, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + sqrt2PI = sqrt(2.0*M_PI); + + for (i=0; i<len_pgauss/3; i++) { + area = pgauss[3*i]; + centroid = pgauss[3*i+1]; + fwhm = pgauss[3*i+2]; + + sigma = fwhm * inv_two_sqrt_two_log2; + height = area / (sigma * sqrt2PI); + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid)/sigma; + if (dhelp <= 35) { + y[j] += height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + + +/* sum_fastagauss + Sum of gaussian functions defined by (area, centroid, fwhm). + This implementation uses a lookup table of precalculated exp values + and a limited development (exp(-x) = 1 - x for small values of x) + + *area* is the area underneath the peak + *centroid* is the peak x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pgauss: Array of gaussian parameters: + (area1, centroid1, fwhm1, area2, centroid2, fwhm2,...) + - len_pgauss: Number of elements in the pgauss array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ + +int sum_fastagauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y) +{ + int i, j, expindex; + double dhelp, height, sqrt2PI, sigma, inv_two_sqrt_two_log2; + double fwhm, centroid, area; + static double EXP[5000]; + + if (test_params(len_pgauss, 3, "sum_fastagauss", "area, centroid, fwhm")) { + return(1); + } + + if (EXP[0] < 1){ + for (i=0; i<5000; i++){ + EXP[i] = exp(-0.01 * i); + } + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + sqrt2PI = sqrt(2.0*M_PI); + + for (i=0; i<len_pgauss/3; i++) { + area = pgauss[3*i]; + centroid = pgauss[3*i+1]; + fwhm = pgauss[3*i+2]; + + sigma = fwhm * inv_two_sqrt_two_log2; + height = area / (sigma * sqrt2PI); + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid)/sigma; + if (dhelp <= 15){ + dhelp = 0.5 * dhelp * dhelp; + if (dhelp < 50){ + expindex = (int) (dhelp * 100); + y[j] += height * EXP[expindex] * (1.0 - (dhelp - 0.01 * expindex)); + } + else if (dhelp < 100) { + expindex = (int) (dhelp * 10); + y[j] += height * pow(EXP[expindex] * (1.0 - (dhelp - 0.1 * expindex)), 10); + } + else if (dhelp < 1000){ + expindex = (int) (dhelp); + y[j] += height * pow(EXP[expindex] * (1.0 - (dhelp - expindex)), 20); + } + } + } + } + return(0); +} + +/* sum_splitgauss + Sum of split gaussian functions, defined by (height, centroid, fwhm1, fwhm2) + + *height* is the peak amplitude + *centroid* is the peak x-coordinate + *fwhm1* is the full-width at half maximum of the left half of the curve (x < centroid) + *fwhm1* is the full-width at half maximum of the right half of the curve (x > centroid) + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pgauss: Array of gaussian parameters: + (height1, centroid1, fwhm11, fwhm21, height2, centroid2, fwhm12, fwhm22,...) + - len_pgauss: Number of elements in the pgauss array. Must be + a multiple of 4. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_splitgauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, sigma1, sigma2; + double fwhm1, fwhm2, centroid, height; + + if (test_params(len_pgauss, 4, "sum_splitgauss", "height, centroid, fwhm1, fwhm2")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pgauss/4; i++) { + height = pgauss[4*i]; + centroid = pgauss[4*i+1]; + fwhm1 = pgauss[4*i+2]; + fwhm2 = pgauss[4*i+3]; + + sigma1 = fwhm1 * inv_two_sqrt_two_log2; + sigma2 = fwhm2 * inv_two_sqrt_two_log2; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid); + if (dhelp > 0) { + /* Use fwhm2 when x > centroid */ + dhelp = dhelp / sigma2; + } + else { + /* Use fwhm1 when x < centroid */ + dhelp = dhelp / sigma1; + } + + if (dhelp <= 20) { + y[j] += height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + +/* sum_apvoigt + Sum of pseudo-Voigt functions, defined by (area, centroid, fwhm, eta). + + The pseudo-Voigt profile PV(x) is an approximation of the Voigt profile + using a linear combination of a Gaussian curve G(x) and a Lorentzian curve + L(x) instead of their convolution. + + *area* is the area underneath both G(x) and L(x) + *centroid* is the peak x-coordinate for both functions + *fwhm* is the full-width at half maximum of both functions + *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pvoigt: Array of Voigt function parameters: + (area1, centroid1, fwhm1, eta1, area2, centroid2, fwhm2, eta2,...) + - len_voigt: Number of elements in the pvoigt array. Must be + a multiple of 4. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_apvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, sqrt2PI, sigma, height; + double area, centroid, fwhm, eta; + + if (test_params(len_pvoigt, 4, "sum_apvoigt", "area, centroid, fwhm, eta")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + sqrt2PI = sqrt(2.0*M_PI); + + + for (i=0; i<len_pvoigt/4; i++) { + area = pvoigt[4*i]; + centroid = pvoigt[4*i+1]; + fwhm = pvoigt[4*i+2]; + eta = pvoigt[4*i+3]; + + sigma = fwhm * inv_two_sqrt_two_log2; + height = area / (sigma * sqrt2PI); + + for (j=0; j<len_x; j++) { + /* Lorentzian term */ + dhelp = (x[j] - centroid) / (0.5 * fwhm); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += eta * (area / (0.5 * M_PI * fwhm * dhelp)); + + /* Gaussian term */ + dhelp = (x[j] - centroid) / sigma; + if (dhelp <= 35) { + y[j] += (1.0 - eta) * height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + +/* sum_pvoigt + Sum of pseudo-Voigt functions, defined by (height, centroid, fwhm, eta). + + The pseudo-Voigt profile PV(x) is an approximation of the Voigt profile + using a linear combination of a Gaussian curve G(x) and a Lorentzian curve + L(x) instead of their convolution. + + *height* is the peak amplitude of G(x) and L(x) + *centroid* is the peak x-coordinate for both functions + *fwhm* is the full-width at half maximum of both functions + *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pvoigt: Array of Voigt function parameters: + (height1, centroid1, fwhm1, eta1, height2, centroid2, fwhm2, eta2,...) + - len_voigt: Number of elements in the pvoigt array. Must be + a multiple of 4. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_pvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, sigma; + double height, centroid, fwhm, eta; + + if (test_params(len_pvoigt, 4, "sum_pvoigt", "height, centroid, fwhm, eta")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pvoigt/4; i++) { + height = pvoigt[4*i]; + centroid = pvoigt[4*i+1]; + fwhm = pvoigt[4*i+2]; + eta = pvoigt[4*i+3]; + + sigma = fwhm * inv_two_sqrt_two_log2; + + for (j=0; j<len_x; j++) { + /* Lorentzian term */ + dhelp = (x[j] - centroid) / (0.5 * fwhm); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += eta * height / dhelp; + + /* Gaussian term */ + dhelp = (x[j] - centroid) / sigma; + if (dhelp <= 35) { + y[j] += (1.0 - eta) * height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + +/* sum_splitpvoigt + Sum of split pseudo-Voigt functions, defined by + (height, centroid, fwhm1, fwhm2, eta). + + The pseudo-Voigt profile PV(x) is an approximation of the Voigt profile + using a linear combination of a Gaussian curve G(x) and a Lorentzian curve + L(x) instead of their convolution. + + *height* is the peak amplitude of G(x) and L(x) + *centroid* is the peak x-coordinate for both functions + *fwhm1* is the full-width at half maximum of both functions for x < centroid + *fwhm2* is the full-width at half maximum of both functions for x > centroid + *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pvoigt: Array of Voigt function parameters: + (height1, centroid1, fwhm11, fwhm21, eta1, ...) + - len_voigt: Number of elements in the pvoigt array. Must be + a multiple of 5. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_splitpvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, x_minus_centroid, sigma1, sigma2; + double height, centroid, fwhm1, fwhm2, eta; + + if (test_params(len_pvoigt, 5, "sum_splitpvoigt", "height, centroid, fwhm1, fwhm2, eta")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pvoigt/5; i++) { + height = pvoigt[5*i]; + centroid = pvoigt[5*i+1]; + fwhm1 = pvoigt[5*i+2]; + fwhm2 = pvoigt[5*i+3]; + eta = pvoigt[5*i+4]; + + sigma1 = fwhm1 * inv_two_sqrt_two_log2; + sigma2 = fwhm2 * inv_two_sqrt_two_log2; + + for (j=0; j<len_x; j++) { + x_minus_centroid = (x[j] - centroid); + + /* Use fwhm2 when x > centroid */ + if (x_minus_centroid > 0) { + /* Lorentzian term */ + dhelp = x_minus_centroid / (0.5 * fwhm2); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += eta * height / dhelp; + + /* Gaussian term */ + dhelp = x_minus_centroid / sigma2; + if (dhelp <= 35) { + y[j] += (1.0 - eta) * height * exp (-0.5 * dhelp * dhelp); + } + } + /* Use fwhm1 when x < centroid */ + else { + /* Lorentzian term */ + dhelp = x_minus_centroid / (0.5 * fwhm1); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += eta * height / dhelp; + + /* Gaussian term */ + dhelp = x_minus_centroid / sigma1; + if (dhelp <= 35) { + y[j] += (1.0 - eta) * height * exp (-0.5 * dhelp * dhelp); + } + } + } + } + return(0); +} + +/* sum_lorentz + Sum of Lorentz functions, defined by (height, centroid, fwhm). + + *height* is the peak amplitude + *centroid* is the peak's x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the Lorentzians are calculated. + - len_x: Number of elements in the x array. + - plorentz: Array of lorentz function parameters: + (height1, centroid1, fwhm1, ...) + - len_lorentz: Number of elements in the plorentz array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_lorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y) +{ + int i, j; + double dhelp; + double height, centroid, fwhm; + + if (test_params(len_plorentz, 3, "sum_lorentz", "height, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + for (i=0; i<len_plorentz/3; i++) { + height = plorentz[3*i]; + centroid = plorentz[3*i+1]; + fwhm = plorentz[3*i+2]; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid) / (0.5 * fwhm); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += height / dhelp; + } + } + return(0); +} + + +/* sum_alorentz + Sum of Lorentz functions, defined by (area, centroid, fwhm). + + *area* is the area underneath the peak + *centroid* is the peak's x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the Lorentzians are calculated. + - len_x: Number of elements in the x array. + - plorentz: Array of lorentz function parameters: + (area1, centroid1, fwhm1, ...) + - len_lorentz: Number of elements in the plorentz array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_alorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y) +{ + int i, j; + double dhelp; + double area, centroid, fwhm; + + if (test_params(len_plorentz, 3, "sum_alorentz", "area, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + for (i=0; i<len_plorentz/3; i++) { + area = plorentz[3*i]; + centroid = plorentz[3*i+1]; + fwhm = plorentz[3*i+2]; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid) / (0.5 * fwhm); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += area / (0.5 * M_PI * fwhm * dhelp); + } + } + return(0); +} + + +/* sum_splitlorentz + Sum of Lorentz functions, defined by (height, centroid, fwhm1, fwhm2). + + *height* is the peak amplitude + *centroid* is the peak's x-coordinate + *fwhm1* is the full-width at half maximum for x < centroid + *fwhm2* is the full-width at half maximum for x > centroid + + Parameters: + ----------- + + - x: Independant variable where the Lorentzians are calculated. + - len_x: Number of elements in the x array. + - plorentz: Array of lorentz function parameters: + (height1, centroid1, fwhm11, fwhm21 ...) + - len_lorentz: Number of elements in the plorentz array. Must be + a multiple of 4. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_splitlorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y) +{ + int i, j; + double dhelp; + double height, centroid, fwhm1, fwhm2; + + if (test_params(len_plorentz, 4, "sum_splitlorentz", "height, centroid, fwhm1, fwhm2")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + for (i=0; i<len_plorentz/4; i++) { + height = plorentz[4*i]; + centroid = plorentz[4*i+1]; + fwhm1 = plorentz[4*i+2]; + fwhm2 = plorentz[4*i+3]; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid); + if (dhelp>0) { + dhelp = dhelp / (0.5 * fwhm2); + } + else { + dhelp = dhelp / (0.5 * fwhm1); + } + dhelp = 1.0 + (dhelp * dhelp); + y[j] += height / dhelp; + } + } + return(0); +} + +/* sum_stepdown + Sum of stepdown functions, defined by (height, centroid, fwhm). + + *height* is the step amplitude + *centroid* is the step's x-coordinate + *fwhm* is the full-width at half maximum of the derivative + + Parameters: + ----------- + + - x: Independant variable where the stepdown functions are calculated. + - len_x: Number of elements in the x array. + - pdstep: Array of downstpe function parameters: + (height1, centroid1, fwhm1, ...) + - len_pdstep: Number of elements in the pdstep array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_stepdown(double* x, int len_x, double* pdstep, int len_pdstep, double* y) +{ + int i, j; + double dhelp, sqrt2_inv_2_sqrt_two_log2 ; + double height, centroid, fwhm; + + if (test_params(len_pdstep, 3, "sum_stepdown", "height, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + sqrt2_inv_2_sqrt_two_log2 = sqrt(2.0) / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pdstep/3; i++) { + height = pdstep[3*i]; + centroid = pdstep[3*i+1]; + fwhm = pdstep[3*i+2]; + + for (j=0; j<len_x; j++) { + dhelp = fwhm * sqrt2_inv_2_sqrt_two_log2; + dhelp = (x[j] - centroid) / dhelp; + y[j] += height * 0.5 * erfc(dhelp); + } + } + return(0); +} + +/* sum_stepup + Sum of stepup functions, defined by (height, centroid, fwhm). + + *height* is the step amplitude + *centroid* is the step's x-coordinate + *fwhm* is the full-width at half maximum of the derivative + + Parameters: + ----------- + + - x: Independant variable where the stepup functions are calculated. + - len_x: Number of elements in the x array. + - pustep: Array of stepdown function parameters: + (height1, centroid1, fwhm1, ...) + - len_pustep: Number of elements in the pustep array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_stepup(double* x, int len_x, double* pustep, int len_pustep, double* y) +{ + int i, j; + double dhelp, sqrt2_inv_2_sqrt_two_log2 ; + double height, centroid, fwhm; + + if (test_params(len_pustep, 3, "sum_stepup", "height, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + sqrt2_inv_2_sqrt_two_log2 = sqrt(2.0) / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pustep/3; i++) { + height = pustep[3*i]; + centroid = pustep[3*i+1]; + fwhm = pustep[3*i+2]; + + for (j=0; j<len_x; j++) { + dhelp = fwhm * sqrt2_inv_2_sqrt_two_log2; + dhelp = (x[j] - centroid) / dhelp; + y[j] += height * 0.5 * (1.0 + erf(dhelp)); + } + } + return(0); +} + + +/* sum_slit + Sum of slit functions, defined by (height, position, fwhm, beamfwhm). + + *height* is the slit height + *position* is the slit's center x-coordinate + *fwhm* is the full-width at half maximum of the slit + *beamfwhm* is the full-width at half maximum of derivative's peaks + + Parameters: + ----------- + + - x: Independant variable where the slit functions are calculated. + - len_x: Number of elements in the x array. + - pslit: Array of slit function parameters: + (height1, centroid1, fwhm1, beamfwhm1 ...) + - len_pslit: Number of elements in the pslit array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_slit(double* x, int len_x, double* pslit, int len_pslit, double* y) +{ + int i, j; + double dhelp, dhelp1, dhelp2, sqrt2_inv_2_sqrt_two_log2, centroid1, centroid2; + double height, position, fwhm, beamfwhm; + + if (test_params(len_pslit, 4, "sum_slit", "height, centroid, fwhm, beamfwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + sqrt2_inv_2_sqrt_two_log2 = sqrt(2.0) / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pslit/4; i++) { + height = pslit[4*i]; + position = pslit[4*i+1]; + fwhm = pslit[4*i+2]; + beamfwhm = pslit[4*i+3]; + + centroid1 = position - 0.5 * fwhm; + centroid2 = position + 0.5 * fwhm; + + for (j=0; j<len_x; j++) { + dhelp = beamfwhm * sqrt2_inv_2_sqrt_two_log2; + dhelp1 = (x[j] - centroid1) / dhelp; + dhelp2 = (x[j] - centroid2) / dhelp; + y[j] += height * 0.25 * (1.0 + erf(dhelp1)) * erfc(dhelp2); + } + } + return(0); +} + + +/* sum_ahypermet + Sum of hypermet functions, defined by + (area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r). + + - *area* is the area underneath the gaussian peak + - *position* is the center of the various peaks and the position of + the step down + - *fwhm* is the full-width at half maximum of the terms + - *st_area_r* is factor between the gaussian area and the area of the + short tail term + - *st_slope_r* is a parameter related to the slope of the short tail + in the low ``x`` values (the lower, the steeper) + - *lt_area_r* is factor between the gaussian area and the area of the + long tail term + - *lt_slope_r* is a parameter related to the slope of the long tail + in the low ``x`` values (the lower, the steeper) + - *step_height_r* is the factor between the height of the step down + and the gaussian height + + Parameters: + ----------- + + - x: Independant variable where the functions are calculated. + - len_x: Number of elements in the x array. + - phypermet: Array of hypermet function parameters: + *(area1, position1, fwhm1, st_area_r1, st_slope_r1, lt_area_r1, + lt_slope_r1, step_height_r1, ...)* + - len_phypermet: Number of elements in the phypermet array. Must be + a multiple of 8. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + - tail_flags: sum of binary flags to activate the various terms of the + function: + + - 1 (b0001): Gaussian term + - 2 (b0010): st term + - 4 (b0100): lt term + - 8 (b1000): step term + + E.g., to activate all termsof the hypermet, use ``tail_flags = 1 + 2 + 4 + 8 = 15`` + + Adapted from PyMca module SpecFitFuns +*/ +int sum_ahypermet(double* x, int len_x, double* phypermet, int len_phypermet, double* y, int tail_flags) +{ + int i, j; + int g_term_flag, st_term_flag, lt_term_flag, step_term_flag; + double c1, c2, sigma, height, sigma_sqrt2, sqrt2PI, inv_2_sqrt_2_log2, x_minus_position, epsilon; + double area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r; + + if (test_params(len_phypermet, 8, "sum_hypermet", + "height, centroid, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r")) { + return(1); + } + + g_term_flag = tail_flags & 1; + st_term_flag = (tail_flags>>1) & 1; + lt_term_flag = (tail_flags>>2) & 1; + step_term_flag = (tail_flags>>3) & 1; + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + /* define epsilon to compare floating point values with 0. */ + epsilon = 0.00000000001; + + sqrt2PI= sqrt(2.0 * M_PI); + inv_2_sqrt_2_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_phypermet/8; i++) { + area = phypermet[8*i]; + position = phypermet[8*i+1]; + fwhm = phypermet[8*i+2]; + st_area_r = phypermet[8*i+3]; + st_slope_r = phypermet[8*i+4]; + lt_area_r = phypermet[8*i+5]; + lt_slope_r = phypermet[8*i+6]; + step_height_r = phypermet[8*i+7]; + + sigma = fwhm * inv_2_sqrt_2_log2; + height = area / (sigma * sqrt2PI); + + /* Prevent division by 0 */ + if (sigma == 0) { + printf("fwhm must not be equal to 0"); + return(1); + } + sigma_sqrt2 = sigma * 1.4142135623730950488; + + for (j=0; j<len_x; j++) { + x_minus_position = x[j] - position; + c2 = (0.5 * x_minus_position * x_minus_position) / (sigma * sigma); + /* gaussian term */ + if (g_term_flag) { + y[j] += exp(-c2) * height; + } + + /* st term */ + if (st_term_flag) { + if (fabs(st_slope_r) > epsilon) { + c1 = st_area_r * 0.5 * \ + erfc((x_minus_position/sigma_sqrt2) + 0.5 * sigma_sqrt2 / st_slope_r); + y[j] += ((area * c1) / st_slope_r) * \ + exp(0.5 * (sigma / st_slope_r) * (sigma / st_slope_r) + \ + (x_minus_position / st_slope_r)); + } + } + + /* lt term */ + if (lt_term_flag) { + if (fabs(lt_slope_r) > epsilon) { + c1 = lt_area_r * \ + 0.5 * erfc((x_minus_position/sigma_sqrt2) + 0.5 * sigma_sqrt2 / lt_slope_r); + y[j] += ((area * c1) / lt_slope_r) * \ + exp(0.5 * (sigma / lt_slope_r) * (sigma / lt_slope_r) + \ + (x_minus_position / lt_slope_r)); + } + } + + /* step term flag */ + if (step_term_flag) { + y[j] += step_height_r * (area / (sigma * sqrt2PI)) * \ + 0.5 * erfc(x_minus_position / sigma_sqrt2); + } + } + } + return(0); +} + +/* sum_fastahypermet + + Sum of hypermet functions, defined by + (area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r). + + - *area* is the area underneath the gaussian peak + - *position* is the center of the various peaks and the position of + the step down + - *fwhm* is the full-width at half maximum of the terms + - *st_area_r* is factor between the gaussian area and the area of the + short tail term + - *st_slope_r* is a parameter related to the slope of the short tail + in the low ``x`` values (the lower, the steeper) + - *lt_area_r* is factor between the gaussian area and the area of the + long tail term + - *lt_slope_r* is a parameter related to the slope of the long tail + in the low ``x`` values (the lower, the steeper) + - *step_height_r* is the factor between the height of the step down + and the gaussian height + + Parameters: + ----------- + + - x: Independant variable where the functions are calculated. + - len_x: Number of elements in the x array. + - phypermet: Array of hypermet function parameters: + *(area1, position1, fwhm1, st_area_r1, st_slope_r1, lt_area_r1, + lt_slope_r1, step_height_r1, ...)* + - len_phypermet: Number of elements in the phypermet array. Must be + a multiple of 8. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + - tail_flags: sum of binary flags to activate the various terms of the + function: + + - 1 (b0001): Gaussian term + - 2 (b0010): st term + - 4 (b0100): lt term + - 8 (b1000): step term + + E.g., to activate all termsof the hypermet, use ``tail_flags = 1 + 2 + 4 + 8 = 15`` + + Adapted from PyMca module SpecFitFuns +*/ +int sum_fastahypermet(double* x, int len_x, double* phypermet, int len_phypermet, double* y, int tail_flags) +{ + int i, j; + int g_term_flag, st_term_flag, lt_term_flag, step_term_flag; + double c1, c2, sigma, height, sigma_sqrt2, sqrt2PI, inv_2_sqrt_2_log2, x_minus_position, epsilon; + double area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r; + + if (test_params(len_phypermet, 8, "sum_hypermet", + "height, centroid, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r")) { + return(1); + } + + g_term_flag = tail_flags & 1; + st_term_flag = (tail_flags>>1) & 1; + lt_term_flag = (tail_flags>>2) & 1; + step_term_flag = (tail_flags>>3) & 1; + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + /* define epsilon to compare floating point values with 0. */ + epsilon = 0.00000000001; + + sqrt2PI= sqrt(2.0 * M_PI); + inv_2_sqrt_2_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_phypermet/8; i++) { + area = phypermet[8*i]; + position = phypermet[8*i+1]; + fwhm = phypermet[8*i+2]; + st_area_r = phypermet[8*i+3]; + st_slope_r = phypermet[8*i+4]; + lt_area_r = phypermet[8*i+5]; + lt_slope_r = phypermet[8*i+6]; + step_height_r = phypermet[8*i+7]; + + sigma = fwhm * inv_2_sqrt_2_log2; + height = area / (sigma * sqrt2PI); + + /* Prevent division by 0 */ + if (sigma == 0) { + printf("fwhm must not be equal to 0"); + return(1); + } + sigma_sqrt2 = sigma * 1.4142135623730950488; + + for (j=0; j<len_x; j++) { + x_minus_position = x[j] - position; + c2 = (0.5 * x_minus_position * x_minus_position) / (sigma * sigma); + /* gaussian term */ + if (g_term_flag && c2 < 100) { + y[j] += fastexp(-c2) * height; + } + + /* st term */ + if (st_term_flag && (fabs(st_slope_r) > epsilon) && (x_minus_position / st_slope_r) <= 612) { + c1 = st_area_r * 0.5 * \ + erfc((x_minus_position/sigma_sqrt2) + 0.5 * sigma_sqrt2 / st_slope_r); + y[j] += ((area * c1) / st_slope_r) * \ + fastexp(0.5 * (sigma / st_slope_r) * (sigma / st_slope_r) +\ + (x_minus_position / st_slope_r)); + } + + /* lt term */ + if (lt_term_flag && (fabs(lt_slope_r) > epsilon) && (x_minus_position / lt_slope_r) <= 612) { + c1 = lt_area_r * \ + 0.5 * erfc((x_minus_position/sigma_sqrt2) + 0.5 * sigma_sqrt2 / lt_slope_r); + y[j] += ((area * c1) / lt_slope_r) * \ + fastexp(0.5 * (sigma / lt_slope_r) * (sigma / lt_slope_r) +\ + (x_minus_position / lt_slope_r)); + + } + + /* step term flag */ + if (step_term_flag) { + y[j] += step_height_r * (area / (sigma * sqrt2PI)) *\ + 0.5 * erfc(x_minus_position / sigma_sqrt2); + } + } + } + return(0); +} + +void pileup(double* x, long len_x, double* ret, int input2, double zero, double gain) +{ + //int input2=0; + //double zero=0.0; + //double gain=1.0; + + int i, j, k; + double *px, *pret, *pall; + + /* the pointer to the starting position of par data */ + px = x; + pret = ret; + + *pret = 0; + k = (int )(zero/gain); + for (i=input2; i<len_x; i++){ + pall = x; + if ((i+k) >= 0) + { + pret = (double *) ret+(i+k); + for (j=0; j<len_x-i-k ;j++){ + *pret += *px * (*pall); + pall++; + pret++; + } + } + px++; + } +} diff --git a/src/silx/math/fit/functions_wrapper.pxd b/src/silx/math/fit/functions_wrapper.pxd new file mode 100644 index 0000000..780116c --- /dev/null +++ b/src/silx/math/fit/functions_wrapper.pxd @@ -0,0 +1,170 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "14/06/2016" + +cimport cython + +cdef extern from "functions.h": + int erfc_array(double* x, + int len_x, + double* y) + + int erf_array(double* x, + int len_x, + double* y); + + void snip1d(double *data, + int size, + int width) + + void snip2d(double *data, + int nrows, + int ncolumns, + int width) + + void snip3d(double *data, + int nx, + int ny, + int nz, + int width) + + int strip(double* input, + long len_input, + double c, + long niter, + int deltai, + long* anchors, + long len_anchors, + double* output) + + int sum_gauss(double* x, + int len_x, + double* pgauss, + int len_pgauss, + double* y) + + int sum_agauss(double* x, + int len_x, + double* pgauss, + int len_pgauss, + double* y) + + int sum_fastagauss(double* x, + int len_x, + double* pgauss, + int len_pgauss, + double* y) + + int sum_splitgauss(double* x, + int len_x, + double* pgauss, + int len_pgauss, + double* y) + + int sum_apvoigt(double* x, + int len_x, + double* pvoigt, + int len_pvoigt, + double* y) + + int sum_pvoigt(double* x, + int len_x, + double* pvoigt, + int len_pvoigt, + double* y) + + int sum_splitpvoigt(double* x, + int len_x, + double* pvoigt, + int len_pvoigt, + double* y) + + int sum_lorentz(double* x, + int len_x, + double* plorentz, + int len_plorentz, + double* y) + + int sum_alorentz(double* x, + int len_x, + double* plorentz, + int len_plorentz, + double* y) + + int sum_splitlorentz(double* x, + int len_x, + double* plorentz, + int len_plorentz, + double* y) + + int sum_stepdown(double* x, + int len_x, + double* pdstep, + int len_pdstep, + double* y) + + int sum_stepup(double* x, + int len_x, + double* pustep, + int len_pustep, + double* y) + + int sum_slit(double* x, + int len_x, + double* pslit, + int len_pslit, + double* y) + + int sum_ahypermet(double* x, + int len_x, + double* phypermet, + int len_phypermet, + double* y, + int tail_flags) + + int sum_fastahypermet(double* x, + int len_x, + double* phypermet, + int len_phypermet, + double* y, + int tail_flags) + + long seek(long begin_index, + long end_index, + long nsamples, + double fwhm, + double sensitivity, + double debug_info, + long max_npeaks, + double * data, + double * peaks, + double * relevances) + + int SavitskyGolay(double* input, + long len_input, + int npoints, + double* output) diff --git a/src/silx/math/fit/leastsq.py b/src/silx/math/fit/leastsq.py new file mode 100644 index 0000000..3df1a35 --- /dev/null +++ b/src/silx/math/fit/leastsq.py @@ -0,0 +1,901 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This module implements a Levenberg-Marquardt algorithm with constraints on the +fitted parameters without introducing any other dependendency than numpy. + +If scipy dependency is not an issue, and no constraints are applied to the fitting +parameters, there is no real gain compared to the use of scipy.optimize.curve_fit +other than a more conservative calculation of uncertainties on fitted parameters. + +This module is a refactored version of PyMca Gefit.py module. +""" +__authors__ = ["V.A. Sole"] +__license__ = "MIT" +__date__ = "15/05/2017" +__copyright__ = "European Synchrotron Radiation Facility, Grenoble, France" + +import numpy +from numpy.linalg import inv +from numpy.linalg.linalg import LinAlgError +import time +import logging +import copy + +_logger = logging.getLogger(__name__) + +# codes understood by the routine +CFREE = 0 +CPOSITIVE = 1 +CQUOTED = 2 +CFIXED = 3 +CFACTOR = 4 +CDELTA = 5 +CSUM = 6 +CIGNORED = 7 + +def leastsq(model, xdata, ydata, p0, sigma=None, + constraints=None, model_deriv=None, epsfcn=None, + deltachi=None, full_output=None, + check_finite=True, + left_derivative=False, + max_iter=100): + """ + Use non-linear least squares Levenberg-Marquardt algorithm to fit a function, f, to + data with optional constraints on the fitted parameters. + + Assumes ``ydata = f(xdata, *params) + eps`` + + :param model: callable + The model function, f(x, ...). It must take the independent + variable as the first argument and the parameters to fit as + separate remaining arguments. + The returned value is a one dimensional array of floats. + + :param xdata: An M-length sequence. + The independent variable where the data is measured. + + :param ydata: An M-length sequence + The dependent data --- nominally f(xdata, ...) + + :param p0: N-length sequence + Initial guess for the parameters. + + :param sigma: None or M-length sequence, optional + If not None, the uncertainties in the ydata array. These are used as + weights in the least-squares problem + i.e. minimising ``np.sum( ((f(xdata, *popt) - ydata) / sigma)**2 )`` + If None, the uncertainties are assumed to be 1 + + :param constraints: + If provided, it is a 2D sequence of dimension (n_parameters, 3) where, + for each parameter denoted by the index i, the meaning is + + - constraints[i][0] + + - 0 - Free (CFREE) + - 1 - Positive (CPOSITIVE) + - 2 - Quoted (CQUOTED) + - 3 - Fixed (CFIXED) + - 4 - Factor (CFACTOR) + - 5 - Delta (CDELTA) + - 6 - Sum (CSUM) + + + - constraints[i][1] + + - Ignored if constraints[i][0] is 0, 1, 3 + - Min value of the parameter if constraints[i][0] is CQUOTED + - Index of fitted parameter to which it is related + + - constraints[i][2] + + - Ignored if constraints[i][0] is 0, 1, 3 + - Max value of the parameter if constraints[i][0] is CQUOTED + - Factor to apply to related parameter with index constraints[i][1] + - Difference with parameter with index constraints[i][1] + - Sum obtained when adding parameter with index constraints[i][1] + :type constraints: *optional*, None or 2D sequence + + :param model_deriv: + None (default) or function providing the derivatives of the fitting function respect to the fitted parameters. + It will be called as model_deriv(xdata, parameters, index) where parameters is a sequence with the current + values of the fitting parameters, index is the fitting parameter index for which the the derivative has + to be provided in the supplied array of xdata points. + :type model_deriv: *optional*, None or callable + + + :param epsfcn: float + A variable used in determining a suitable parameter variation when + calculating the numerical derivatives (for model_deriv=None). + Normally the actual step length will be sqrt(epsfcn)*x + Original Gefit module was using epsfcn 1.0e-5 while default value + is now numpy.finfo(numpy.float64).eps as in scipy + :type epsfcn: *optional*, float + + :param deltachi: float + A variable used to control the minimum change in chisq to consider the + fitting process not worth to be continued. Default is 0.1 %. + :type deltachi: *optional*, float + + :param full_output: bool, optional + non-zero to return all optional outputs. The default is None what will give a warning in case + of a constrained fit without having set this kweyword. + + :param check_finite: bool, optional + If True, check that the input arrays do not contain nans of infs, + and raise a ValueError if they do. Setting this parameter to + False will ignore input arrays values containing nans. + Default is True. + + :param left_derivative: + This parameter only has an influence if no derivative function + is provided. When True the left and right derivatives of the + model will be calculated for each fitted parameters thus leading to + the double number of function evaluations. Default is False. + Original Gefit module was always using left_derivative as True. + :type left_derivative: *optional*, bool + + :param max_iter: Maximum number of iterations (default is 100) + + :return: Returns a tuple of length 2 (or 3 if full_ouput is True) with the content: + + ``popt``: array + Optimal values for the parameters so that the sum of the squared error + of ``f(xdata, *popt) - ydata`` is minimized + ``pcov``: 2d array + If no constraints are applied, this array contains the estimated covariance + of popt. The diagonal provides the variance of the parameter estimate. + To compute one standard deviation errors use ``perr = np.sqrt(np.diag(pcov))``. + If constraints are applied, this array does not contain the estimated covariance of + the parameters actually used during the fitting process but the uncertainties after + recalculating the covariance if all the parameters were free. + To get the actual uncertainties following error propagation of the actually fitted + parameters one should set full_output to True and access the uncertainties key. + ``infodict``: dict + a dictionary of optional outputs with the keys: + + ``uncertainties`` + The actual uncertainty on the optimized parameters. + ``nfev`` + The number of function calls + ``fvec`` + The function evaluated at the output + ``niter`` + The number of iterations performed + ``chisq`` + The chi square ``np.sum( ((f(xdata, *popt) - ydata) / sigma)**2 )`` + ``reduced_chisq`` + The chi square ``np.sum( ((f(xdata, *popt) - ydata) / sigma)**2 )`` divided + by the number of degrees of freedom ``(M - number_of_free_parameters)`` + """ + function_call_counter = 0 + if numpy.isscalar(p0): + p0 = [p0] + parameters = numpy.array(p0, dtype=numpy.float64, copy=False) + if deltachi is None: + deltachi = 0.001 + + # NaNs can not be handled + if check_finite: + xdata = numpy.asarray_chkfinite(xdata) + ydata = numpy.asarray_chkfinite(ydata) + if sigma is not None: + sigma = numpy.asarray_chkfinite(sigma) + else: + sigma = numpy.ones((ydata.shape), dtype=numpy.float64) + ydata.shape = -1 + sigma.shape = -1 + else: + ydata = numpy.asarray(ydata) + xdata = numpy.asarray(xdata) + ydata.shape = -1 + if sigma is not None: + sigma = numpy.asarray(sigma) + else: + sigma = numpy.ones((ydata.shape), dtype=numpy.float64) + sigma.shape = -1 + # get rid of NaN in input data + idx = numpy.isfinite(ydata) + if False in idx: + # xdata must have a shape able to be understood by the user function + # in principle, one should not need to change it, however, if there are + # points to be excluded, one has to be able to exclude them. + # We can only hope that the sequence is properly arranged + if xdata.size == ydata.size: + if len(xdata.shape) != 1: + msg = "Need to reshape input xdata." + _logger.warning(msg) + xdata.shape = -1 + else: + raise ValueError("Cannot reshape xdata to deal with NaN in ydata") + ydata = ydata[idx] + xdata = xdata[idx] + sigma = sigma[idx] + idx = numpy.isfinite(sigma) + if False in idx: + # xdata must have a shape able to be understood by the user function + # in principle, one should not need to change it, however, if there are + # points to be excluded, one has to be able to exclude them. + # We can only hope that the sequence is properly arranged + ydata = ydata[idx] + xdata = xdata[idx] + sigma = sigma[idx] + idx = numpy.isfinite(xdata) + filter_xdata = False + if False in idx: + # What to do? + try: + # Let's see if the function is able to deal with non-finite data + msg = "Checking if function can deal with non-finite data" + _logger.debug(msg) + evaluation = model(xdata, *parameters) + function_call_counter += 1 + if evaluation.shape != ydata.shape: + if evaluation.size == ydata.size: + msg = "Supplied function does not return a proper array of floats." + msg += "\nFunction should be rewritten to return a 1D array of floats." + msg += "\nTrying to reshape output." + _logger.warning(msg) + evaluation.shape = ydata.shape + if False in numpy.isfinite(evaluation): + msg = "Supplied function unable to handle non-finite x data" + msg += "\nAttempting to filter out those x data values." + _logger.warning(msg) + filter_xdata = True + else: + filter_xdata = False + evaluation = None + except: + # function cannot handle input data + filter_xdata = True + if filter_xdata: + if xdata.size != ydata.size: + raise ValueError("xdata contains non-finite data that cannot be filtered") + else: + # we leave the xdata as they where + old_shape = xdata.shape + xdata.shape = ydata.shape + idx0 = numpy.isfinite(xdata) + xdata.shape = old_shape + ydata = ydata[idx0] + xdata = xdata[idx] + sigma = sigma[idx0] + weight = 1.0 / (sigma + numpy.equal(sigma, 0)) + weight0 = weight * weight + + nparameters = len(parameters) + + if epsfcn is None: + epsfcn = numpy.finfo(numpy.float64).eps + else: + epsfcn = max(epsfcn, numpy.finfo(numpy.float64).eps) + + # check if constraints have been passed as text + constrained_fit = False + if constraints is not None: + # make sure we work with a list of lists + input_constraints = constraints + tmp_constraints = [None] * len(input_constraints) + for i in range(nparameters): + tmp_constraints[i] = list(input_constraints[i]) + constraints = tmp_constraints + for i in range(nparameters): + if hasattr(constraints[i][0], "upper"): + txt = constraints[i][0].upper() + if txt == "FREE": + constraints[i][0] = CFREE + elif txt == "POSITIVE": + constraints[i][0] = CPOSITIVE + elif txt == "QUOTED": + constraints[i][0] = CQUOTED + elif txt == "FIXED": + constraints[i][0] = CFIXED + elif txt == "FACTOR": + constraints[i][0] = CFACTOR + constraints[i][1] = int(constraints[i][1]) + elif txt == "DELTA": + constraints[i][0] = CDELTA + constraints[i][1] = int(constraints[i][1]) + elif txt == "SUM": + constraints[i][0] = CSUM + constraints[i][1] = int(constraints[i][1]) + elif txt in ["IGNORED", "IGNORE"]: + constraints[i][0] = CIGNORED + else: + #I should raise an exception + raise ValueError("Unknown constraint %s" % constraints[i][0]) + if constraints[i][0] > 0: + constrained_fit = True + if constrained_fit: + if full_output is None: + _logger.info("Recommended to set full_output to True when using constraints") + + # Levenberg-Marquardt algorithm + fittedpar = parameters.__copy__() + flambda = 0.001 + iiter = max_iter + #niter = 0 + last_evaluation=None + x = xdata + y = ydata + chisq0 = -1 + iteration_counter = 0 + while (iiter > 0): + weight = weight0 + """ + I cannot evaluate the initial chisq here because I do not know + if some parameters are to be ignored, otherways I could do it as follows: + if last_evaluation is None: + yfit = model(x, *fittedpar) + last_evaluation = yfit + chisq0 = (weight * pow(y-yfit, 2)).sum() + and chisq would not need to be recalculated. + Passing the last_evaluation assumes that there are no parameters being + ignored or not between calls. + """ + iteration_counter += 1 + chisq0, alpha0, beta, internal_output = chisq_alpha_beta( + model, fittedpar, + x, y, weight, constraints=constraints, + model_deriv=model_deriv, + epsfcn=epsfcn, + left_derivative=left_derivative, + last_evaluation=last_evaluation, + full_output=True) + n_free = internal_output["n_free"] + free_index = internal_output["free_index"] + noigno = internal_output["noigno"] + fitparam = internal_output["fitparam"] + function_calls = internal_output["function_calls"] + function_call_counter += function_calls + #print("chisq0 = ", chisq0, n_free, fittedpar) + #raise + nr, nc = alpha0.shape + flag = 0 + #lastdeltachi = chisq0 + while flag == 0: + alpha = alpha0 * (1.0 + flambda * numpy.identity(nr)) + deltapar = numpy.dot(beta, inv(alpha)) + if constraints is None: + newpar = fitparam + deltapar [0] + else: + newpar = parameters.__copy__() + pwork = numpy.zeros(deltapar.shape, numpy.float64) + for i in range(n_free): + if constraints is None: + pwork [0] [i] = fitparam [i] + deltapar [0] [i] + elif constraints [free_index[i]][0] == CFREE: + pwork [0] [i] = fitparam [i] + deltapar [0] [i] + elif constraints [free_index[i]][0] == CPOSITIVE: + #abs method + pwork [0] [i] = fitparam [i] + deltapar [0] [i] + #square method + #pwork [0] [i] = (numpy.sqrt(fitparam [i]) + deltapar [0] [i]) * \ + # (numpy.sqrt(fitparam [i]) + deltapar [0] [i]) + elif constraints[free_index[i]][0] == CQUOTED: + pmax = max(constraints[free_index[i]][1], + constraints[free_index[i]][2]) + pmin = min(constraints[free_index[i]][1], + constraints[free_index[i]][2]) + A = 0.5 * (pmax + pmin) + B = 0.5 * (pmax - pmin) + if B != 0: + pwork [0] [i] = A + \ + B * numpy.sin(numpy.arcsin((fitparam[i] - A)/B)+ \ + deltapar [0] [i]) + else: + txt = "Error processing constrained fit\n" + txt += "Parameter limits are %g and %g\n" % (pmin, pmax) + txt += "A = %g B = %g" % (A, B) + raise ValueError("Invalid parameter limits") + newpar[free_index[i]] = pwork [0] [i] + newpar = numpy.array(_get_parameters(newpar, constraints)) + workpar = numpy.take(newpar, noigno) + yfit = model(x, *workpar) + if last_evaluation is None: + if len(yfit.shape) > 1: + msg = "Supplied function does not return a 1D array of floats." + msg += "\nFunction should be rewritten." + msg += "\nTrying to reshape output." + _logger.warning(msg) + yfit.shape = -1 + function_call_counter += 1 + chisq = (weight * pow(y-yfit, 2)).sum() + absdeltachi = chisq0 - chisq + if absdeltachi < 0: + flambda *= 10.0 + if flambda > 1000: + flag = 1 + iiter = 0 + else: + flag = 1 + fittedpar = newpar.__copy__() + lastdeltachi = 100 * (absdeltachi / (chisq + (chisq == 0))) + if iteration_counter < 2: + # ignore any limit, the fit *has* to be improved + pass + elif (lastdeltachi) < deltachi: + iiter = 0 + elif absdeltachi < numpy.sqrt(epsfcn): + iiter = 0 + _logger.info("Iteration finished due to too small absolute chi decrement") + chisq0 = chisq + flambda = flambda / 10.0 + last_evaluation = yfit + iiter = iiter - 1 + # this is the covariance matrix of the actually fitted parameters + cov0 = inv(alpha0) + if constraints is None: + cov = cov0 + else: + # yet another call needed with all the parameters being free except those + # that are FIXED and that will be assigned a 100 % uncertainty. + new_constraints = copy.deepcopy(constraints) + flag_special = [0] * len(fittedpar) + for idx, constraint in enumerate(constraints): + if constraints[idx][0] in [CFIXED, CIGNORED]: + flag_special[idx] = constraints[idx][0] + else: + new_constraints[idx][0] = CFREE + new_constraints[idx][1] = 0 + new_constraints[idx][2] = 0 + chisq, alpha, beta, internal_output = chisq_alpha_beta( + model, fittedpar, + x, y, weight, constraints=new_constraints, + model_deriv=model_deriv, + epsfcn=epsfcn, + left_derivative=left_derivative, + last_evaluation=last_evaluation, + full_output=True) + # obtained chisq should be identical to chisq0 + try: + cov = inv(alpha) + except LinAlgError: + _logger.critical("Error calculating covariance matrix after successful fit") + cov = None + if cov is not None: + for idx, value in enumerate(flag_special): + if value in [CFIXED, CIGNORED]: + cov = numpy.insert(numpy.insert(cov, idx, 0, axis=1), idx, 0, axis=0) + cov[idx, idx] = fittedpar[idx] * fittedpar[idx] + + if not full_output: + return fittedpar, cov + else: + sigma0 = numpy.sqrt(abs(numpy.diag(cov0))) + sigmapar = _get_sigma_parameters(fittedpar, sigma0, constraints) + ddict = {} + ddict["chisq"] = chisq0 + ddict["reduced_chisq"] = chisq0 / (len(yfit)-n_free) + ddict["covariance"] = cov0 + ddict["uncertainties"] = sigmapar + ddict["fvec"] = last_evaluation + ddict["nfev"] = function_call_counter + ddict["niter"] = iteration_counter + return fittedpar, cov, ddict #, chisq/(len(yfit)-len(sigma0)), sigmapar,niter,lastdeltachi + +def chisq_alpha_beta(model, parameters, x, y, weight, constraints=None, + model_deriv=None, epsfcn=None, left_derivative=False, + last_evaluation=None, full_output=False): + + """ + Get chi square, the curvature matrix alpha and the matrix beta according to the input parameters. + If all the parameters are unconstrained, the covariance matrix is the inverse of the alpha matrix. + + :param model: callable + The model function, f(x, ...). It must take the independent + variable as the first argument and the parameters to fit as + separate remaining arguments. + The returned value is a one dimensional array of floats. + + :param parameters: N-length sequence + Values of parameters at which function and derivatives are to be calculated. + + :param x: An M-length sequence. + The independent variable where the data is measured. + + :param y: An M-length sequence + The dependent data --- nominally f(xdata, ...) + + :param weight: M-length sequence + Weights to be applied in the calculation of chi square + As a reminder ``chisq = np.sum(weigth * (model(x, *parameters) - y)**2)`` + + :param constraints: + If provided, it is a 2D sequence of dimension (n_parameters, 3) where, + for each parameter denoted by the index i, the meaning is + + - constraints[i][0] + + - 0 - Free (CFREE) + - 1 - Positive (CPOSITIVE) + - 2 - Quoted (CQUOTED) + - 3 - Fixed (CFIXED) + - 4 - Factor (CFACTOR) + - 5 - Delta (CDELTA) + - 6 - Sum (CSUM) + + + - constraints[i][1] + + - Ignored if constraints[i][0] is 0, 1, 3 + - Min value of the parameter if constraints[i][0] is CQUOTED + - Index of fitted parameter to which it is related + + - constraints[i][2] + + - Ignored if constraints[i][0] is 0, 1, 3 + - Max value of the parameter if constraints[i][0] is CQUOTED + - Factor to apply to related parameter with index constraints[i][1] + - Difference with parameter with index constraints[i][1] + - Sum obtained when adding parameter with index constraints[i][1] + :type constraints: *optional*, None or 2D sequence + + :param model_deriv: + None (default) or function providing the derivatives of the fitting function respect to the fitted parameters. + It will be called as model_deriv(xdata, parameters, index) where parameters is a sequence with the current + values of the fitting parameters, index is the fitting parameter index for which the the derivative has + to be provided in the supplied array of xdata points. + :type model_deriv: *optional*, None or callable + + + :param epsfcn: float + A variable used in determining a suitable parameter variation when + calculating the numerical derivatives (for model_deriv=None). + Normally the actual step length will be sqrt(epsfcn)*x + Original Gefit module was using epsfcn 1.0e-10 while default value + is now numpy.finfo(numpy.float64).eps as in scipy + :type epsfcn: *optional*, float + + :param left_derivative: + This parameter only has an influence if no derivative function + is provided. When True the left and right derivatives of the + model will be calculated for each fitted parameters thus leading to + the double number of function evaluations. Default is False. + Original Gefit module was always using left_derivative as True. + :type left_derivative: *optional*, bool + + :param last_evaluation: An M-length array + Used for optimization purposes. If supplied, this array will be taken as the result of + evaluating the function, that is as the result of ``model(x, *parameters)`` thus avoiding + the evaluation call. + + :param full_output: bool, optional + Additional output used for internal purposes with the keys: + ``function_calls`` + The number of model function calls performed. + ``fitparam`` + A sequence with the actual free parameters + ``free_index`` + Sequence with the indices of the free parameters in input parameters sequence. + ``noigno`` + Sequence with the indices of the original parameters considered in the calculations. + """ + if epsfcn is None: + epsfcn = numpy.finfo(numpy.float64).eps + else: + epsfcn = max(epsfcn, numpy.finfo(numpy.float64).eps) + #nr0, nc = data.shape + n_param = len(parameters) + if constraints is None: + derivfactor = numpy.ones((n_param, )) + n_free = n_param + noigno = numpy.arange(n_param) + free_index = noigno * 1 + fitparam = parameters * 1 + else: + n_free = 0 + fitparam = [] + free_index = [] + noigno = [] + derivfactor = [] + for i in range(n_param): + if constraints[i][0] != CIGNORED: + noigno.append(i) + if constraints[i][0] == CFREE: + fitparam.append(parameters [i]) + derivfactor.append(1.0) + free_index.append(i) + n_free += 1 + elif constraints[i][0] == CPOSITIVE: + fitparam.append(abs(parameters[i])) + derivfactor.append(1.0) + #fitparam.append(numpy.sqrt(abs(parameters[i]))) + #derivfactor.append(2.0*numpy.sqrt(abs(parameters[i]))) + free_index.append(i) + n_free += 1 + elif constraints[i][0] == CQUOTED: + pmax = max(constraints[i][1], constraints[i][2]) + pmin =min(constraints[i][1], constraints[i][2]) + if ((pmax-pmin) > 0) & \ + (parameters[i] <= pmax) & \ + (parameters[i] >= pmin): + A = 0.5 * (pmax + pmin) + B = 0.5 * (pmax - pmin) + fitparam.append(parameters[i]) + derivfactor.append(B*numpy.cos(numpy.arcsin((parameters[i] - A)/B))) + free_index.append(i) + n_free += 1 + elif (pmax-pmin) > 0: + print("WARNING: Quoted parameter outside boundaries") + print("Initial value = %f" % parameters[i]) + print("Limits are %f and %f" % (pmin, pmax)) + print("Parameter will be kept at its starting value") + fitparam = numpy.array(fitparam, numpy.float64) + alpha = numpy.zeros((n_free, n_free), numpy.float64) + beta = numpy.zeros((1, n_free), numpy.float64) + #delta = (fitparam + numpy.equal(fitparam, 0.0)) * 0.00001 + delta = (fitparam + numpy.equal(fitparam, 0.0)) * numpy.sqrt(epsfcn) + nr = y.size + ############## + # Prior to each call to the function one has to re-calculate the + # parameters + pwork = parameters.__copy__() + for i in range(n_free): + pwork [free_index[i]] = fitparam [i] + if n_free == 0: + raise ValueError("No free parameters to fit") + function_calls = 0 + if not left_derivative: + if last_evaluation is not None: + f2 = last_evaluation + else: + f2 = model(x, *parameters) + f2.shape = -1 + function_calls += 1 + for i in range(n_free): + if model_deriv is None: + #pwork = parameters.__copy__() + pwork[free_index[i]] = fitparam [i] + delta [i] + newpar = _get_parameters(pwork.tolist(), constraints) + newpar = numpy.take(newpar, noigno) + f1 = model(x, *newpar) + f1.shape = -1 + function_calls += 1 + if left_derivative: + pwork[free_index[i]] = fitparam [i] - delta [i] + newpar = _get_parameters(pwork.tolist(), constraints) + newpar=numpy.take(newpar, noigno) + f2 = model(x, *newpar) + function_calls += 1 + help0 = (f1 - f2) / (2.0 * delta[i]) + else: + help0 = (f1 - f2) / (delta[i]) + help0 = help0 * derivfactor[i] + pwork[free_index[i]] = fitparam [i] + #removed I resize outside the loop: + #help0 = numpy.resize(help0, (1, nr)) + else: + help0 = model_deriv(x, pwork, free_index[i]) + help0 = help0 * derivfactor[i] + + if i == 0: + deriv = help0 + else: + deriv = numpy.concatenate((deriv, help0), 0) + + #line added to resize outside the loop + deriv = numpy.resize(deriv, (n_free, nr)) + if last_evaluation is None: + if constraints is None: + yfit = model(x, *fitparam) + yfit.shape = -1 + else: + newpar = _get_parameters(pwork.tolist(), constraints) + newpar = numpy.take(newpar, noigno) + yfit = model(x, *newpar) + yfit.shape = -1 + function_calls += 1 + else: + yfit = last_evaluation + deltay = y - yfit + help0 = weight * deltay + for i in range(n_free): + derivi = numpy.resize(deriv[i, :], (1, nr)) + help1 = numpy.resize(numpy.sum((help0 * derivi), 1), (1, 1)) + if i == 0: + beta = help1 + else: + beta = numpy.concatenate((beta, help1), 1) + help1 = numpy.inner(deriv, weight*derivi) + if i == 0: + alpha = help1 + else: + alpha = numpy.concatenate((alpha, help1), 1) + chisq = (help0 * deltay).sum() + if full_output: + ddict = {} + ddict["n_free"] = n_free + ddict["free_index"] = free_index + ddict["noigno"] = noigno + ddict["fitparam"] = fitparam + ddict["derivfactor"] = derivfactor + ddict["function_calls"] = function_calls + return chisq, alpha, beta, ddict + else: + return chisq, alpha, beta + + +def _get_parameters(parameters, constraints): + """ + Apply constraints to input parameters. + + Parameters not depending on other parameters, they are returned as the input. + + Parameters depending on other parameters, return the value after applying the + relation to the parameter wo which they are related. + """ + # 0 = Free 1 = Positive 2 = Quoted + # 3 = Fixed 4 = Factor 5 = Delta + if constraints is None: + return parameters * 1 + newparam = [] + #first I make the free parameters + #because the quoted ones put troubles + for i in range(len(constraints)): + if constraints[i][0] == CFREE: + newparam.append(parameters[i]) + elif constraints[i][0] == CPOSITIVE: + #newparam.append(parameters[i] * parameters[i]) + newparam.append(abs(parameters[i])) + elif constraints[i][0] == CQUOTED: + newparam.append(parameters[i]) + elif abs(constraints[i][0]) == CFIXED: + newparam.append(parameters[i]) + else: + newparam.append(parameters[i]) + for i in range(len(constraints)): + if constraints[i][0] == CFACTOR: + newparam[i] = constraints[i][2] * newparam[int(constraints[i][1])] + elif constraints[i][0] == CDELTA: + newparam[i] = constraints[i][2] + newparam[int(constraints[i][1])] + elif constraints[i][0] == CIGNORED: + # The whole ignored stuff should not be documented because setting + # a parameter to 0 is not the same as being ignored. + # Being ignored should imply the parameter is simply not accounted for + # and should be stripped out of the list of parameters by the program + # using this module + newparam[i] = 0 + elif constraints[i][0] == CSUM: + newparam[i] = constraints[i][2]-newparam[int(constraints[i][1])] + return newparam + + +def _get_sigma_parameters(parameters, sigma0, constraints): + """ + Internal function propagating the uncertainty on the actually fitted parameters and related parameters to the + final parameters considering the applied constraints. + + Parameters + ---------- + parameters : 1D sequence of length equal to the number of free parameters N + The parameters actually used in the fitting process. + sigma0 : 1D sequence of length N + Uncertainties calculated as the square-root of the diagonal of + the covariance matrix + constraints : The set of constraints applied in the fitting process + """ + # 0 = Free 1 = Positive 2 = Quoted + # 3 = Fixed 4 = Factor 5 = Delta + if constraints is None: + return sigma0 + n_free = 0 + sigma_par = numpy.zeros(parameters.shape, numpy.float64) + for i in range(len(constraints)): + if constraints[i][0] == CFREE: + sigma_par [i] = sigma0[n_free] + n_free += 1 + elif constraints[i][0] == CPOSITIVE: + #sigma_par [i] = 2.0 * sigma0[n_free] + sigma_par [i] = sigma0[n_free] + n_free += 1 + elif constraints[i][0] == CQUOTED: + pmax = max(constraints [i][1], constraints [i][2]) + pmin = min(constraints [i][1], constraints [i][2]) + # A = 0.5 * (pmax + pmin) + B = 0.5 * (pmax - pmin) + if (B > 0) & (parameters [i] < pmax) & (parameters [i] > pmin): + sigma_par [i] = abs(B * numpy.cos(parameters[i]) * sigma0[n_free]) + n_free += 1 + else: + sigma_par [i] = parameters[i] + elif abs(constraints[i][0]) == CFIXED: + sigma_par[i] = parameters[i] + for i in range(len(constraints)): + if constraints[i][0] == CFACTOR: + sigma_par [i] = constraints[i][2]*sigma_par[int(constraints[i][1])] + elif constraints[i][0] == CDELTA: + sigma_par [i] = sigma_par[int(constraints[i][1])] + elif constraints[i][0] == CSUM: + sigma_par [i] = sigma_par[int(constraints[i][1])] + return sigma_par + + +def main(argv=None): + if argv is None: + npoints = 10000 + elif hasattr(argv, "__len__"): + if len(argv) > 1: + npoints = int(argv[1]) + else: + print("Usage:") + print("fit [npoints]") + else: + # expected a number + npoints = argv + + def gauss(t0, *param0): + param = numpy.array(param0) + t = numpy.array(t0) + dummy = 2.3548200450309493 * (t - param[3]) / param[4] + return param[0] + param[1] * t + param[2] * myexp(-0.5 * dummy * dummy) + + + def myexp(x): + # put a (bad) filter to avoid over/underflows + # with no python looping + return numpy.exp(x * numpy.less(abs(x), 250)) -\ + 1.0 * numpy.greater_equal(abs(x), 250) + + xx = numpy.arange(npoints, dtype=numpy.float64) + yy = gauss(xx, *[10.5, 2, 1000.0, 20., 15]) + sy = numpy.sqrt(abs(yy)) + parameters = [0.0, 1.0, 900.0, 25., 10] + stime = time.time() + + fittedpar, cov, ddict = leastsq(gauss, xx, yy, parameters, + sigma=sy, + left_derivative=False, + full_output=True, + check_finite=True) + etime = time.time() + sigmapars = numpy.sqrt(numpy.diag(cov)) + print("Took ", etime - stime, "seconds") + print("Function calls = ", ddict["nfev"]) + print("chi square = ", ddict["chisq"]) + print("Fitted pars = ", fittedpar) + print("Sigma pars = ", sigmapars) + try: + from scipy.optimize import curve_fit as cfit + SCIPY = True + except ImportError: + SCIPY = False + if SCIPY: + counter = 0 + stime = time.time() + scipy_fittedpar, scipy_cov = cfit(gauss, + xx, + yy, + parameters, + sigma=sy) + etime = time.time() + print("Scipy Took ", etime - stime, "seconds") + print("Counter = ", counter) + print("scipy = ", scipy_fittedpar) + print("Sigma = ", numpy.sqrt(numpy.diag(scipy_cov))) + +if __name__ == "__main__": + main() diff --git a/src/silx/math/fit/peaks.pyx b/src/silx/math/fit/peaks.pyx new file mode 100644 index 0000000..a4fce89 --- /dev/null +++ b/src/silx/math/fit/peaks.pyx @@ -0,0 +1,175 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +"""This module provides a peak search function and tools related to peak +analysis. +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "15/05/2017" + +import logging +import numpy + +from silx.math.fit import filters + +_logger = logging.getLogger(__name__) + +cimport cython +from libc.stdlib cimport free + +cimport silx.math.fit.peaks_wrapper as peaks_wrapper + + +def peak_search(y, fwhm, sensitivity=3.5, + begin_index=None, end_index=None, + debug=False, relevance_info=False): + """Find peaks in a curve. + + :param y: Data array + :type y: numpy.ndarray + :param fwhm: Estimated full width at half maximum of the typical peaks we + are interested in (expressed in number of samples) + :param sensitivity: Threshold factor used for peak detection. Only peaks + with amplitudes higher than ``σ * sensitivity`` - where ``σ`` is the + standard deviation of the noise - qualify as peaks. + :param begin_index: Index of the first sample of the region of interest + in the ``y`` array. If ``None``, start from the first sample. + :param end_index: Index of the last sample of the region of interest in + the ``y`` array. If ``None``, process until the last sample. + :param debug: If ``True``, print debug messages. Default: ``False`` + :param relevance_info: If ``True``, add a second dimension with relevance + information to the output array. Default: ``False`` + :return: 1D sequence with indices of peaks in the data + if ``relevance_info`` is ``False``. + Else, sequence of ``(peak_index, peak_relevance)`` tuples (one tuple + per peak). + :raise: ``IndexError`` if the number of peaks is too large to fit in the + output array. + """ + cdef: + int i + double[::1] y_c + double* peaks_c + double* relevances_c + + y_c = numpy.array(y, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + if debug: + debug = 1 + else: + debug = 0 + + if begin_index is None: + begin_index = 0 + if end_index is None: + end_index = y_c.size - 1 + + n_peaks = peaks_wrapper.seek(begin_index, end_index, y_c.size, + fwhm, sensitivity, debug, + &y_c[0], &peaks_c, &relevances_c) + + + # A negative return value means that peaks were found but not enough + # memory could be allocated for all + if n_peaks < 0 and n_peaks != -123456: + msg = "Before memory allocation error happened, " + msg += "we found %d peaks.\n" % abs(n_peaks) + _logger.debug(msg) + msg = "" + for i in range(abs(n_peaks)): + msg += "peak index %f, " % peaks_c[i] + msg += "relevance %f\n" % relevances_c[i] + _logger.debug(msg) + free(peaks_c) + free(relevances_c) + raise MemoryError("Failed to reallocate memory for output arrays") + # Special value -123456 is returned if the initial memory allocation + # fails, before any search could be performed + elif n_peaks == -123456: + raise MemoryError("Failed to allocate initial memory for " + + "output arrays") + + peaks = numpy.empty(shape=(n_peaks,), + dtype=numpy.float64) + relevances = numpy.empty(shape=(n_peaks,), + dtype=numpy.float64) + + for i in range(n_peaks): + peaks[i] = peaks_c[i] + relevances[i] = relevances_c[i] + + free(peaks_c) + free(relevances_c) + + if not relevance_info: + return peaks + else: + return list(zip(peaks, relevances)) + + +def guess_fwhm(y): + """Return the full-width at half maximum for the largest peak in + the data array. + + The algorithm removes the background, then finds a global maximum + and its corresponding FWHM. + + This value can be used as an initial fit parameter, used as input for + an iterative fit function. + + :param y: Data to be used for guessing the fwhm. + :return: Estimation of full-width at half maximum, based on fwhm of + the global maximum. + """ + # set at a minimum value for the fwhm + fwhm_min = 4 + + # remove data background (computed with a strip filter) + background = filters.strip(y, w=1, niterations=1000) + yfit = y - background + + # basic peak search: find the global maximum + maximum = max(yfit) + # find indices of all values == maximum + idx = numpy.nonzero(yfit == maximum)[0] + # take the last one (if any) + if not len(idx): + return 0 + posindex = idx[-1] + height = yfit[posindex] + + # now find the width of the peak at half maximum + imin = posindex + while yfit[imin] > 0.5 * height and imin > 0: + imin -= 1 + imax = posindex + while yfit[imax] > 0.5 * height and imax < len(yfit) - 1: + imax += 1 + + fwhm = max(imax - imin - 1, fwhm_min) + + return fwhm diff --git a/src/silx/math/fit/peaks/include/peaks.h b/src/silx/math/fit/peaks/include/peaks.h new file mode 100644 index 0000000..bd25d96 --- /dev/null +++ b/src/silx/math/fit/peaks/include/peaks.h @@ -0,0 +1,32 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#ifndef PEAKS_H +#define PEAKS_H + +/* Smoothing functions */ + +long seek(long begin_index, long end_index, long nsamples, double fwhm, double sensitivity, + double debug_info, double *data, double **peaks, double **relevances); + +#endif /* #define PEAKS_H */ diff --git a/src/silx/math/fit/peaks/src/peaks.c b/src/silx/math/fit/peaks/src/peaks.c new file mode 100644 index 0000000..65cb4f6 --- /dev/null +++ b/src/silx/math/fit/peaks/src/peaks.c @@ -0,0 +1,255 @@ +#/*########################################################################## +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +#include <math.h> +#include <stdlib.h> +#include <stdio.h> +#include "peaks.h" + + +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +/* Peak search function, adapted from PyMca SpecFitFuns + + This uses a convolution with the second-derivative of a gaussian curve, to + smooth the data. + + Arguments: + + - begin_index: First index of the region of interest in the input data + array + - end_index: Last index of the region of interest in the input data + array + - nsamples: Number of samples in the input array + - fwhm: Full width at half maximum for the gaussian used for smoothing. + - sensitivity: + - debug_info: If different from 0, print debugging messages + - data: input array of 1D data + - peaks: pointer to output array of peak indices + - relevances: pointer to output array of peak relevances +*/ +long seek(long begin_index, + long end_index, + long nsamples, + double fwhm, + double sensitivity, + double debug_info, + double *data, + double **peaks, + double **relevances) +{ + /* local variables */ + double *peaks0, *relevances0; + double *realloc_peaks, *realloc_relevances; + double sigma, sigma2, sigma4; + long max_gfactor = 100; + double gfactor[100]; + long nr_factor; + double lowthreshold; + double data2[2]; + double nom; + double den2; + long channel1; + long lld; + long cch; + long cfac, cfac2, max_cfac; + long ihelp1, ihelp2; + long i; + long max_npeaks = 100; + long n_peaks = 0; + double peakstarted = 0; + + peaks0 = malloc(100 * sizeof(double)); + relevances0 = malloc(100 * sizeof(double)); + if (peaks0 == NULL || relevances0 == NULL) { + printf("Error: failed to allocate memory for peaks array."); + return(-123456); + } + /* Make sure the peaks matrix is filled with zeros */ + for (i=0;i<100;i++){ + peaks0[i] = 0.0; + relevances0[i] = 0.0; + } + /* Output pointers */ + *peaks = peaks0; + *relevances = relevances0; + + /* prepare the calculation of the Gaussian scaling factors */ + + sigma = fwhm / 2.35482; + sigma2 = sigma * sigma; + sigma4 = sigma2 * sigma2; + lowthreshold = 0.01 / sigma2; + + /* calculate the factors until lower threshold reached */ + nr_factor = 0; + max_cfac = MIN(max_gfactor, ((end_index - begin_index - 2) / 2) - 1); + for (cfac=0; cfac < max_cfac; cfac++) { + nr_factor++; + cfac2 = (cfac+1) * (cfac+1); + gfactor[cfac] = (sigma2 - cfac2) * exp(-cfac2/(sigma2*2.0)) / sigma4; + + if ((gfactor[cfac] < lowthreshold) + && (gfactor[cfac] > (-lowthreshold))){ + break; + } + } + + /* What comes now is specific to MCA spectra ... */ + lld = 0; + while (data[lld] == 0) { + lld++; + } + lld = lld + (int) (0.5 * fwhm); + + channel1 = begin_index - nr_factor - 1; + channel1 = MAX (channel1, lld); + if(debug_info){ + printf("nrfactor = %ld\n", nr_factor); + } + /* calculates smoothed value and variance at begincalc */ + cch = MAX(begin_index, 0); + nom = data[cch] / sigma2; + den2 = data[cch] / sigma4; + for (cfac = 0; cfac < nr_factor; cfac++){ + ihelp1 = cch-cfac; + if (ihelp1 < 0){ + ihelp1 = 0; + } + ihelp2 = cch+cfac; + if (ihelp2 >= nsamples){ + ihelp2 = nsamples-1; + } + nom += gfactor[cfac] * (data[ihelp2] + data[ihelp1]); + den2 += gfactor[cfac] * gfactor[cfac] * + (data[ihelp2] + data[ihelp1]); + } + + /* now normalize the smoothed value to the standard deviation */ + if (den2 <= 0.0) { + data2[1] = 0.0; + }else{ + data2[1] = nom / sqrt(den2); + } + data[0] = data[1]; + + while (cch <= MIN(end_index,nsamples-2)){ + /* calculate gaussian smoothed values */ + data2[0] = data2[1]; + cch++; + nom = data[cch]/sigma2; + den2 = data[cch] / sigma4; + for (cfac = 1; cfac < nr_factor; cfac++){ + ihelp1 = cch-cfac; + if (ihelp1 < 0){ + ihelp1 = 0; + } + ihelp2 = cch+cfac; + if (ihelp2 >= nsamples){ + ihelp2 = nsamples-1; + } + nom += gfactor[cfac-1] * (data[ihelp2] + data[ihelp1]); + den2 += gfactor[cfac-1] * gfactor[cfac-1] * + (data[ihelp2] + data[ihelp1]); + } + /* now normalize the smoothed value to the standard deviation */ + if (den2 <= 0) { + data2[1] = 0; + }else{ + data2[1] = nom / sqrt(den2); + } + /* look if the current point falls in a peak */ + if (data2[1] > sensitivity) { + if(peakstarted == 0){ + if (data2[1] > data2[0]){ + /* this second test is to prevent a peak from outside + the region from being detected at the beginning of the search */ + peakstarted=1; + } + } + /* there is a peak */ + if (debug_info){ + printf("At cch = %ld y[cch] = %g\n", cch, data[cch]); + printf("data2[0] = %g\n", data2[0]); + printf("data2[1] = %g\n", data2[1]); + printf("sensitivity = %g\n", sensitivity); + } + if(peakstarted == 1){ + /* look for the top of the peak */ + if (data2[1] < data2[0]) { + /* we are close to the top of the peak */ + if (debug_info){ + printf("we are close to the top of the peak\n"); + } + if (n_peaks == max_npeaks) { + max_npeaks = max_npeaks + 100; + realloc_peaks = realloc(peaks0, max_npeaks * sizeof(double)); + realloc_relevances = realloc(relevances0, max_npeaks * sizeof(double)); + if (realloc_peaks == NULL || realloc_relevances == NULL) { + printf("Error: failed to extend memory for peaks array."); + *peaks = peaks0; + *relevances = relevances0; + return(-n_peaks); + } + else { + peaks0 = realloc_peaks; + relevances0 = realloc_relevances; + } + } + peaks0[n_peaks] = cch-1; + relevances0[n_peaks] = data2[0]; + n_peaks++; + peakstarted=2; + } + } + /* Doublet case */ + if(peakstarted == 2){ + if ((cch-peaks0[n_peaks-1]) > 0.6 * fwhm) { + if (data2[1] > data2[0]){ + if(debug_info){ + printf("We may have a doublet\n"); + } + peakstarted=1; + } + } + } + }else{ + if (peakstarted==1){ + /* We were on a peak but we did not find the top */ + if(debug_info){ + printf("We were on a peak but we did not find the top\n"); + } + } + peakstarted=0; + } + } + if(debug_info){ + for (i=0;i< n_peaks;i++){ + printf("Peak %ld found at ",i+1); + printf("index %g with y = %g\n", peaks0[i],data[(long ) peaks0[i]]); + } + } + *peaks = peaks0; + *relevances = relevances0; + return (n_peaks); +} diff --git a/src/silx/math/fit/peaks_wrapper.pxd b/src/silx/math/fit/peaks_wrapper.pxd new file mode 100644 index 0000000..4c77dc6 --- /dev/null +++ b/src/silx/math/fit/peaks_wrapper.pxd @@ -0,0 +1,41 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "22/06/2016" + +cimport cython + +cdef extern from "peaks.h": + long seek(long begin_index, + long end_index, + long nsamples, + double fwhm, + double sensitivity, + double debug_info, + double * data, + double ** peaks, + double ** relevances) + diff --git a/src/silx/math/fit/setup.py b/src/silx/math/fit/setup.py new file mode 100644 index 0000000..649387f --- /dev/null +++ b/src/silx/math/fit/setup.py @@ -0,0 +1,85 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "22/06/2016" + + +import os.path + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('fit', parent_package, top_path) + config.add_subpackage('test') + + # ===================================== + # fit functions + # ===================================== + fun_src = [os.path.join('functions', "src", "funs.c"), + "functions.pyx"] + fun_inc = [os.path.join('functions', 'include')] + + config.add_extension('functions', + sources=fun_src, + include_dirs=fun_inc, + language='c') + + # ===================================== + # fit filters + # ===================================== + filt_src = [os.path.join('filters', "src", srcf) + for srcf in ["smoothnd.c", "snip1d.c", + "snip2d.c", "snip3d.c", "strip.c"]] + filt_src.append("filters.pyx") + filt_inc = [os.path.join('filters', 'include')] + + config.add_extension('filters', + sources=filt_src, + include_dirs=filt_inc, + language='c') + + # ===================================== + # peaks + # ===================================== + peaks_src = [os.path.join('peaks', "src", "peaks.c"), + "peaks.pyx"] + peaks_inc = [os.path.join('peaks', 'include')] + + config.add_extension('peaks', + sources=peaks_src, + include_dirs=peaks_inc, + language='c') + # ===================================== + # ===================================== + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + + setup(configuration=configuration) diff --git a/src/silx/math/fit/test/__init__.py b/src/silx/math/fit/test/__init__.py new file mode 100644 index 0000000..745efe3 --- /dev/null +++ b/src/silx/math/fit/test/__init__.py @@ -0,0 +1,23 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ diff --git a/src/silx/math/fit/test/test_bgtheories.py b/src/silx/math/fit/test/test_bgtheories.py new file mode 100644 index 0000000..6620d38 --- /dev/null +++ b/src/silx/math/fit/test/test_bgtheories.py @@ -0,0 +1,154 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +import copy +import unittest +import numpy +import random + +from silx.math.fit import bgtheories +from silx.math.fit.functions import sum_gauss + + +class TestBgTheories(unittest.TestCase): + """ + """ + def setUp(self): + self.x = numpy.arange(100) + self.y = 10 + 0.05 * self.x + sum_gauss(self.x, 10., 45., 15.) + # add a very narrow high amplitude peak to test strip and snip + self.y += sum_gauss(self.x, 100., 75., 2.) + self.narrow_peak_index = list(self.x).index(75) + random.seed() + + def tearDown(self): + pass + + def testTheoriesAttrs(self): + for theory_name in bgtheories.THEORY: + self.assertIsInstance(theory_name, str) + self.assertTrue(hasattr(bgtheories.THEORY[theory_name], + "function")) + self.assertTrue(hasattr(bgtheories.THEORY[theory_name].function, + "__call__")) + # Ensure legacy functions are not renamed accidentally + self.assertTrue( + {"No Background", "Constant", "Linear", "Strip", "Snip"}.issubset( + set(bgtheories.THEORY))) + + def testNoBg(self): + nobgfun = bgtheories.THEORY["No Background"].function + self.assertTrue(numpy.array_equal(nobgfun(self.x, self.y), + numpy.zeros_like(self.x))) + # default estimate + self.assertEqual(bgtheories.THEORY["No Background"].estimate(self.x, self.y), + ([], [])) + + def testConstant(self): + consfun = bgtheories.THEORY["Constant"].function + c = random.random() * 100 + self.assertTrue(numpy.array_equal(consfun(self.x, self.y, c), + c * numpy.ones_like(self.x))) + # default estimate + esti_par, cons = bgtheories.THEORY["Constant"].estimate(self.x, self.y) + self.assertEqual(cons, + [[0, 0, 0]]) + self.assertAlmostEqual(esti_par, + min(self.y)) + + def testLinear(self): + linfun = bgtheories.THEORY["Linear"].function + a = random.random() * 100 + b = random.random() * 100 + self.assertTrue(numpy.array_equal(linfun(self.x, self.y, a, b), + a + b * self.x)) + # default estimate + esti_par, cons = bgtheories.THEORY["Linear"].estimate(self.x, self.y) + + self.assertEqual(cons, + [[0, 0, 0], [0, 0, 0]]) + self.assertAlmostEqual(esti_par[0], 10, places=3) + self.assertAlmostEqual(esti_par[1], 0.05, places=3) + + def testStrip(self): + stripfun = bgtheories.THEORY["Strip"].function + anchors = sorted(random.sample(list(self.x), 4)) + anchors_indices = [list(self.x).index(a) for a in anchors] + + # we really want to strip away the narrow peak + anchors_indices_copy = copy.deepcopy(anchors_indices) + for idx in anchors_indices_copy: + if abs(idx - self.narrow_peak_index) < 5: + anchors_indices.remove(idx) + anchors.remove(self.x[idx]) + + width = 2 + niter = 1000 + bgtheories.THEORY["Strip"].configure(AnchorsList=anchors, AnchorsFlag=True) + + bg = stripfun(self.x, self.y, width, niter) + + # assert peak amplitude has been decreased + self.assertLess(bg[self.narrow_peak_index], + self.y[self.narrow_peak_index]) + + # default estimate + for i in anchors_indices: + self.assertEqual(bg[i], self.y[i]) + + # estimated parameters are equal to the default ones in the config dict + bgtheories.THEORY["Strip"].configure(StripWidth=7, StripIterations=8) + esti_par, cons = bgtheories.THEORY["Strip"].estimate(self.x, self.y) + self.assertTrue(numpy.array_equal(cons, [[3, 0, 0], [3, 0, 0]])) + self.assertEqual(esti_par, [7, 8]) + + def testSnip(self): + snipfun = bgtheories.THEORY["Snip"].function + anchors = sorted(random.sample(list(self.x), 4)) + anchors_indices = [list(self.x).index(a) for a in anchors] + + # we want to strip away the narrow peak, so remove nearby anchors + anchors_indices_copy = copy.deepcopy(anchors_indices) + for idx in anchors_indices_copy: + if abs(idx - self.narrow_peak_index) < 5: + anchors_indices.remove(idx) + anchors.remove(self.x[idx]) + + width = 16 + bgtheories.THEORY["Snip"].configure(AnchorsList=anchors, AnchorsFlag=True) + bg = snipfun(self.x, self.y, width) + + # assert peak amplitude has been decreased + self.assertLess(bg[self.narrow_peak_index], + self.y[self.narrow_peak_index], + "Snip didn't decrease the peak amplitude.") + + # anchored data must remain fixed + for i in anchors_indices: + self.assertEqual(bg[i], self.y[i]) + + # estimated parameters are equal to the default ones in the config dict + bgtheories.THEORY["Snip"].configure(SnipWidth=7) + esti_par, cons = bgtheories.THEORY["Snip"].estimate(self.x, self.y) + self.assertTrue(numpy.array_equal(cons, [[3, 0, 0]])) + self.assertEqual(esti_par, [7]) diff --git a/src/silx/math/fit/test/test_filters.py b/src/silx/math/fit/test/test_filters.py new file mode 100644 index 0000000..8314bdc --- /dev/null +++ b/src/silx/math/fit/test/test_filters.py @@ -0,0 +1,122 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +import numpy +import unittest +from silx.math.fit import filters +from silx.math.fit import functions +from silx.test.utils import add_relative_noise + + +class TestSmooth(unittest.TestCase): + """ + Unit tests of smoothing functions. + + Test that the difference between a synthetic curve with 5% added random + noise and the result of smoothing that signal is less than 5%. We compare + the sum of all samples in each curve. + """ + def setUp(self): + x = numpy.arange(5000) + # (height1, center1, fwhm1, beamfwhm...) + slit_params = (50, 500, 200, 100, + 50, 600, 80, 30, + 20, 2000, 150, 150, + 50, 2250, 110, 100, + 40, 3000, 50, 10, + 23, 4980, 250, 20) + + self.y1 = functions.sum_slit(x, *slit_params) + # 5% noise + self.y1 = add_relative_noise(self.y1, 5.) + + # (height1, center1, fwhm1...) + step_params = (50, 500, 200, + 50, 600, 80, + 20, 2000, 150, + 50, 2250, 110, + 40, 3000, 50, + 23, 4980, 250,) + + self.y2 = functions.sum_stepup(x, *step_params) + # 5% noise + self.y2 = add_relative_noise(self.y2, 5.) + + self.y3 = functions.sum_stepdown(x, *step_params) + # 5% noise + self.y3 = add_relative_noise(self.y3, 5.) + + def tearDown(self): + pass + + def testSavitskyGolay(self): + npts = 25 + for y in [self.y1, self.y2, self.y3]: + smoothed_y = filters.savitsky_golay(y, npoints=npts) + + # we added +-5% of random noise. The difference must be much lower + # than 5%. + diff = abs(sum(smoothed_y) - sum(y)) / sum(y) + self.assertLess(diff, 0.05, + "Difference between data with 5%% noise and " + + "smoothed data is > 5%% (%f %%)" % (diff * 100)) + + # Try various smoothing levels + npts += 25 + + def testSmooth1d(self): + """Test the 1D smoothing against the formula + ys[i] = (y[i-1] + 2 * y[i] + y[i+1]) / 4 (for 1 < i < n-1)""" + smoothed_y = filters.smooth1d(self.y1) + + for i in range(1, len(self.y1) - 1): + self.assertAlmostEqual(4 * smoothed_y[i], + self.y1[i-1] + 2 * self.y1[i] + self.y1[i+1]) + + def testSmooth2d(self): + """Test that a 2D smoothing is the same as two successive and + orthogonal 1D smoothings""" + x = numpy.arange(10000) + + noise = 2 * numpy.random.random(10000) - 1 + noise *= 0.05 + y = x * (1 + noise) + + y.shape = (100, 100) + + smoothed_y = filters.smooth2d(y) + + intermediate_smooth = numpy.zeros_like(y) + expected_smooth = numpy.zeros_like(y) + # smooth along first dimension + for i in range(0, y.shape[0]): + intermediate_smooth[i, :] = filters.smooth1d(y[i, :]) + + # smooth along second dimension + for j in range(0, y.shape[1]): + expected_smooth[:, j] = filters.smooth1d(intermediate_smooth[:, j]) + + for i in range(0, y.shape[0]): + for j in range(0, y.shape[1]): + self.assertAlmostEqual(smoothed_y[i, j], + expected_smooth[i, j]) diff --git a/src/silx/math/fit/test/test_fit.py b/src/silx/math/fit/test/test_fit.py new file mode 100644 index 0000000..00f04e2 --- /dev/null +++ b/src/silx/math/fit/test/test_fit.py @@ -0,0 +1,373 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Nominal tests of the leastsq function. +""" + +import unittest + +import numpy +import sys + +from silx.utils import testutils +from silx.math.fit.leastsq import _logger as fitlogger + + +class Test_leastsq(unittest.TestCase): + """ + Unit tests of the leastsq function. + """ + + ndims = None + + def setUp(self): + try: + from silx.math.fit import leastsq + self.instance = leastsq + except ImportError: + self.instance = None + + def myexp(x): + # put a (bad) filter to avoid over/underflows + # with no python looping + with numpy.errstate(invalid='ignore'): + return numpy.exp(x*numpy.less(abs(x), 250)) - \ + 1.0 * numpy.greater_equal(abs(x), 250) + + self.my_exp = myexp + + def gauss(x, *params): + params = numpy.array(params, copy=False, dtype=numpy.float64) + result = params[0] + params[1] * x + for i in range(2, len(params), 3): + p = params[i:(i+3)] + dummy = 2.3548200450309493*(x - p[1])/p[2] + result += p[0] * self.my_exp(-0.5 * dummy * dummy) + return result + + self.gauss = gauss + + def gauss_derivative(x, params, idx): + if idx == 0: + return numpy.ones(len(x), numpy.float64) + if idx == 1: + return x + gaussian_peak = (idx - 2) // 3 + gaussian_parameter = (idx - 2) % 3 + actual_idx = 2 + 3 * gaussian_peak + p = params[actual_idx:(actual_idx+3)] + if gaussian_parameter == 0: + return self.gauss(x, *[0, 0, 1.0, p[1], p[2]]) + if gaussian_parameter == 1: + tmp = self.gauss(x, *[0, 0, p[0], p[1], p[2]]) + tmp *= 2.3548200450309493*(x - p[1])/p[2] + return tmp * 2.3548200450309493/p[2] + if gaussian_parameter == 2: + tmp = self.gauss(x, *[0, 0, p[0], p[1], p[2]]) + tmp *= 2.3548200450309493*(x - p[1])/p[2] + return tmp * 2.3548200450309493*(x - p[1])/(p[2]*p[2]) + + self.gauss_derivative = gauss_derivative + + def tearDown(self): + self.instance = None + self.gauss = None + self.gauss_derivative = None + self.my_exp = None + self.model_function = None + self.model_derivative = None + + def testImport(self): + self.assertTrue(self.instance is not None, + "Cannot import leastsq from silx.math.fit") + + def testUnconstrainedFitNoWeight(self): + parameters_actual = [10.5, 2, 1000.0, 20., 15] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + + fittedpar, cov = self.instance(model_function, x, y, parameters_estimate) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + def testUnconstrainedFitWeight(self): + parameters_actual = [10.5,2,1000.0,20.,15] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + sigma = numpy.sqrt(y) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + def testDerivativeFunction(self): + parameters_actual = [10.5, 2, 10000.0, 20., 150, 5000, 900., 300] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + delta = numpy.sqrt(numpy.finfo(numpy.float64).eps) + for i in range(len(parameters_actual)): + p = parameters_actual * 1 + if p[i] == 0: + delta_par = delta + else: + delta_par = p[i] * delta + if i > 2: + p[0] = 0.0 + p[1] = 0.0 + p[i] += delta_par + yPlus = self.gauss(x, *p) + p[i] = parameters_actual[i] - delta_par + yMinus = self.gauss(x, *p) + numerical_derivative = (yPlus - yMinus) / (2 * delta_par) + #numerical_derivative = (self.gauss(x, *p) - y) / delta_par + p[i] = parameters_actual[i] + derivative = self.gauss_derivative(x, p, i) + diff = numerical_derivative - derivative + test_condition = numpy.allclose(numerical_derivative, + derivative, atol=5.0e-6) + if not test_condition: + msg = "Error calculating derivative of parameter %d." % i + msg += "\n diff min = %g diff max = %g" % (diff.min(), diff.max()) + self.assertTrue(test_condition, msg) + + def testConstrainedFit(self): + CFREE = 0 + CPOSITIVE = 1 + CQUOTED = 2 + CFIXED = 3 + CFACTOR = 4 + CDELTA = 5 + CSUM = 6 + parameters_actual = [10.5, 2, 10000.0, 20., 150, 5000, 900., 300] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10, 400, 850, 200] + model_function = self.gauss + model_deriv = self.gauss_derivative + constraints_all_free = [[0, 0, 0]] * len(parameters_actual) + constraints_all_positive = [[1, 0, 0]] * len(parameters_actual) + constraints_delta_position = [[0, 0, 0]] * len(parameters_actual) + constraints_delta_position[6] = [CDELTA, 3, 880] + constraints_sum_position = constraints_all_positive * 1 + constraints_sum_position[6] = [CSUM, 3, 920] + constraints_factor = constraints_delta_position * 1 + constraints_factor[2] = [CFACTOR, 5, 2] + constraints_list = [None, + constraints_all_free, + constraints_all_positive, + constraints_delta_position, + constraints_sum_position] + + # for better code coverage, the warning recommending to set full_output + # to True when using constraints should be shown at least once + full_output = True + for index, constraints in enumerate(constraints_list): + if index == 2: + full_output = None + elif index == 3: + full_output = 0 + for model_deriv in [None, self.gauss_derivative]: + for sigma in [None, numpy.sqrt(y)]: + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + constraints=constraints, + model_deriv=model_deriv, + full_output=full_output)[:2] + full_output = True + + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + def testUnconstrainedFitAnalyticalDerivative(self): + parameters_actual = [10.5, 2, 1000.0, 20., 15] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + sigma = numpy.sqrt(y) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + model_deriv = self.gauss_derivative + + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + model_deriv=model_deriv) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + @testutils.validate_logging(fitlogger.name, warning=2) + def testBadlyShapedData(self): + parameters_actual = [10.5, 2, 1000.0, 20., 15] + x = numpy.arange(10000.).reshape(1000, 10) + y = self.gauss(x, *parameters_actual) + sigma = numpy.sqrt(y) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + + for check_finite in [True, False]: + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=check_finite) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + @testutils.validate_logging(fitlogger.name, warning=3) + def testDataWithNaN(self): + parameters_actual = [10.5, 2, 1000.0, 20., 15] + x = numpy.arange(10000.).reshape(1000, 10) + y = self.gauss(x, *parameters_actual) + sigma = numpy.sqrt(y) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + x[500] = numpy.inf + # check default behavior + try: + self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma) + except ValueError: + info = "%s" % sys.exc_info()[1] + self.assertTrue("array must not contain inf" in info) + + # check requested behavior + try: + self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=True) + except ValueError: + info = "%s" % sys.exc_info()[1] + self.assertTrue("array must not contain inf" in info) + + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=False) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + # testing now with ydata containing NaN + x = numpy.arange(10000.).reshape(1000, 10) + y[500] = numpy.nan + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=False) + + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + # testing now with sigma containing NaN + sigma[300] = numpy.nan + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=False) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + def testUncertainties(self): + """Test for validity of uncertainties in returned full-output + dictionary. This is a non-regression test for pull request #197""" + parameters_actual = [10.5, 2, 1000.0, 20., 15, 2001.0, 30.1, 16] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10., 1500., 20., 2.0] + + # test that uncertainties are not 0. + fittedpar, cov, infodict = self.instance(self.gauss, x, y, parameters_estimate, + full_output=True) + uncertainties = infodict["uncertainties"] + self.assertEqual(len(uncertainties), len(parameters_actual)) + self.assertEqual(len(uncertainties), len(fittedpar)) + for uncertainty in uncertainties: + self.assertNotAlmostEqual(uncertainty, 0.) + + # set constraint FIXED for half the parameters. + # This should cause leastsq to return 100% uncertainty. + parameters_estimate = [10.6, 2.1, 1000.1, 20.1, 15.1, 2001.1, 30.2, 16.1] + CFIXED = 3 + CFREE = 0 + constraints = [] + for i in range(len(parameters_estimate)): + if i % 2: + constraints.append([CFIXED, 0, 0]) + else: + constraints.append([CFREE, 0, 0]) + fittedpar, cov, infodict = self.instance(self.gauss, x, y, parameters_estimate, + constraints=constraints, + full_output=True) + uncertainties = infodict["uncertainties"] + for i in range(len(parameters_estimate)): + if i % 2: + # test that all FIXED parameters have 100% uncertainty + self.assertAlmostEqual(uncertainties[i], + parameters_estimate[i]) diff --git a/src/silx/math/fit/test/test_fitmanager.py b/src/silx/math/fit/test/test_fitmanager.py new file mode 100644 index 0000000..4ab56a5 --- /dev/null +++ b/src/silx/math/fit/test/test_fitmanager.py @@ -0,0 +1,498 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Tests for fitmanager module +""" + +import unittest +import numpy +import os.path + +from silx.math.fit import fitmanager +from silx.math.fit import fittheories +from silx.math.fit import bgtheories +from silx.math.fit.fittheory import FitTheory +from silx.math.fit.functions import sum_gauss, sum_stepdown, sum_stepup + +from silx.utils.testutils import ParametricTestCase +from silx.test.utils import temp_dir + +custom_function_definition = """ +import copy +from silx.math.fit.fittheory import FitTheory + +CONFIG = {'d': 1.} + +def myfun(x, a, b, c): + "Model function" + return (a * x**2 + b * x + c) / CONFIG['d'] + +def myesti(x, y): + "Initial parameters for iterative fit (a, b, c) = (1, 1, 1)" + return (1., 1., 1.), ((0, 0, 0), (0, 0, 0), (0, 0, 0)) + +def myconfig(d=1., **kw): + "This function can modify CONFIG" + CONFIG["d"] = d + return CONFIG + +def myderiv(x, parameters, index): + "Custom derivative (does not work, causes singular matrix)" + pars_plus = copy.copy(parameters) + pars_plus[index] *= 1.0001 + + pars_minus = parameters + pars_minus[index] *= copy.copy(0.9999) + + delta_fun = myfun(x, *pars_plus) - myfun(x, *pars_minus) + delta_par = parameters[index] * 0.0001 * 2 + + return delta_fun / delta_par + +THEORY = { + 'my fit theory': + FitTheory(function=myfun, + parameters=('A', 'B', 'C'), + estimate=myesti, + configure=myconfig, + derivative=myderiv) +} + +""" + +old_custom_function_definition = """ +CONFIG = {'d': 1.0} + +def myfun(x, a, b, c): + "Model function" + return (a * x**2 + b * x + c) / CONFIG['d'] + +def myesti(x, y, bg, xscalinq, yscaling): + "Initial parameters for iterative fit (a, b, c) = (1, 1, 1)" + return (1., 1., 1.), ((0, 0, 0), (0, 0, 0), (0, 0, 0)) + +def myconfig(**kw): + "Update or complete CONFIG dictionary" + for key in kw: + CONFIG[key] = kw[key] + return CONFIG + +THEORY = ['my fit theory'] +PARAMETERS = [('A', 'B', 'C')] +FUNCTION = [myfun] +ESTIMATE = [myesti] +CONFIGURE = [myconfig] + +""" + + +def _order_of_magnitude(x): + return numpy.log10(x).round() + + +class TestFitmanager(ParametricTestCase): + """ + Unit tests of multi-peak functions. + """ + def setUp(self): + pass + + def tearDown(self): + pass + + def testFitManager(self): + """Test fit manager on synthetic data using a gaussian function + and a linear background""" + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(1000).astype(numpy.float64) + + p = [1000, 100., 250, + 255, 650., 45, + 1500, 800.5, 95] + linear_bg = 2.65 * x + 13 + y = linear_bg + sum_gauss(x, *p) + + y_with_nans = numpy.array(y) + y_with_nans[::10] = numpy.nan + + x_with_nans = numpy.array(x) + x_with_nans[5::15] = numpy.nan + + tests = { + 'all finite': (x, y), + 'y with NaNs': (x, y_with_nans), + 'x with NaNs': (x_with_nans, y), + } + + for name, (xdata, ydata) in tests.items(): + with self.subTest(name=name): + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=xdata, y=ydata) + fit.loadtheories(fittheories) + # Use one of the default fit functions + fit.settheory('Gaussians') + fit.setbackground('Linear') + fit.estimate() + fit.runfit() + + # fit.fit_results[] + + # first 2 parameters are related to the linear background + self.assertEqual(fit.fit_results[0]["name"], "Constant") + self.assertAlmostEqual(fit.fit_results[0]["fitresult"], 13) + self.assertEqual(fit.fit_results[1]["name"], "Slope") + self.assertAlmostEqual(fit.fit_results[1]["fitresult"], 2.65) + + for i, param in enumerate(fit.fit_results[2:]): + param_number = i // 3 + 1 + if i % 3 == 0: + self.assertEqual(param["name"], + "Height%d" % param_number) + elif i % 3 == 1: + self.assertEqual(param["name"], + "Position%d" % param_number) + elif i % 3 == 2: + self.assertEqual(param["name"], + "FWHM%d" % param_number) + + self.assertAlmostEqual(param["fitresult"], + p[i]) + self.assertAlmostEqual(_order_of_magnitude(param["estimation"]), + _order_of_magnitude(p[i])) + + def testLoadCustomFitFunction(self): + """Test FitManager using a custom fit function defined in an external + file and imported with FitManager.loadtheories""" + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(100).astype(numpy.float64) + + # a, b, c are the fit parameters + # d is a known scaling parameter that is set using configure() + a, b, c, d = 1.5, 2.5, 3.5, 4.5 + y = (a * x**2 + b * x + c) / d + + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y) + + # Create a temporary function definition file, and import it + with temp_dir() as tmpDir: + tmpfile = os.path.join(tmpDir, 'customfun.py') + # custom_function_definition + fd = open(tmpfile, "w") + fd.write(custom_function_definition) + fd.close() + fit.loadtheories(tmpfile) + tmpfile_pyc = os.path.join(tmpDir, 'customfun.pyc') + if os.path.exists(tmpfile_pyc): + os.unlink(tmpfile_pyc) + os.unlink(tmpfile) + + fit.settheory('my fit theory') + # Test configure + fit.configure(d=4.5) + fit.estimate() + fit.runfit() + + self.assertEqual(fit.fit_results[0]["name"], + "A1") + self.assertAlmostEqual(fit.fit_results[0]["fitresult"], + 1.5) + self.assertEqual(fit.fit_results[1]["name"], + "B1") + self.assertAlmostEqual(fit.fit_results[1]["fitresult"], + 2.5) + self.assertEqual(fit.fit_results[2]["name"], + "C1") + self.assertAlmostEqual(fit.fit_results[2]["fitresult"], + 3.5) + + def testLoadOldCustomFitFunction(self): + """Test FitManager using a custom fit function defined in an external + file and imported with FitManager.loadtheories (legacy PyMca format)""" + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(100).astype(numpy.float64) + + # a, b, c are the fit parameters + # d is a known scaling parameter that is set using configure() + a, b, c, d = 1.5, 2.5, 3.5, 4.5 + y = (a * x**2 + b * x + c) / d + + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y) + + # Create a temporary function definition file, and import it + with temp_dir() as tmpDir: + tmpfile = os.path.join(tmpDir, 'oldcustomfun.py') + # custom_function_definition + fd = open(tmpfile, "w") + fd.write(old_custom_function_definition) + fd.close() + fit.loadtheories(tmpfile) + tmpfile_pyc = os.path.join(tmpDir, 'oldcustomfun.pyc') + if os.path.exists(tmpfile_pyc): + os.unlink(tmpfile_pyc) + os.unlink(tmpfile) + + fit.settheory('my fit theory') + fit.configure(d=4.5) + fit.estimate() + fit.runfit() + + self.assertEqual(fit.fit_results[0]["name"], + "A1") + self.assertAlmostEqual(fit.fit_results[0]["fitresult"], + 1.5) + self.assertEqual(fit.fit_results[1]["name"], + "B1") + self.assertAlmostEqual(fit.fit_results[1]["fitresult"], + 2.5) + self.assertEqual(fit.fit_results[2]["name"], + "C1") + self.assertAlmostEqual(fit.fit_results[2]["fitresult"], + 3.5) + + def testAddTheory(self, estimate=True): + """Test FitManager using a custom fit function imported with + FitManager.addtheory""" + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(100).astype(numpy.float64) + + # a, b, c are the fit parameters + # d is a known scaling parameter that is set using configure() + a, b, c, d = -3.14, 1234.5, 10000, 4.5 + y = (a * x**2 + b * x + c) / d + + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y) + + # Define and add the fit theory + CONFIG = {'d': 1.} + + def myfun(x_, a_, b_, c_): + """"Model function""" + return (a_ * x_**2 + b_ * x_ + c_) / CONFIG['d'] + + def myesti(x_, y_): + """"Initial parameters for iterative fit: + (a, b, c) = (1, 1, 1) + Constraints all set to 0 (FREE)""" + return (1., 1., 1.), ((0, 0, 0), (0, 0, 0), (0, 0, 0)) + + def myconfig(d_=1., **kw): + """This function can modify CONFIG""" + CONFIG["d"] = d_ + return CONFIG + + def myderiv(x_, parameters, index): + """Custom derivative""" + pars_plus = numpy.array(parameters, copy=True) + pars_plus[index] *= 1.001 + + pars_minus = numpy.array(parameters, copy=True) + pars_minus[index] *= 0.999 + + delta_fun = myfun(x_, *pars_plus) - myfun(x_, *pars_minus) + delta_par = parameters[index] * 0.001 * 2 + + return delta_fun / delta_par + + fit.addtheory("polynomial", + FitTheory(function=myfun, + parameters=["A", "B", "C"], + estimate=myesti if estimate else None, + configure=myconfig, + derivative=myderiv)) + + fit.settheory('polynomial') + fit.configure(d_=4.5) + fit.estimate() + params1, sigmas, infodict = fit.runfit() + + self.assertEqual(fit.fit_results[0]["name"], + "A1") + self.assertAlmostEqual(fit.fit_results[0]["fitresult"], + -3.14) + self.assertEqual(fit.fit_results[1]["name"], + "B1") + # params1[1] is the same as fit.fit_results[1]["fitresult"] + self.assertAlmostEqual(params1[1], + 1234.5) + self.assertEqual(fit.fit_results[2]["name"], + "C1") + self.assertAlmostEqual(params1[2], + 10000) + + # change configuration scaling factor and check that the fit returns + # different values + fit.configure(d_=5.) + fit.estimate() + params2, sigmas, infodict = fit.runfit() + for p1, p2 in zip(params1, params2): + self.assertFalse(numpy.array_equal(p1, p2), + "Fit parameters are equal even though the " + + "configuration has been changed") + + def testNoEstimate(self): + """Ensure that the in the absence of the estimation function, + the default estimation function :meth:`FitTheory.default_estimate` + is used.""" + self.testAddTheory(estimate=False) + + def testStep(self): + """Test fit manager on a step function with a more complex estimate + function than the gaussian (convolution filter)""" + for theory_name, theory_fun in (('Step Down', sum_stepdown), + ('Step Up', sum_stepup)): + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(1000).astype(numpy.float64) + + # ('Height', 'Position', 'FWHM') + p = [1000, 439, 250] + + constantbg = 13 + y = theory_fun(x, *p) + constantbg + + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y) + fit.loadtheories(fittheories) + fit.settheory(theory_name) + fit.setbackground('Constant') + + fit.estimate() + + params, sigmas, infodict = fit.runfit() + + # first parameter is the constant background + self.assertAlmostEqual(params[0], 13, places=5) + for i, param in enumerate(params[1:]): + self.assertAlmostEqual(param, p[i], places=5) + self.assertAlmostEqual(_order_of_magnitude(fit.fit_results[i+1]["estimation"]), + _order_of_magnitude(p[i])) + + +def quadratic(x, a, b, c): + return a * x**2 + b * x + c + + +def cubic(x, a, b, c, d): + return a * x**3 + b * x**2 + c * x + d + + +class TestPolynomials(unittest.TestCase): + """Test polynomial fit theories and fit background""" + def setUp(self): + self.x = numpy.arange(100).astype(numpy.float64) + + def testQuadraticBg(self): + gaussian_params = [100, 45, 8] + poly_params = [0.05, -2, 3] + p = numpy.poly1d(poly_params) + + y = p(self.x) + sum_gauss(self.x, *gaussian_params) + + fm = fitmanager.FitManager(self.x, y) + fm.loadbgtheories(bgtheories) + fm.loadtheories(fittheories) + fm.settheory("Gaussians") + fm.setbackground("Degree 2 Polynomial") + esti_params = fm.estimate() + fit_params = fm.runfit()[0] + + for p, pfit in zip(poly_params + gaussian_params, fit_params): + self.assertAlmostEqual(p, + pfit) + + def testCubicBg(self): + gaussian_params = [1000, 45, 8] + poly_params = [0.0005, -0.05, 3, -4] + p = numpy.poly1d(poly_params) + + y = p(self.x) + sum_gauss(self.x, *gaussian_params) + + fm = fitmanager.FitManager(self.x, y) + fm.loadtheories(fittheories) + fm.settheory("Gaussians") + fm.setbackground("Degree 3 Polynomial") + esti_params = fm.estimate() + fit_params = fm.runfit()[0] + + for p, pfit in zip(poly_params + gaussian_params, fit_params): + self.assertAlmostEqual(p, + pfit) + + def testQuarticcBg(self): + gaussian_params = [10000, 69, 25] + poly_params = [5e-10, 0.0005, 0.005, 2, 4] + p = numpy.poly1d(poly_params) + + y = p(self.x) + sum_gauss(self.x, *gaussian_params) + + fm = fitmanager.FitManager(self.x, y) + fm.loadtheories(fittheories) + fm.settheory("Gaussians") + fm.setbackground("Degree 4 Polynomial") + esti_params = fm.estimate() + fit_params = fm.runfit()[0] + + for p, pfit in zip(poly_params + gaussian_params, fit_params): + self.assertAlmostEqual(p, + pfit, + places=5) + + def _testPoly(self, poly_params, theory, places=5): + p = numpy.poly1d(poly_params) + + y = p(self.x) + + fm = fitmanager.FitManager(self.x, y) + fm.loadbgtheories(bgtheories) + fm.loadtheories(fittheories) + fm.settheory(theory) + esti_params = fm.estimate() + fit_params = fm.runfit()[0] + + for p, pfit in zip(poly_params, fit_params): + self.assertAlmostEqual(p, pfit, places=places) + + def testQuadratic(self): + self._testPoly([0.05, -2, 3], + "Degree 2 Polynomial") + + def testCubic(self): + self._testPoly([0.0005, -0.05, 3, -4], + "Degree 3 Polynomial") + + def testQuartic(self): + self._testPoly([1, -2, 3, -4, -5], + "Degree 4 Polynomial") + + def testQuintic(self): + self._testPoly([1, -2, 3, -4, -5, 6], + "Degree 5 Polynomial", + places=4) diff --git a/src/silx/math/fit/test/test_functions.py b/src/silx/math/fit/test/test_functions.py new file mode 100644 index 0000000..7e3ff63 --- /dev/null +++ b/src/silx/math/fit/test/test_functions.py @@ -0,0 +1,259 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Tests for functions module +""" + +import unittest +import numpy +import math + +from silx.math.fit import functions + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "21/07/2016" + +class Test_functions(unittest.TestCase): + """ + Unit tests of multi-peak functions. + """ + def setUp(self): + self.x = numpy.arange(11) + + # height, center, sigma1, sigma2 + (h, c, s1, s2) = (7., 5., 3., 2.1) + self.g_params = { + "height": h, + "center": c, + #"sigma": s, + "fwhm1": 2 * math.sqrt(2 * math.log(2)) * s1, + "fwhm2": 2 * math.sqrt(2 * math.log(2)) * s2, + "area1": h * s1 * math.sqrt(2 * math.pi) + } + # result of `7 * scipy.signal.gaussian(11, 3)` + self.scipy_gaussian = numpy.array( + [1.74546546, 2.87778603, 4.24571462, 5.60516182, 6.62171628, + 7., 6.62171628, 5.60516182, 4.24571462, 2.87778603, + 1.74546546] + ) + + # result of: + # numpy.concatenate((7 * scipy.signal.gaussian(11, 3)[0:5], + # 7 * scipy.signal.gaussian(11, 2.1)[5:11])) + self.scipy_asym_gaussian = numpy.array( + [1.74546546, 2.87778603, 4.24571462, 5.60516182, 6.62171628, + 7., 6.24968751, 4.44773692, 2.52313452, 1.14093853, 0.41124877] + ) + + def tearDown(self): + pass + + def testGauss(self): + """Compare sum_gauss with scipy.signals.gaussian""" + y = functions.sum_gauss(self.x, + self.g_params["height"], + self.g_params["center"], + self.g_params["fwhm1"]) + + for i in range(11): + self.assertAlmostEqual(y[i], self.scipy_gaussian[i]) + + def testAGauss(self): + """Compare sum_agauss with scipy.signals.gaussian""" + y = functions.sum_agauss(self.x, + self.g_params["area1"], + self.g_params["center"], + self.g_params["fwhm1"]) + for i in range(11): + self.assertAlmostEqual(y[i], self.scipy_gaussian[i]) + + def testFastAGauss(self): + """Compare sum_fastagauss with scipy.signals.gaussian + Limit precision to 3 decimal places.""" + y = functions.sum_fastagauss(self.x, + self.g_params["area1"], + self.g_params["center"], + self.g_params["fwhm1"]) + for i in range(11): + self.assertAlmostEqual(y[i], self.scipy_gaussian[i], 3) + + + def testSplitGauss(self): + """Compare sum_splitgauss with scipy.signals.gaussian""" + y = functions.sum_splitgauss(self.x, + self.g_params["height"], + self.g_params["center"], + self.g_params["fwhm1"], + self.g_params["fwhm2"]) + for i in range(11): + self.assertAlmostEqual(y[i], self.scipy_asym_gaussian[i]) + + def testErf(self): + """Compare erf with math.erf""" + # scalars + self.assertAlmostEqual(functions.erf(0.14), math.erf(0.14), places=5) + self.assertAlmostEqual(functions.erf(0), math.erf(0), places=5) + self.assertAlmostEqual(functions.erf(-0.74), math.erf(-0.74), places=5) + + # lists + x = [-5, -2, -1.5, -0.6, 0, 0.1, 2, 3] + erfx = functions.erf(x) + for i in range(len(x)): + self.assertAlmostEqual(erfx[i], + math.erf(x[i]), + places=5) + + # ndarray + x = numpy.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) + erfx = functions.erf(x) + for i in range(x.shape[0]): + for j in range(x.shape[1]): + self.assertAlmostEqual(erfx[i, j], + math.erf(x[i, j]), + places=5) + + def testErfc(self): + """Compare erf with math.erf""" + # scalars + self.assertAlmostEqual(functions.erfc(0.14), math.erfc(0.14), places=5) + self.assertAlmostEqual(functions.erfc(0), math.erfc(0), places=5) + self.assertAlmostEqual(functions.erfc(-0.74), math.erfc(-0.74), places=5) + + # lists + x = [-5, -2, -1.5, -0.6, 0, 0.1, 2, 3] + erfcx = functions.erfc(x) + for i in range(len(x)): + self.assertAlmostEqual(erfcx[i], math.erfc(x[i]), places=5) + + # ndarray + x = numpy.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) + erfcx = functions.erfc(x) + for i in range(x.shape[0]): + for j in range(x.shape[1]): + self.assertAlmostEqual(erfcx[i, j], math.erfc(x[i, j]), places=5) + + def testAtanStepUp(self): + """Compare atan_stepup with math.atan + + atan_stepup(x, a, b, c) = a * (0.5 + (arctan((x - b) / c) / pi))""" + x0 = numpy.arange(100) / 6.33 + y0 = functions.atan_stepup(x0, 11.1, 22.2, 3.33) + + for x, y in zip(x0, y0): + self.assertAlmostEqual( + 11.1 * (0.5 + math.atan((x - 22.2) / 3.33) / math.pi), + y + ) + + def testStepUp(self): + """sanity check for step up: + + - derivative must be largest around the step center + - max value must be close to height parameter + + """ + x0 = numpy.arange(1000) + center = 444 + height = 1234 + fwhm = 210 + y0 = functions.sum_stepup(x0, height, center, fwhm) + + self.assertLess(max(y0), height) + self.assertAlmostEqual(max(y0), height, places=1) + self.assertAlmostEqual(min(y0), 0, places=1) + + deriv0 = _numerical_derivative(functions.sum_stepup, x0, [height, center, fwhm]) + + # Test center position within +- 1 sample of max derivative + index_max_deriv = numpy.argmax(deriv0) + self.assertLess(abs(index_max_deriv - center), + 1) + + def testStepDown(self): + """sanity check for step down: + + - absolute value of derivative must be largest around the step center + - max value must be close to height parameter + + """ + x0 = numpy.arange(1000) + center = 444 + height = 1234 + fwhm = 210 + y0 = functions.sum_stepdown(x0, height, center, fwhm) + + self.assertLess(max(y0), height) + self.assertAlmostEqual(max(y0), height, places=1) + self.assertAlmostEqual(min(y0), 0, places=1) + + deriv0 = _numerical_derivative(functions.sum_stepdown, x0, [height, center, fwhm]) + + # Test center position within +- 1 sample of max derivative + index_min_deriv = numpy.argmax(-deriv0) + self.assertLess(abs(index_min_deriv - center), + 1) + + def testSlit(self): + """sanity check for slit: + + - absolute value of derivative must be largest around the step center + - max value must be close to height parameter + + """ + x0 = numpy.arange(1000) + center = 444 + height = 1234 + fwhm = 210 + beamfwhm = 30 + y0 = functions.sum_slit(x0, height, center, fwhm, beamfwhm) + + self.assertAlmostEqual(max(y0), height, places=1) + self.assertAlmostEqual(min(y0), 0, places=1) + + deriv0 = _numerical_derivative(functions.sum_slit, x0, [height, center, fwhm, beamfwhm]) + + # Test step up center position (center - fwhm/2) within +- 1 sample of max derivative + index_max_deriv = numpy.argmax(deriv0) + self.assertLess(abs(index_max_deriv - (center - fwhm/2)), + 1) + # Test step down center position (center + fwhm/2) within +- 1 sample of min derivative + index_min_deriv = numpy.argmin(deriv0) + self.assertLess(abs(index_min_deriv - (center + fwhm/2)), + 1) + + +def _numerical_derivative(f, x, params=[], delta_factor=0.0001): + """Compute the numerical derivative of ``f`` for all values of ``x``. + + :param f: function + :param x: Array of evenly spaced abscissa values + :param params: list of additional parameters + :return: Array of derivative values + """ + deltax = (x[1] - x[0]) * delta_factor + y_plus = f(x + deltax, *params) + y_minus = f(x - deltax, *params) + + return (y_plus - y_minus) / (2 * deltax) diff --git a/src/silx/math/fit/test/test_peaks.py b/src/silx/math/fit/test/test_peaks.py new file mode 100644 index 0000000..495c70d --- /dev/null +++ b/src/silx/math/fit/test/test_peaks.py @@ -0,0 +1,132 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Tests for peaks module +""" + +import unittest +import numpy +import math + +from silx.math.fit import functions +from silx.math.fit import peaks + +class Test_peak_search(unittest.TestCase): + """ + Unit tests of peak_search on various types of multi-peak functions. + """ + def setUp(self): + self.x = numpy.arange(5000) + # (height1, center1, fwhm1, ...) + self.h_c_fwhm = (50, 500, 100, + 50, 600, 80, + 20, 2000, 100, + 50, 2250, 110, + 40, 3000, 99, + 23, 4980, 80) + # (height1, center1, fwhm1, eta1 ...) + self.h_c_fwhm_eta = (50, 500, 100, 0.4, + 50, 600, 80, 0.5, + 20, 2000, 100, 0.6, + 50, 2250, 110, 0.7, + 40, 3000, 99, 0.8, + 23, 4980, 80, 0.3,) + # (height1, center1, fwhm11, fwhm21, ...) + self.h_c_fwhm_fwhm = (50, 500, 100, 85, + 50, 600, 80, 110, + 20, 2000, 100, 100, + 50, 2250, 110, 99, + 40, 3000, 99, 110, + 23, 4980, 80, 80,) + # (height1, center1, fwhm11, fwhm21, eta1 ...) + self.h_c_fwhm_fwhm_eta = (50, 500, 100, 85, 0.4, + 50, 600, 80, 110, 0.5, + 20, 2000, 100, 100, 0.6, + 50, 2250, 110, 99, 0.7, + 40, 3000, 99, 110, 0.8, + 23, 4980, 80, 80, 0.3,) + # (area1, center1, fwhm1, ...) + self.a_c_fwhm = (2550, 500, 100, + 2000, 600, 80, + 500, 2000, 100, + 4000, 2250, 110, + 2300, 3000, 99, + 3333, 4980, 80) + # (area1, center1, fwhm1, eta1 ...) + self.a_c_fwhm_eta = (500, 500, 100, 0.4, + 500, 600, 80, 0.5, + 200, 2000, 100, 0.6, + 500, 2250, 110, 0.7, + 400, 3000, 99, 0.8, + 230, 4980, 80, 0.3,) + # (area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r) + self.hypermet_params = (1000, 500, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 1000, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 2000, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 2350, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 3000, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 4900, 200, 0.2, 100, 0.3, 100, 0.05,) + + + def tearDown(self): + pass + + def get_peaks(self, function, params): + """ + + :param function: Multi-peak function + :param params: Parameter for this function + :return: list of (peak, relevance) tuples + """ + y = function(self.x, *params) + return peaks.peak_search(y=y, fwhm=100, relevance_info=True) + + def testPeakSearch_various_functions(self): + """Run peak search on a variety of synthetic functions, and + check that result falls within +-25 samples of the actual peak + (reasonable delta considering a fwhm of ~100 samples) and effects + of overlapping peaks).""" + f_p = ((functions.sum_gauss, self.h_c_fwhm ), + (functions.sum_lorentz, self.h_c_fwhm), + (functions.sum_pvoigt, self.h_c_fwhm_eta), + (functions.sum_splitgauss, self.h_c_fwhm_fwhm), + (functions.sum_splitlorentz, self.h_c_fwhm_fwhm), + (functions.sum_splitpvoigt, self.h_c_fwhm_fwhm_eta), + (functions.sum_agauss, self.a_c_fwhm), + (functions.sum_fastagauss, self.a_c_fwhm), + (functions.sum_alorentz, self.a_c_fwhm), + (functions.sum_apvoigt, self.a_c_fwhm_eta), + (functions.sum_ahypermet, self.hypermet_params), + (functions.sum_fastahypermet, self.hypermet_params),) + + for function, params in f_p: + peaks = self.get_peaks(function, params) + + self.assertEqual(len(peaks), 6, + "Wrong number of peaks detected") + + for i in range(6): + theoretical_peak_index = params[i*(len(params)//6) + 1] + found_peak_index = peaks[i][0] + self.assertLess(abs(found_peak_index - theoretical_peak_index), 25) diff --git a/src/silx/math/histogram.py b/src/silx/math/histogram.py new file mode 100644 index 0000000..af9ee68 --- /dev/null +++ b/src/silx/math/histogram.py @@ -0,0 +1,593 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + + +""" +This module provides a function and a class to compute multidimensional +histograms. + + +Classes +======= + +- :class:`Histogramnd` : multi dimensional histogram. +- :class:`HistogramndLut` : optimized to compute several histograms from data sharing the same coordinates. + +Examples +======== + +Single histogram +---------------- + +Given some 3D data: + +>>> import numpy as np +>>> shape = (10**7, 3) +>>> sample = np.random.random(shape) * 500 +>>> weights = np.random.random((shape[0],)) + +Computing the histogram with Histogramnd : + +>>> from silx.math import Histogramnd +>>> n_bins = 35 +>>> ranges = [[40., 150.], [-130., 250.], [0., 505]] +>>> histo, w_histo, edges = Histogramnd(sample, n_bins=n_bins, histo_range=ranges, weights=weights) + +Histogramnd can accumulate sets of data that don't have the same +coordinates : + +>>> from silx.math import Histogramnd +>>> histo_obj = Histogramnd(sample, n_bins=n_bins, histo_range=ranges, weights=weights) +>>> sample_2 = np.random.random(shape) * 200 +>>> weights_2 = np.random.random((shape[0],)) +>>> histo_obj.accumulate(sample_2, weights=weights_2) + +And then access the results: + +>>> histo = histo_obj.histo +>>> weighted_histo = histo_obj.weighted_histo + +or even: + +>>> histo, w_histo, edges = histo_obj + +Accumulating histograms (LUT) +----------------------------- +In some situations we need to compute the weighted histogram of several +sets of data (weights) that have the same coordinates (sample). + +Again, some data (2 sets of weights) : + +>>> import numpy as np +>>> shape = (10**7, 3) +>>> sample = np.random.random(shape) * 500 +>>> weights_1 = np.random.random((shape[0],)) +>>> weights_2 = np.random.random((shape[0],)) + +And getting the result with HistogramLut : + +>>> from silx.math import HistogramndLut + +>>> n_bins = 35 +>>> ranges = [[40., 150.], [-130., 250.], [0., 505]] + +>>> histo_lut = HistogramndLut(sample, ranges, n_bins) + +First call, with weight_1 : + +>>> histo_lut.accumulate(weights_1) + +Second call, with weight_2 : + +>>> histo_lut.accumulate(weights_2) + +Retrieving the results (this is a copy of what's actually stored in +this instance) : + +>>> histo = histo_lut.histo +>>> w_histo = histo_lut.weighted_histo + +Note that the following code gives the same result, but the +HistogramndLut instance does not store the accumulated weighted histogram. + +First call with weights_1 + +>>> histo, w_histo = histo_lut.apply_lut(weights_1) + +Second call with weights_2 + +>>> histo, w_histo = histo_lut.apply_lut(weights_2, histo=histo, weighted_histo=w_histo) + +Bin edges +--------- +When computing an histogram the caller is asked to provide the histogram +range along each coordinates (parameter *histo_range*). This parameter must +be given a [N, 2] array where N is the number of dimensions of the histogram. + +In other words, the caller must provide, for each dimension, +the left edge of the first (*leftmost*) bin, and the right edge of the +last (*rightmost*) bin. + +E.g. : for a 1D sample, for a histo_range equal to [0, 10] and n_bins=4, the +bins ranges will be : + +* [0, 2.5[, [2.5, 5[, [5, 7.5[, [7.5, 10 **[** if last_bin_closed = **False** +* [0, 2.5[, [2.5, 5[, [5, 7.5[, [7.5, 10 **]** if last_bin_closed = **True** + +.... +""" + +__authors__ = ["D. Naudet"] +__license__ = "MIT" +__date__ = "02/10/2017" + +import numpy as np +from .chistogramnd import chistogramnd as _chistogramnd # noqa +from .chistogramnd_lut import histogramnd_get_lut as _histo_get_lut +from .chistogramnd_lut import histogramnd_from_lut as _histo_from_lut + + +class Histogramnd(object): + """ + Computes the multidimensional histogram of some data. + """ + + def __init__(self, + sample, + histo_range, + n_bins, + weights=None, + weight_min=None, + weight_max=None, + last_bin_closed=False, + wh_dtype=None): + """ + :param sample: + The data to be histogrammed. + Its shape must be either + (N,) if it contains one dimensional coordinates, + or an (N,D) array where the rows are the + coordinates of points in a D dimensional space. + The following dtypes are supported : :class:`numpy.float64`, + :class:`numpy.float32`, :class:`numpy.int32`. + + .. warning:: if sample is not a C_CONTIGUOUS ndarray (e.g : a non + contiguous slice) then histogramnd will have to do make an internal + copy. + :type sample: :class:`numpy.array` + + :param histo_range: + A (N, 2) array containing the histogram range along each dimension, + where N is the sample's number of dimensions. + :type histo_range: array_like + + :param n_bins: + The number of bins : + * a scalar (same number of bins for all dimensions) + * a D elements array (number of bins for each dimensions) + :type n_bins: scalar or array_like + + :param weights: + A N elements numpy array of values associated with + each sample. + The values of the *weighted_histo* array + returned by the function are equal to the sum of + the weights associated with the samples falling + into each bin. + The following dtypes are supported : :class:`numpy.float64`, + :class:`numpy.float32`, :class:`numpy.int32`. + + .. note:: If None, the weighted histogram returned will be None. + :type weights: *optional*, :class:`numpy.array` + + :param weight_min: + Use this parameter to filter out all samples whose + weights are lower than this value. + + .. note:: This value will be cast to the same type + as *weights*. + :type weight_min: *optional*, scalar + + :param weight_max: + Use this parameter to filter out all samples whose + weights are higher than this value. + + .. note:: This value will be cast to the same type + as *weights*. + + :type weight_max: *optional*, scalar + + :param last_bin_closed: + By default the last bin is half + open (i.e.: [x,y) ; x included, y + excluded), like all the other bins. + Set this parameter to true if you want + the LAST bin to be closed. + :type last_bin_closed: *optional*, :class:`python.boolean` + + :param wh_dtype: type of the weighted histogram array. + If not provided, the weighted histogram array will contain values + of type numpy.double. Allowed values are : `numpy.double` and + `numpy.float32` + :type wh_dtype: *optional*, numpy data type + """ + + self.__histo_range = histo_range + self.__n_bins = n_bins + self.__last_bin_closed = last_bin_closed + self.__wh_dtype = wh_dtype + + if sample is None: + self.__data = [None, None, None] + else: + self.__data = _chistogramnd(sample, + self.__histo_range, + self.__n_bins, + weights=weights, + weight_min=weight_min, + weight_max=weight_max, + last_bin_closed=self.__last_bin_closed, + wh_dtype=self.__wh_dtype) + + def __getitem__(self, key): + """ + If necessary, results can be unpacked from an instance of Histogramnd : + *histogram*, *weighted histogram*, *bins edge*. + + Example : + + .. code-block:: python + + histo, w_histo, edges = Histogramnd(sample, histo_range, n_bins, weights) + + """ + return self.__data[key] + + def accumulate(self, + sample, + weights=None, + weight_min=None, + weight_max=None): + """ + Computes the multidimensional histogram of some data and accumulates it + into the histogram held by this instance of Histogramnd. + + :param sample: + The data to be histogrammed. + Its shape must be either + (N,) if it contains one dimensional coordinates, + or an (N,D) array where the rows are the + coordinates of points in a D dimensional space. + The following dtypes are supported : :class:`numpy.float64`, + :class:`numpy.float32`, :class:`numpy.int32`. + + .. warning:: if sample is not a C_CONTIGUOUS ndarray (e.g : a non + contiguous slice) then histogramnd will have to do make an internal + copy. + :type sample: :class:`numpy.array` + + :param weights: + A N elements numpy array of values associated with + each sample. + The values of the *weighted_histo* array + returned by the function are equal to the sum of + the weights associated with the samples falling + into each bin. + The following dtypes are supported : :class:`numpy.float64`, + :class:`numpy.float32`, :class:`numpy.int32`. + + .. note:: If None, the weighted histogram returned will be None. + :type weights: *optional*, :class:`numpy.array` + + :param weight_min: + Use this parameter to filter out all samples whose + weights are lower than this value. + + .. note:: This value will be cast to the same type + as *weights*. + :type weight_min: *optional*, scalar + + :param weight_max: + Use this parameter to filter out all samples whose + weights are higher than this value. + + .. note:: This value will be cast to the same type + as *weights*. + :type weight_max: *optional*, scalar + """ + result = _chistogramnd(sample, + self.__histo_range, + self.__n_bins, + weights=weights, + weight_min=weight_min, + weight_max=weight_max, + last_bin_closed=self.__last_bin_closed, + histo=self.__data[0], + weighted_histo=self.__data[1], + wh_dtype=self.__wh_dtype) + if self.__data[0] is None: + self.__data = result + elif self.__data[1] is None and result[1] is not None: + self.__data = result + + histo = property(lambda self: self[0]) + """ Histogram array, or None if this instance was initialized without + <sample> and accumulate has not been called yet. + + .. note:: this is a **reference** to the array store in this + Histogramnd instance, use with caution. + """ + weighted_histo = property(lambda self: self[1]) + """ Weighted Histogram, or None if this instance was initialized without + <sample>, or no weights have been passed to __init__ nor accumulate. + + .. note:: this is a **reference** to the array store in this + Histogramnd instance, use with caution. + """ + edges = property(lambda self: self[2]) + """ Bins edges, or None if this instance was initialized without + <sample> and accumulate has not been called yet. + """ + + +class HistogramndLut(object): + """ + The HistogramndLut class allows you to bin data onto a regular grid. + The use of HistogramndLut is interesting when several sets of data that + share the same coordinates (*sample*) have to be mapped onto the same grid. + """ + + def __init__(self, + sample, + histo_range, + n_bins, + last_bin_closed=False, + dtype=None): + """ + :param sample: + The coordinates of the data to be histogrammed. + Its shape must be either (N,) if it contains one dimensional + coordinates, or an (N, D) array where the rows are the + coordinates of points in a D dimensional space. + The following dtypes are supported : :class:`numpy.float64`, + :class:`numpy.float32`, :class:`numpy.int32`. + :type sample: :class:`numpy.array` + + :param histo_range: + A (N, 2) array containing the histogram range along each dimension, + where N is the sample's number of dimensions. + :type histo_range: array_like + + :param n_bins: + The number of bins : + * a scalar (same number of bins for all dimensions) + * a D elements array (number of bins for each dimensions) + :type n_bins: scalar or array_like + + :param dtype: data type of the weighted histogram. If None, the data type + will be the same as the first weights array provided (on first call of + the instance). + :type dtype: `numpy.dtype` + + :param last_bin_closed: + By default the last bin is half + open (i.e.: [x,y) ; x included, y + excluded), like all the other bins. + Set this parameter to true if you want + the LAST bin to be closed. + :type last_bin_closed: *optional*, :class:`python.boolean` + """ + lut, histo, edges = _histo_get_lut(sample, + histo_range, + n_bins, + last_bin_closed=last_bin_closed) + + self.__n_bins = np.array(histo.shape) + self.__histo_range = histo_range + self.__lut = lut + self.__histo = None + self.__weighted_histo = None + self.__edges = edges + self.__dtype = dtype + self.__shape = histo.shape + self.__last_bin_closed = last_bin_closed + self.clear() + + def clear(self): + """ + Resets the instance (zeroes the histograms). + """ + self.__weighted_histo = None + self.__histo = None + + @property + def lut(self): + """ + Copy of the Lut + """ + return self.__lut.copy() + + def histo(self, copy=True): + """ + Histogram (a copy of it), or None if `~accumulate` has not been called yet + (or clear was just called). + If *copy* is set to False then the actual reference to the array is + returned *(use with caution)*. + """ + if copy and self.__histo is not None: + return self.__histo.copy() + return self.__histo + + def weighted_histo(self, copy=True): + """ + Weighted histogram (a copy of it), or None if `~accumulate` has not been called yet + (or clear was just called). If *copy* is set to False then the actual + reference to the array is returned *(use with caution)*. + """ + if copy and self.__weighted_histo is not None: + return self.__weighted_histo.copy() + return self.__weighted_histo + + @property + def histo_range(self): + """ + Bins ranges. + """ + return self.__histo_range.copy() + + @property + def n_bins(self): + """ + Number of bins in each direction. + """ + return self.__n_bins.copy() + + @property + def bins_edges(self): + """ + Bins edges of the histograms, one array for each dimensions. + """ + return tuple([edges[:] for edges in self.__edges]) + + @property + def last_bin_closed(self): + """ + Returns True if the rightmost bin in each dimension is close (i.e : + values equal to the rightmost bin edge is included in the bin). + """ + return self.__last_bin_closed + + def accumulate(self, + weights, + weight_min=None, + weight_max=None): + """ + Computes the multidimensional histogram of some data and adds it to + the current histogram stored by this instance. The results can be + retrieved with the :attr:`~.histo` and :attr:`~.weighted_histo` + properties. + + :param weights: + A numpy array of values associated with each sample. The number of + elements in the array must be the same as the number of samples + provided at instantiation time. + :type histo_range: array_like + + :param weight_min: + Use this parameter to filter out all samples whose + weights are lower than this value. + + .. note:: This value will be cast to the same type + as *weights*. + :type weight_min: *optional*, scalar + + :param weight_max: + Use this parameter to filter out all samples whose + weights are higher than this value. + + .. note:: This value will be cast to the same type + as *weights*. + + :type weight_max: *optional*, scalar + """ + if self.__dtype is None: + self.__dtype = weights.dtype + + histo, w_histo = _histo_from_lut(weights, + self.__lut, + histo=self.__histo, + weighted_histo=self.__weighted_histo, + shape=self.__shape, + dtype=self.__dtype, + weight_min=weight_min, + weight_max=weight_max) + + if self.__histo is None: + self.__histo = histo + + if self.__weighted_histo is None: + self.__weighted_histo = w_histo + + def apply_lut(self, + weights, + histo=None, + weighted_histo=None, + weight_min=None, + weight_max=None): + """ + Computes the multidimensional histogram of some data and returns the + result (it is NOT added to the current histogram stored by this + instance). + + :param weights: + A numpy array of values associated with each sample. The number of + elements in the array must be the same as the number of samples + provided at instantiation time. + :type histo_range: array_like + + :param histo: + Use this parameter if you want to pass your + own histogram array instead of the one + created by this function. New values + will be added to this array. The returned array + will then be this one. + :type histo: *optional*, :class:`numpy.array` + + :param weighted_histo: + Use this parameter if you want to pass your + own weighted histogram array instead of + the created by this function. New + values will be added to this array. The returned array + will then be this one (same reference). + :type weighted_histo: *optional*, :class:`numpy.array` + + :param weight_min: + Use this parameter to filter out all samples whose + weights are lower than this value. + + .. note:: This value will be cast to the same type + as *weights*. + :type weight_min: *optional*, scalar + + :param weight_max: + Use this parameter to filter out all samples whose + weights are higher than this value. + + .. note:: This value will be cast to the same type + as *weights*. + :type weight_max: *optional*, scalar + """ + histo, w_histo = _histo_from_lut(weights, + self.__lut, + histo=histo, + weighted_histo=weighted_histo, + shape=self.__shape, + dtype=self.__dtype, + weight_min=weight_min, + weight_max=weight_max) + self.__dtype = w_histo.dtype + return histo, w_histo + +if __name__ == '__main__': + pass diff --git a/src/silx/math/histogramnd/include/histogramnd_c.h b/src/silx/math/histogramnd/include/histogramnd_c.h new file mode 100644 index 0000000..abe464f --- /dev/null +++ b/src/silx/math/histogramnd/include/histogramnd_c.h @@ -0,0 +1,313 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#ifndef HISTOGRAMND_C_H +#define HISTOGRAMND_C_H + +/* checking for MSVC version because VS 2008 doesnt fully support C99 + so inttypes.h and stdint.h are not provided with the compiler. */ +#if defined(_MSC_VER) && _MSC_VER < 1600 + #include "msvc/stdint.h" +#else + #include <inttypes.h> +#endif + +#include "templates.h" + +/** Allowed flag values for the i_opt_flags arguments. + */ +typedef enum { + HISTO_NONE = 0, /**< No options. */ + HISTO_WEIGHT_MIN = 1, /**< Filter weights with i_weight_min. */ + HISTO_WEIGHT_MAX = 1<<1, /**< Filter weights with i_weight_max. */ + HISTO_LAST_BIN_CLOSED = 1<<2 /**< Last bin is closed. */ +} histo_opt_type; + +/** Return codees for the histogramnd function. + */ +typedef enum { + HISTO_OK = 0, /**< No error. */ + HISTO_ERR_ALLOC /**< Failed to allocate memory. */ +} histo_rc_t; + +/*===================== + * double sample, double cumul + * ==================== +*/ + +int histogramnd_double_double_double(double *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + double *o_cumul, + double *o_bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max); + +int histogramnd_double_float_double(double *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + double *o_cumul, + double *o_bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max); + +int histogramnd_double_int32_t_double(double *i_sample, + int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + double *o_cumul, + double *o_bin_edges, + int i_opt_flags, + int32_t i_weight_min, + int32_t i_weight_max); + +/*===================== + * float sample, double cumul + * ==================== +*/ +int histogramnd_float_double_double(float *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + double *o_cumul, + double *o_bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max); + +int histogramnd_float_float_double(float *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + double *o_cumul, + double *o_bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max); + +int histogramnd_float_int32_t_double(float *i_sample, + int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + double *o_cumul, + double *o_bin_edges, + int i_opt_flags, + int32_t i_weight_min, + int32_t i_weight_max); + +/*===================== + * int32_t sample, double cumul + * ==================== +*/ +int histogramnd_int32_t_double_double(int32_t *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + double *o_cumul, + double *o_bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max); + +int histogramnd_int32_t_float_double(int32_t *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + double *o_cumul, + double *o_bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max); + +int histogramnd_int32_t_int32_t_double(int32_t *i_sample, + int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + double *o_cumul, + double *o_bin_edges, + int i_opt_flags, + int32_t i_weight_min, + int32_t i_weight_max); + +/*===================== + * double sample, float cumul + * ==================== +*/ + +int histogramnd_double_double_float(double *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + float *o_cumul, + double *o_bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max); + +int histogramnd_double_float_float(double *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + float *o_cumul, + double *o_bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max); + +int histogramnd_double_int32_t_float(double *i_sample, + int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + float *o_cumul, + double *o_bin_edges, + int i_opt_flags, + int32_t i_weight_min, + int32_t i_weight_max); + +/*===================== + * float sample, float cumul + * ==================== +*/ +int histogramnd_float_double_float(float *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + float *o_cumul, + double *o_bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max); + +int histogramnd_float_float_float(float *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + float *o_cumul, + double *o_bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max); + +int histogramnd_float_int32_t_float(float *i_sample, + int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + float *o_cumul, + double *o_bin_edges, + int i_opt_flags, + int32_t i_weight_min, + int32_t i_weight_max); + +/*===================== + * int32_t sample, double cumul + * ==================== +*/ +int histogramnd_int32_t_double_float(int32_t *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + float *o_cumul, + double *o_bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max); + +int histogramnd_int32_t_float_float(int32_t *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + float *o_cumul, + double *o_bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max); + +int histogramnd_int32_t_int32_t_float(int32_t *i_sample, + int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + uint32_t *o_histo, + float *o_cumul, + double *o_bin_edges, + int i_opt_flags, + int32_t i_weight_min, + int32_t i_weight_max); + +#endif /* #define HISTOGRAMND_C_H */ diff --git a/src/silx/math/histogramnd/include/msvc/stdint.h b/src/silx/math/histogramnd/include/msvc/stdint.h new file mode 100644 index 0000000..e236bb0 --- /dev/null +++ b/src/silx/math/histogramnd/include/msvc/stdint.h @@ -0,0 +1,247 @@ +// ISO C9x compliant stdint.h for Microsoft Visual Studio +// Based on ISO/IEC 9899:TC2 Committee draft (May 6, 2005) WG14/N1124 +// +// Copyright (c) 2006-2008 Alexander Chemeris +// +// 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. The name of the author may be used to endorse or promote products +// derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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. +// +/////////////////////////////////////////////////////////////////////////////// + +#ifndef _MSC_VER // [ +#error "Use this header only with Microsoft Visual C++ compilers!" +#endif // _MSC_VER ] + +#ifndef _MSC_STDINT_H_ // [ +#define _MSC_STDINT_H_ + +#if _MSC_VER > 1000 +#pragma once +#endif + +#include <limits.h> + +// For Visual Studio 6 in C++ mode and for many Visual Studio versions when +// compiling for ARM we should wrap <wchar.h> include with 'extern "C++" {}' +// or compiler give many errors like this: +// error C2733: second C linkage of overloaded function 'wmemchr' not allowed +#ifdef __cplusplus +extern "C" { +#endif +# include <wchar.h> +#ifdef __cplusplus +} +#endif + +// Define _W64 macros to mark types changing their size, like intptr_t. +#ifndef _W64 +# if !defined(__midl) && (defined(_X86_) || defined(_M_IX86)) && _MSC_VER >= 1300 +# define _W64 __w64 +# else +# define _W64 +# endif +#endif + + +// 7.18.1 Integer types + +// 7.18.1.1 Exact-width integer types + +// Visual Studio 6 and Embedded Visual C++ 4 doesn't +// realize that, e.g. char has the same size as __int8 +// so we give up on __intX for them. +#if (_MSC_VER < 1300) + typedef char int8_t; + typedef short int16_t; + typedef int int32_t; + typedef unsigned char uint8_t; + typedef unsigned short uint16_t; + typedef unsigned int uint32_t; +#else + typedef __int8 int8_t; + typedef __int16 int16_t; + typedef __int32 int32_t; + typedef unsigned __int8 uint8_t; + typedef unsigned __int16 uint16_t; + typedef unsigned __int32 uint32_t; +#endif +typedef __int64 int64_t; +typedef unsigned __int64 uint64_t; + + +// 7.18.1.2 Minimum-width integer types +typedef int8_t int_least8_t; +typedef int16_t int_least16_t; +typedef int32_t int_least32_t; +typedef int64_t int_least64_t; +typedef uint8_t uint_least8_t; +typedef uint16_t uint_least16_t; +typedef uint32_t uint_least32_t; +typedef uint64_t uint_least64_t; + +// 7.18.1.3 Fastest minimum-width integer types +typedef int8_t int_fast8_t; +typedef int16_t int_fast16_t; +typedef int32_t int_fast32_t; +typedef int64_t int_fast64_t; +typedef uint8_t uint_fast8_t; +typedef uint16_t uint_fast16_t; +typedef uint32_t uint_fast32_t; +typedef uint64_t uint_fast64_t; + +// 7.18.1.4 Integer types capable of holding object pointers +#ifdef _WIN64 // [ + typedef __int64 intptr_t; + typedef unsigned __int64 uintptr_t; +#else // _WIN64 ][ + typedef _W64 int intptr_t; + typedef _W64 unsigned int uintptr_t; +#endif // _WIN64 ] + +// 7.18.1.5 Greatest-width integer types +typedef int64_t intmax_t; +typedef uint64_t uintmax_t; + + +// 7.18.2 Limits of specified-width integer types + +#if !defined(__cplusplus) || defined(__STDC_LIMIT_MACROS) // [ See footnote 220 at page 257 and footnote 221 at page 259 + +// 7.18.2.1 Limits of exact-width integer types +#define INT8_MIN ((int8_t)_I8_MIN) +#define INT8_MAX _I8_MAX +#define INT16_MIN ((int16_t)_I16_MIN) +#define INT16_MAX _I16_MAX +#define INT32_MIN ((int32_t)_I32_MIN) +#define INT32_MAX _I32_MAX +#define INT64_MIN ((int64_t)_I64_MIN) +#define INT64_MAX _I64_MAX +#define UINT8_MAX _UI8_MAX +#define UINT16_MAX _UI16_MAX +#define UINT32_MAX _UI32_MAX +#define UINT64_MAX _UI64_MAX + +// 7.18.2.2 Limits of minimum-width integer types +#define INT_LEAST8_MIN INT8_MIN +#define INT_LEAST8_MAX INT8_MAX +#define INT_LEAST16_MIN INT16_MIN +#define INT_LEAST16_MAX INT16_MAX +#define INT_LEAST32_MIN INT32_MIN +#define INT_LEAST32_MAX INT32_MAX +#define INT_LEAST64_MIN INT64_MIN +#define INT_LEAST64_MAX INT64_MAX +#define UINT_LEAST8_MAX UINT8_MAX +#define UINT_LEAST16_MAX UINT16_MAX +#define UINT_LEAST32_MAX UINT32_MAX +#define UINT_LEAST64_MAX UINT64_MAX + +// 7.18.2.3 Limits of fastest minimum-width integer types +#define INT_FAST8_MIN INT8_MIN +#define INT_FAST8_MAX INT8_MAX +#define INT_FAST16_MIN INT16_MIN +#define INT_FAST16_MAX INT16_MAX +#define INT_FAST32_MIN INT32_MIN +#define INT_FAST32_MAX INT32_MAX +#define INT_FAST64_MIN INT64_MIN +#define INT_FAST64_MAX INT64_MAX +#define UINT_FAST8_MAX UINT8_MAX +#define UINT_FAST16_MAX UINT16_MAX +#define UINT_FAST32_MAX UINT32_MAX +#define UINT_FAST64_MAX UINT64_MAX + +// 7.18.2.4 Limits of integer types capable of holding object pointers +#ifdef _WIN64 // [ +# define INTPTR_MIN INT64_MIN +# define INTPTR_MAX INT64_MAX +# define UINTPTR_MAX UINT64_MAX +#else // _WIN64 ][ +# define INTPTR_MIN INT32_MIN +# define INTPTR_MAX INT32_MAX +# define UINTPTR_MAX UINT32_MAX +#endif // _WIN64 ] + +// 7.18.2.5 Limits of greatest-width integer types +#define INTMAX_MIN INT64_MIN +#define INTMAX_MAX INT64_MAX +#define UINTMAX_MAX UINT64_MAX + +// 7.18.3 Limits of other integer types + +#ifdef _WIN64 // [ +# define PTRDIFF_MIN _I64_MIN +# define PTRDIFF_MAX _I64_MAX +#else // _WIN64 ][ +# define PTRDIFF_MIN _I32_MIN +# define PTRDIFF_MAX _I32_MAX +#endif // _WIN64 ] + +#define SIG_ATOMIC_MIN INT_MIN +#define SIG_ATOMIC_MAX INT_MAX + +#ifndef SIZE_MAX // [ +# ifdef _WIN64 // [ +# define SIZE_MAX _UI64_MAX +# else // _WIN64 ][ +# define SIZE_MAX _UI32_MAX +# endif // _WIN64 ] +#endif // SIZE_MAX ] + +// WCHAR_MIN and WCHAR_MAX are also defined in <wchar.h> +#ifndef WCHAR_MIN // [ +# define WCHAR_MIN 0 +#endif // WCHAR_MIN ] +#ifndef WCHAR_MAX // [ +# define WCHAR_MAX _UI16_MAX +#endif // WCHAR_MAX ] + +#define WINT_MIN 0 +#define WINT_MAX _UI16_MAX + +#endif // __STDC_LIMIT_MACROS ] + + +// 7.18.4 Limits of other integer types + +#if !defined(__cplusplus) || defined(__STDC_CONSTANT_MACROS) // [ See footnote 224 at page 260 + +// 7.18.4.1 Macros for minimum-width integer constants + +#define INT8_C(val) val##i8 +#define INT16_C(val) val##i16 +#define INT32_C(val) val##i32 +#define INT64_C(val) val##i64 + +#define UINT8_C(val) val##ui8 +#define UINT16_C(val) val##ui16 +#define UINT32_C(val) val##ui32 +#define UINT64_C(val) val##ui64 + +// 7.18.4.2 Macros for greatest-width integer constants +#define INTMAX_C INT64_C +#define UINTMAX_C UINT64_C + +#endif // __STDC_CONSTANT_MACROS ] + + +#endif // _MSC_STDINT_H_ ] diff --git a/src/silx/math/histogramnd/include/templates.h b/src/silx/math/histogramnd/include/templates.h new file mode 100644 index 0000000..490eed3 --- /dev/null +++ b/src/silx/math/histogramnd/include/templates.h @@ -0,0 +1,30 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#ifndef TEMPLATES_H_ +#define TEMPLATES_H_ + +#define CONCAT(X,Y,Z,T) X##_##Y##_##Z##_##T +#define TEMPLATE(X,Y,Z,T) CONCAT(X,Y,Z,T) + +#endif diff --git a/src/silx/math/histogramnd/src/histogramnd_c.c b/src/silx/math/histogramnd/src/histogramnd_c.c new file mode 100644 index 0000000..fc9d77e --- /dev/null +++ b/src/silx/math/histogramnd/src/histogramnd_c.c @@ -0,0 +1,301 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#include "histogramnd_c.h" + +/*===================== + * double sample, double cumul + * ===================== +*/ +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T double +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T double +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T double +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T double +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T float +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T double +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T double +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T int32_t +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T double +#include "histogramnd_template.c" + +/*===================== + * float sample, double cumul + * ===================== +*/ +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T float +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T double +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T double +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T float +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T float +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T double +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T float +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T int32_t +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T double +#include "histogramnd_template.c" + +/*===================== + * int32_t sample, double cumul + * ===================== +*/ +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T int32_t +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T double +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T double +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T int32_t +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T float +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T double +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T int32_t +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T int32_t +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T double +#include "histogramnd_template.c" + + +/*===================== + * double sample, float cumul + * ===================== +*/ +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T double +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T double +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T float +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T double +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T float +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T float +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T double +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T int32_t +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T float +#include "histogramnd_template.c" + +/*===================== + * float sample, float cumul + * ===================== +*/ +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T float +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T double +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T float +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T float +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T float +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T float +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T float +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T int32_t +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T float +#include "histogramnd_template.c" + +/*===================== + * int32_t sample, float cumul + * ===================== +*/ +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T int32_t +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T double +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T float +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T int32_t +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T float +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T float +#include "histogramnd_template.c" + +#ifdef HISTO_SAMPLE_T +#undef HISTO_SAMPLE_T +#endif +#define HISTO_SAMPLE_T int32_t +#ifdef HISTO_WEIGHT_T +#undef HISTO_WEIGHT_T +#endif +#define HISTO_WEIGHT_T int32_t +#ifdef HISTO_CUMUL_T +#undef HISTO_CUMUL_T +#endif +#define HISTO_CUMUL_T float +#include "histogramnd_template.c" diff --git a/src/silx/math/histogramnd/src/histogramnd_template.c b/src/silx/math/histogramnd/src/histogramnd_template.c new file mode 100644 index 0000000..0276bb4 --- /dev/null +++ b/src/silx/math/histogramnd/src/histogramnd_template.c @@ -0,0 +1,260 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#include "templates.h" + +#include <stdio.h> +#include <stdlib.h> +#include <math.h> +#include <stdarg.h> + +#ifdef HISTO_SAMPLE_T +#ifdef HISTO_WEIGHT_T +#ifdef HISTO_CUMUL_T + +int TEMPLATE(histogramnd, HISTO_SAMPLE_T, HISTO_WEIGHT_T, HISTO_CUMUL_T) + (HISTO_SAMPLE_T *i_sample, + HISTO_WEIGHT_T *i_weights, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bins, + uint32_t *o_histo, + HISTO_CUMUL_T *o_cumul, + double *o_bin_edges, + int i_opt_flags, + HISTO_WEIGHT_T i_weight_min, + HISTO_WEIGHT_T i_weight_max) +{ + /* some counters */ + int i = 0, j = 0; + long elem_idx = 0; + + HISTO_WEIGHT_T * weight_ptr = 0; + HISTO_SAMPLE_T elem_coord = 0.; + + /* computed bin index (i_sample -> grid) */ + long bin_idx = 0; + + double * g_min = 0; + double * g_max = 0; + double * range = 0; + + /* ================================ + * Parsing options, if any. + * ================================ + */ + + int filt_min_weight = 0; + int filt_max_weight = 0; + int last_bin_closed = 0; + + /* Testing the option flags */ + if(i_opt_flags & HISTO_WEIGHT_MIN) + { + filt_min_weight = 1; + } + + if(i_opt_flags & HISTO_WEIGHT_MAX) + { + filt_max_weight = 1; + } + + if(i_opt_flags & HISTO_LAST_BIN_CLOSED) + { + last_bin_closed = 1; + } + + /* storing the min & max bin coordinates in their own arrays because + * i_bin_ranges = [[min0, max0], [min1, max1], ...] + * (mostly for the sake of clarity) + * (maybe faster access too?) + */ + g_min = (double *) malloc(i_n_dim *sizeof(double)); + g_max = (double *) malloc(i_n_dim * sizeof(double)); + /* range used to convert from i_coords to bin indices in the grid */ + range = (double *) malloc(i_n_dim * sizeof(double)); + + if(!g_min || !g_max || !range) + { + free(g_min); + free(g_max); + free(range); + return HISTO_ERR_ALLOC; + } + + j = 0; + for(i=0; i<i_n_dim; i++) + { + g_min[i] = i_bin_ranges[i*2]; + g_max[i] = i_bin_ranges[i*2+1]; + range[i] = g_max[i]-g_min[i]; + + for(bin_idx=0; bin_idx<i_n_bins[i]; j++, bin_idx++) + { + o_bin_edges[j] = g_min[i] + + bin_idx * (range[i] / i_n_bins[i]); + } + o_bin_edges[j++] = g_max[i]; + } + + weight_ptr = i_weights; + + if(!i_weights) + { + /* if weights are not provided there no point in trying to filter them + * (!! careful if you change this, some code below relies on it !!) + */ + filt_min_weight = 0; + filt_max_weight = 0; + + /* If the weights array is not provided then there is no point + * updating the weighted histogram, only the bin counts (o_histo) + * will be filled. + * (!! careful if you change this, some code below relies on it !!) + */ + o_cumul = 0; + } + + /* tried to use pointers instead of indices here, but it didn't + * seem any faster (probably because the compiler + * optimizes stuff anyway), + * so i'm keeping the "indices" version, for the sake of clarity + */ + for(elem_idx=0; + elem_idx<i_n_elem*i_n_dim; + elem_idx+=i_n_dim, weight_ptr++) + { + /* no testing the validity of weight_ptr here, because if it is NULL + * then filt_min_weight/filt_max_weight will be 0. + * (see code above) + */ + if(filt_min_weight && *weight_ptr<i_weight_min) + { + continue; + } + if(filt_max_weight && *weight_ptr>i_weight_max) + { + continue; + } + + bin_idx = 0; + + for(i=0; i<i_n_dim; i++) + { + elem_coord = i_sample[elem_idx+i]; + + /* ===================== + * Element is rejected if any of the following is NOT true : + * 1. coordinate is >= than the minimum value + * 2. coordinate is <= than the maximum value + * 3. coordinate==maximum value and last_bin_closed is True + * ===================== + */ + if(elem_coord<g_min[i]) + { + bin_idx = -1; + break; + } + + /* Here we make the assumption that most of the time + * there will be more coordinates inside the grid interval + * (one test) + * than coordinates higher or equal to the max + * (two tests) + */ + if(elem_coord<g_max[i]) + { + /* Warning : the following factorization seems to + * increase the effect of precision error. + * bin_idx = (long)floor( + * (bin_idx + + * (elem_coord-g_min[i])/range[i]) * + * i_n_bins[i] + * ); + */ + + /* Not using floor to speed up things. + * We don't (?) need all the error checking provided by + * the built-in floor(). + * Also the value is supposed to be always positive. + */ + bin_idx = bin_idx * i_n_bins[i] + + (long)( + ((elem_coord-g_min[i]) * i_n_bins[i]) / + range[i] + ); + } + else /* ===> elem_coord>=g_max[i] */ + { + /* if equal and the last bin is closed : + * put it in the last bin + * else : discard + */ + if(last_bin_closed && elem_coord==g_max[i]) + { + bin_idx = (bin_idx + 1) * i_n_bins[i] - 1; + } + else + { + bin_idx = -1; + break; + } + } /* if(elem_coord<g_max[i]) */ + + } /* for(i=0; i<i_n_dim; i++) */ + + /* element is out of the grid */ + if(bin_idx==-1) + { + continue; + } + + if(o_histo) + { + o_histo[bin_idx] += 1; + } + if(o_cumul) + { + /* not testing the pointer since o_cumul is null if + * i_weights is null. + */ + o_cumul[bin_idx] += (HISTO_CUMUL_T) *weight_ptr; + } + + } /* for(elem_idx=0; elem_idx<i_n_elem*i_n_dim; elem_idx+=i_n_dim) */ + + free(g_min); + free(g_max); + free(range); + + /* For now just returning 0 (OK) since all the checks are done in + * python. This might change later if people want to call this + * function directly from C (might have to implement error codes). + */ + return HISTO_OK; +} + +#endif +#endif +#endif diff --git a/src/silx/math/histogramnd_c.pxd b/src/silx/math/histogramnd_c.pxd new file mode 100644 index 0000000..35db529 --- /dev/null +++ b/src/silx/math/histogramnd_c.pxd @@ -0,0 +1,299 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["D. Naudet"] +__license__ = "MIT" +__date__ = "01/02/2016" + +cimport numpy as cnumpy + +cdef extern from "histogramnd_c.h": + + ctypedef enum histo_opt_type: + HISTO_NONE + HISTO_WEIGHT_MIN + HISTO_WEIGHT_MAX + HISTO_LAST_BIN_CLOSED + + ctypedef enum histo_rc_t: + HISTO_OK + HISTO_ERR_ALLOC + + # ===================== + # double sample, double cumul + # ===================== + + int histogramnd_double_double_double(double *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + double *o_cumul, + double * bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max) nogil + + int histogramnd_double_float_double(double *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + double *o_cumul, + double * bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max) nogil + + int histogramnd_double_int32_t_double(double *i_sample, + cnumpy.int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + double *o_cumul, + double * bin_edges, + int i_opt_flags, + cnumpy.int32_t i_weight_min, + cnumpy.int32_t i_weight_max) nogil + + # ===================== + # float sample, double cumul + # ===================== + + int histogramnd_float_double_double(float *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + double *o_cumul, + double * bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max) nogil + + int histogramnd_float_float_double(float *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + double *o_cumul, + double * bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max) nogil + + int histogramnd_float_int32_t_double(float *i_sample, + cnumpy.int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + double *o_cumul, + double * bin_edges, + int i_opt_flags, + cnumpy.int32_t i_weight_min, + cnumpy.int32_t i_weight_max) nogil + + # ===================== + # numpy.int32_t sample, double cumul + # ===================== + + int histogramnd_int32_t_double_double(cnumpy.int32_t *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + double *o_cumul, + double * bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max) nogil + + int histogramnd_int32_t_float_double(cnumpy.int32_t *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + double *o_cumul, + double * bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max) nogil + + int histogramnd_int32_t_int32_t_double(cnumpy.int32_t *i_sample, + cnumpy.int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + double *o_cumul, + double * bin_edges, + int i_opt_flags, + cnumpy.int32_t i_weight_min, + cnumpy.int32_t i_weight_max) nogil + + # ===================== + # double sample, float cumul + # ===================== + + int histogramnd_double_double_float(double *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + float *o_cumul, + double * bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max) nogil + + int histogramnd_double_float_float(double *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + float *o_cumul, + double * bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max) nogil + + int histogramnd_double_int32_t_float(double *i_sample, + cnumpy.int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + float *o_cumul, + double * bin_edges, + int i_opt_flags, + cnumpy.int32_t i_weight_min, + cnumpy.int32_t i_weight_max) nogil + + # ===================== + # float sample, float cumul + # ===================== + + int histogramnd_float_double_float(float *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + float *o_cumul, + double * bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max) nogil + + int histogramnd_float_float_float(float *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + float *o_cumul, + double * bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max) nogil + + int histogramnd_float_int32_t_float(float *i_sample, + cnumpy.int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + float *o_cumul, + double * bin_edges, + int i_opt_flags, + cnumpy.int32_t i_weight_min, + cnumpy.int32_t i_weight_max) nogil + + # ===================== + # numpy.int32_t sample, float cumul + # ===================== + + int histogramnd_int32_t_double_float(cnumpy.int32_t *i_sample, + double *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + float *o_cumul, + double * bin_edges, + int i_opt_flags, + double i_weight_min, + double i_weight_max) nogil + + int histogramnd_int32_t_float_float(cnumpy.int32_t *i_sample, + float *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + float *o_cumul, + double * bin_edges, + int i_opt_flags, + float i_weight_min, + float i_weight_max) nogil + + int histogramnd_int32_t_int32_t_float(cnumpy.int32_t *i_sample, + cnumpy.int32_t *i_weigths, + int i_n_dim, + int i_n_elem, + double *i_bin_ranges, + int *i_n_bin, + cnumpy.uint32_t *o_histo, + float *o_cumul, + double * bin_edges, + int i_opt_flags, + cnumpy.int32_t i_weight_min, + cnumpy.int32_t i_weight_max) nogil diff --git a/src/silx/math/include/math_compatibility.h b/src/silx/math/include/math_compatibility.h new file mode 100644 index 0000000..3d69c0c --- /dev/null +++ b/src/silx/math/include/math_compatibility.h @@ -0,0 +1,53 @@ +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +/* This header provides libc math functions and macros across platforms. + + Needed as VisualStudio 2008 (i.e., Python2.7) is missing some functions/macros. +*/ + +#ifndef __MATH_COMPATIBILITY_H__ +#define __MATH_COMPATIBILITY_H__ + +#include <math.h> + +#ifndef INFINITY +#define INFINITY (DBL_MAX+DBL_MAX) +#endif + +#ifndef NAN +#define NAN (INFINITY-INFINITY) +#endif + +#if (defined (_MSC_VER) && _MSC_VER < 1800) +#include <float.h> + +/* Make sure asinh returns -inf rather than NaN for v=-inf */ +#define asinh(v) (v == -INFINITY ? v : log((v) + sqrt((v)*(v) + 1))) + +#define isnan(v) _isnan(v) +#define isfinite(v) _finite(v) +#define lrint(v) ((long int) (v)) +#endif + +#endif /*__MATH_COMPATIBILITY_H__*/ diff --git a/src/silx/math/interpolate.pyx b/src/silx/math/interpolate.pyx new file mode 100644 index 0000000..c79224a --- /dev/null +++ b/src/silx/math/interpolate.pyx @@ -0,0 +1,165 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""This module provides :func:`interp3d` to perform trilinear interpolation. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "11/07/2019" + + +import cython +from cython.parallel import prange +import numpy + +cimport cython +from libc.math cimport floor +cimport numpy as cnumpy + + +ctypedef fused _floating: + float + double + +ctypedef fused _floating_pts: + float + double + + +@cython.initializedcheck(False) +@cython.boundscheck(False) +@cython.wraparound(False) +cdef inline double trilinear_interpolation( + _floating[:, :, :] values, + _floating_pts pos0, + _floating_pts pos1, + _floating_pts pos2, + double fill_value) nogil: + """Evaluate the trilinear interpolation at a given position + + :param values: 3D dataset from which to do the interpolation + :param pos0: Dimension 0 coordinate at which to evaluate the interpolation + :param pos1: Dimension 1 coordinate at which to evaluate the interpolation + :param pos2: Dimension 2 coordinate at which to evaluate the interpolation + :param fill_value: Value to return for points outside data + """ + cdef: + int i0, i1, i2 # Indices + int i0_plus1, i1_plus1, i2_plus1 # Indices+1 + double delta + double c00, c01, c10, c11, c0, c1 + double c + + if (pos0 < 0. or pos0 > (values.shape[0] -1) or + pos1 < 0. or pos1 > (values.shape[1] -1) or + pos2 < 0. or pos2 > (values.shape[2] -1)): + return fill_value + + i0 = < int > floor(pos0) + i1 = < int > floor(pos1) + i2 = < int > floor(pos2) + + # Clip i+1 indices to data volume + # In this case, corresponding dX is 0. + i0_plus1 = min(i0 + 1, values.shape[0] - 1) + i1_plus1 = min(i1 + 1, values.shape[1] - 1) + i2_plus1 = min(i2 + 1, values.shape[2] - 1) + + if pos2 == i2: # Avoids multiplication by 0 (which yields to NaN with inf) + c00 = <double> values[i0, i1, i2] + c10 = <double> values[i0, i1_plus1, i2] + c01 = <double> values[i0_plus1, i1, i2] + c11 = <double> values[i0_plus1, i1_plus1, i2] + else: + delta = pos2 - i2 + c00 = (<double> values[i0, i1, i2]) * (1. - delta) + (<double> values[i0, i1, i2_plus1]) * delta + c10 = (<double> values[i0, i1_plus1, i2]) * (1. - delta) + (<double> values[i0, i1_plus1, i2_plus1]) * delta + c01 = (<double> values[i0_plus1, i1, i2]) * (1. - delta) + (<double> values[i0_plus1, i1, i2_plus1]) * delta + c11 = (<double> values[i0_plus1, i1_plus1, i2]) * (1. - delta) + (<double> values[i0_plus1, i1_plus1, i2_plus1]) * delta + + if pos1 == i1: # Avoids multiplication by 0 (which yields to NaN with inf) + c0 = c00 + c1 = c01 + else: + delta = pos1 - i1 + c0 = c00 * (1. - delta) + c10 * delta + c1 = c01 * (1. - delta) + c11 * delta + + if pos0 == i0: # Avoids multiplication by 0 (which yields to NaN with inf) + c = c0 + else: + delta = pos0 - i0 + c = c0 * (1 - delta) + c1 * delta + + return c + + +@cython.boundscheck(False) +@cython.wraparound(False) +def interp3d(_floating[:, :, :] values not None, + _floating_pts[:, :] xi not None, + str method='linear', + double fill_value=numpy.nan): + """Trilinear interpolation in a regular grid. + + Perform trilinear interpolation of the 3D dataset at given points + + :param numpy.ndarray values: 3D dataset of floating point values + :param numpy.ndarray xi: (N, 3) sampling points + :param str method: Interpolation method to use in: + - 'linear': Trilinear interpolation + - 'linear_omp': Trilinear interpolation with OpenMP parallelism + :param float fill_value: + Value to use for points outside the volume (default: nan) + :return: Values evaluated at given input points. + :rtype: numpy.ndarray + """ + if _floating is cnumpy.float32_t: + dtype = numpy.float32 + elif _floating is cnumpy.float64_t: + dtype = numpy.float64 + else: # This should not happen + raise ValueError("Unsupported input dtype") + + cdef: + int npoints = xi.shape[0] + _floating[:] result = numpy.empty((npoints,), dtype=dtype) + int index + double c_fill_value = fill_value + + if method == 'linear': + with nogil: + for index in range(npoints): + result[index] = < _floating > trilinear_interpolation( + values, xi[index, 0], xi[index, 1], xi[index, 2], c_fill_value) + + elif method == 'linear_omp': + for index in prange(npoints, nogil=True): + result[index] = < _floating > trilinear_interpolation( + values, xi[index, 0], xi[index, 1], xi[index, 2], c_fill_value) + else: + raise ValueError("Unsupported method: %s" % method) + + return numpy.array(result, copy=False)
\ No newline at end of file diff --git a/src/silx/math/marchingcubes.pyx b/src/silx/math/marchingcubes.pyx new file mode 100644 index 0000000..0409691 --- /dev/null +++ b/src/silx/math/marchingcubes.pyx @@ -0,0 +1,246 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides marching cubes implementation. + +It provides a :class:`MarchingCubes` class allowing to build an isosurface +from data provided as a 3D data set or slice by slice. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "16/08/2017" + + +import numpy +cimport numpy as cnumpy +cimport cython + +cimport silx.math.mc as mc + + +# From numpy_common.pxi to avoid warnings while compiling C code +# See this thread: +# https://mail.python.org/pipermail//cython-devel/2012-March/002137.html +cdef extern from *: + bint FALSE "0" + void import_array() + void import_umath() + +if FALSE: + import_array() + import_umath() + + +cdef class MarchingCubes: + """Compute isosurface using marching cubes algorithm. + + It builds a surface from a 3D scalar dataset as a 3D contour at a + given value. + The resulting surface is not topologically correct. + + See: http://paulbourke.net/geometry/polygonise/ + + Lorensen, W. E. and Cline, H. E. Marching cubes: A high resolution 3D + surface construction algorithm. Computer Graphics, 21, 4 (July 1987). + ACM, 163-169. + + Generated vertex and normal coordinates are in the same order + as input array, i.e., (dim 0, dim 1, dim 2). + + Expected indices in memory of a (2, 2, 2) dataset: + + dim 0 (depth) + | + | + 4 +------+ 5 + /| /| + / | / | + 6 +------+ 7| + | | | | + |0 +---|--+ 1 --- dim 2 (width) + | / | / + |/ |/ + 2 +------+ 3 + / + / + dim 1 (height) + + Example with a 3D data set: + + >>> vertices, normals, indices = MarchingCubes(data, isolevel=1.) + + Example of code for processing a list of images: + + >>> mc = MarchingCubes(isolevel=1.) # Create object with iso-level=1 + >>> previous_image = images[0] + >>> for image in images[1:]: + ... mc.process_image(previous_image, image) # Process one slice + ... previous_image = image + + >>> vertices = mc.get_vertices() # Array of vertex positions + >>> normals = mc.get_normals() # Array of normals + >>> triangle_indices = mc.get_indices() # Array of indices of vertices + + :param data: 3D dataset of float32 or None + :type data: numpy.ndarray of float32 of dimension 3 + :param float isolevel: The value for which to generate the isosurface + :param bool invert_normals: + True (default) for normals oriented in direction of gradient descent + :param sampling: Sampling along each dimension (depth, height, width) + """ + cdef mc.MarchingCubes[float, float] * c_mc # Pointer to the C++ instance + + def __cinit__(self, data=None, isolevel=None, + invert_normals=True, sampling=(1, 1, 1)): + self.c_mc = new mc.MarchingCubes[float, float](isolevel) + self.c_mc.invert_normals = bool(invert_normals) + self.c_mc.sampling[0] = sampling[0] + self.c_mc.sampling[1] = sampling[1] + self.c_mc.sampling[2] = sampling[2] + + if data is not None: + self.process(data) + + def __dealloc__(self): + del self.c_mc + + def __getitem__(self, key): + """Allows one to unpack object as a single liner: + + vertices, normals, indices = MarchingCubes(...) + """ + if key == 0: + return self.get_vertices() + elif key == 1: + return self.get_normals() + elif key == 2: + return self.get_indices() + else: + raise IndexError("Index out of range") + + def process(self, data): + """Compute an isosurface from a 3D scalar field. + + This builds vertices, normals and indices arrays. + Vertices and normals coordinates are in the same order as input array, + i.e., (dim 0, dim 1, dim 2). + + :param numpy.ndarray data: 3D scalar field + """ + # Make sure data is a 3D contiguous array of native endian float32 + data = numpy.ascontiguousarray(data, dtype='=f4') + assert data.ndim == 3 + cdef float[:] c_data = numpy.ravel(data) + cdef unsigned int depth, height, width + + depth = data.shape[0] + height = data.shape[1] + width = data.shape[2] + + self.c_mc.process(&c_data[0], depth, height, width) + + def process_slice(self, slice0, slice1): + """Process a new slice to build the isosurface. + + :param numpy.ndarray slice0: Slice previously provided as slice1. + :param numpy.ndarray slice1: Slice to process. + """ + # Make sure slices are 2D contiguous arrays of native endian float32 + slice0 = numpy.ascontiguousarray(slice0, dtype='=f4') + assert slice0.ndim == 2 + slice1 = numpy.ascontiguousarray(slice1, dtype='=f4') + assert slice1.ndim == 2 + + assert slice0.shape[0] == slice1.shape[0] + assert slice0.shape[1] == slice1.shape[1] + + cdef float[:] c_slice0 = numpy.ravel(slice0) + cdef float[:] c_slice1 = numpy.ravel(slice1) + + if self.c_mc.depth == 0: + # Starts a new isosurface, bootstrap with slice size + self.c_mc.set_slice_size(slice1.shape[0], slice1.shape[1]) + + assert slice1.shape[0] == self.c_mc.height + assert slice1.shape[1] == self.c_mc.width + + self.c_mc.process_slice(&c_slice0[0], &c_slice1[0]) + + def finish_process(self): + """Clear internal cache after processing slice by slice.""" + self.c_mc.finish_process() + + def reset(self): + """Reset internal resources including computed isosurface info.""" + self.c_mc.reset() + + @cython.embedsignature(False) + @property + def shape(self): + """The shape of the processed scalar field (depth, height, width).""" + return self.c_mc.depth, self.c_mc.height, self.c_mc.width + + @cython.embedsignature(False) + @property + def sampling(self): + """The sampling over each dimension (depth, height, width). + + Default: 1, 1, 1 + """ + return (self.c_mc.sampling[0], + self.c_mc.sampling[1], + self.c_mc.sampling[2]) + + @cython.embedsignature(False) + @property + def isolevel(self): + """The iso-level at which to generate the isosurface""" + return self.c_mc.isolevel + + @cython.embedsignature(False) + @property + def invert_normals(self): + """True to use gradient descent as normals.""" + return self.c_mc.invert_normals + + def get_vertices(self): + """Vertices currently computed (ndarray of dim NbVertices x 3) + + Order is dim0, dim1, dim2 (i.e., z, y, x if dim0 is depth). + """ + return numpy.array(self.c_mc.vertices).reshape(-1, 3) + + def get_normals(self): + """Normals currently computed (ndarray of dim NbVertices x 3) + + Order is dim0, dim1, dim2 (i.e., z, y, x if dim0 is depth). + """ + return numpy.array(self.c_mc.normals).reshape(-1, 3) + + def get_indices(self): + """Triangle indices currently computed (ndarray of dim NbTriangles x 3) + """ + return numpy.array(self.c_mc.indices, + dtype=numpy.uint32).reshape(-1, 3) diff --git a/src/silx/math/marchingcubes/mc.hpp b/src/silx/math/marchingcubes/mc.hpp new file mode 100644 index 0000000..82eced9 --- /dev/null +++ b/src/silx/math/marchingcubes/mc.hpp @@ -0,0 +1,724 @@ +/*########################################################################## +# +# Copyright (c) 2015-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +#ifndef __mc_HPP__ +#define __mc_HPP__ + +#include <iostream> +#include <cmath> +#include <map> +#include <stdexcept> +#include <vector> +#include <assert.h> + + +extern const int MCTriangleTable[256][16]; +extern const unsigned int MCEdgeIndexToCoordOffsets[12][4]; + +#define DEPTH_IDX 0 +#define HEIGHT_IDX 1 +#define WIDTH_IDX 2 + +/** Class Marching cubes + * + * Implements the marching cube algorithm and provides an API to process + * data image by image. + * + * Dimension convention used is (dim0, dim1, dim2) denoted as + * (depth, height, width) with dim2 (= width) being contiguous in memory. + * + * If data is provided as (depth, height, width), resulting vertices + * and normals will be stored as (z, y, x) + * + * Indices in memory for a single cube: + * + * dim 0 (depth) + * | + * | + * 4 +------+ 5 + * /| /| + * / | / | + * 6 +------+ 7| + * | | | | + * |0 +---|--+ 1 --- dim 2 (width) + * | / | / + * |/ |/ + * 2 +------+ 3 + * / + * / + * dim 1 (height) + */ +template <typename FloatIn, typename FloatOut> +class MarchingCubes { +public: + /** Create a marching cube object. + * + * @param level Level at which to build the isosurface + */ + MarchingCubes(const FloatIn level); + + ~MarchingCubes(); + + /** Process a 3D scalar field + * + * @param data Pointer to the data set + * @param depth The 1st dimension of the data set + * @param height The 2nd dimension of the data set + * @param width The 3rd dimension of the data set + * (tightly packed in memory) + */ + void process(const FloatIn * data, + const unsigned int depth, + const unsigned int height, + const unsigned int width); + + /** Init dimension of slices + * + * @param height Height in pixels of the slices + * @param width Width in pixels of the slices + */ + void set_slice_size(const unsigned int height, + const unsigned int width); + + /** Process a slice (i.e., an image) + * + * The size of the images MUST match height and width provided to + * set_slice_size. + * + * The marching cube process 2 consecutive images at a time. + * A slice provided as next parameter MUST be provided as current + * parameter for the next call. + * Example with 3 images: + * + * float * img1; + * float * img2; + * float * img3; + * ... + * mc = MarchingCubes<float>(100.); + * mc.set_slice_size(10, 10); + * mc.process_slice(img1, img2); + * mc.process_slice(img2, img3); + * mc.finish_process(); + * + * @param slice0 Pointer to the nth slice data + * @param slice1 Pointer to the (n+1)th slice of data + */ + void process_slice(const FloatIn * slice0, + const FloatIn * slice1); + + /** Clear marching cube processing internal cache. */ + void finish_process(); + + /** Reset all internal data and counters. */ + void reset(); + + /** Vertices of the isosurface (x, y, z) */ + std::vector<FloatOut> vertices; + + /** Approximation of normals at the vertices (nx, ny, nz) + * + * Current implementation provides coarse (but fast) normals computation + */ + std::vector<FloatOut> normals; + + /** Triangle indices */ + std::vector<unsigned int> indices; + + unsigned int depth; /**< Number of images currently processed */ + unsigned int height; /**< Images height in pixels */ + unsigned int width; /**< Images width in pixels */ + + /** Sampling of the data (depth, height, width) + * + * Default: 1, 1, 1 + */ + unsigned int sampling[3]; + + FloatIn isolevel; /**< Iso level to use */ + bool invert_normals; /**< True to inverse gradient as normals */ + +private: + + /** Start to build isosurface starting with first slice + * + * Bootstrap cache edge_indices + * + * @param slice The first slice of the data + * @param next The second slice + */ + void first_slice(const FloatIn * slice, + const FloatIn * next); + + /** Process an edge + * + * @param value0 Data at 'begining' of edge + * @param value Data at 'end' of edge + * @param depth Depth coordinate of the edge position + * @param row Row coordinate of the edge + * @param col Column coordinate of the edge + * @param direction Direction of the edge: 0 for x, 1 for y and 2 for z + * @param previous + * @param current + * @param next + */ + void process_edge(const FloatIn value0, + const FloatIn value, + const unsigned int depth, + const unsigned int row, + const unsigned int col, + const unsigned int direction, + const FloatIn * previous, + const FloatIn * current, + const FloatIn * next); + + /** Return the bit mask of cube corners <= the iso-value. + * + * @param slice1 1st slice of the cube to consider + * @param slice2 2nd slice of the cube to consider + * @param row Row of the cube to consider + * @param col Column of the cube to consider + * @return The bit mask of cube corners <= the iso-value + */ + unsigned char get_cell_code(const FloatIn * slice1, + const FloatIn * slice2, + const unsigned int row, + const unsigned int col); + + /** Compute an edge index from position and edge direction. + * + * @param depth Depth of the origin of the edge + * @param row Row of the origin of the edge + * @param col Column of the origin of the edge + * @param direction 0 for x, 1 for y, 2 for z + * @return The (4D) index of the edge + */ + unsigned int edge_index(const unsigned int depth, + const unsigned int row, + const unsigned int col, + const unsigned int direction); + + /** For each dimension, a map from edge index to vertex index + * + * This caches indices for previously processed slice. + * + * Edge index is the linearized position of the edge using size + 1 + * in all dimensions as coordinates plus the direction as 4th coord. + * WARNING: direction 0 for x, 1 for y and 2 for z + */ + std::map<unsigned int, unsigned int> * edge_indices; +}; + + +/* Implementation */ + +template <typename FloatIn, typename FloatOut> +MarchingCubes<FloatIn, FloatOut>::MarchingCubes(const FloatIn level) +{ + this->edge_indices = 0; + this->reset(); + this->height = 0; + this->width = 0; + this->isolevel = level; + this->invert_normals = true; + this->sampling[0] = 1; + this->sampling[1] = 1; + this->sampling[2] = 1; +} + +template <typename FloatIn, typename FloatOut> +MarchingCubes<FloatIn, FloatOut>::~MarchingCubes() +{ +} + +template <typename FloatIn, typename FloatOut> +void +MarchingCubes<FloatIn, FloatOut>::reset() +{ + this->depth = 0; + this->vertices.clear(); + this->normals.clear(); + this->indices.clear(); + if (this->edge_indices != 0) { + delete this->edge_indices; + this->edge_indices = 0; + } +} + +template <typename FloatIn, typename FloatOut> +void +MarchingCubes<FloatIn, FloatOut>::finish_process() +{ + if (this->edge_indices != 0) { + delete this->edge_indices; + this->edge_indices = 0; + } +} + + +template <typename FloatIn, typename FloatOut> +void +MarchingCubes<FloatIn, FloatOut>::process(const FloatIn * data, + const unsigned int depth, + const unsigned int height, + const unsigned int width) +{ + assert(data != NULL); + unsigned int size = height * width * this->sampling[DEPTH_IDX]; + + /* number of slices minus - 1 to process */ + const unsigned int nb_slices = (depth - 1) / this->sampling[DEPTH_IDX]; + + this->reset(); + this->set_slice_size(height, width); + + for (unsigned int index=0; index < nb_slices; index++) { + const FloatIn * slice0 = data + (index * size); + const FloatIn * slice1 = slice0 + size; + + this->process_slice(slice0, slice1); + } + this->finish_process(); + + this->depth = depth; /* Forced as it might be < depth otherwise */ +} + + +template <typename FloatIn, typename FloatOut> +void +MarchingCubes<FloatIn, FloatOut>::set_slice_size(const unsigned int height, + const unsigned int width) +{ + this->reset(); + this->height = height; + this->width = width; +} + + +template <typename FloatIn, typename FloatOut> +void +MarchingCubes<FloatIn, FloatOut>::process_slice(const FloatIn * slice0, + const FloatIn * slice1) +{ + assert(slice0 != NULL); + assert(slice1 != NULL); + unsigned int row, col; + + if (this->edge_indices == 0) { + /* No previously processed slice, bootstrap */ + this->first_slice(slice0, slice1); + } + + /* Keep reference to cache from previous slice */ + std::map<unsigned int, unsigned int> * previous_edge_indices = + this->edge_indices; + + /* Init cache for this slice */ + this->edge_indices = new std::map<unsigned int, unsigned int>(); + + /* Loop over slice to add vertices */ + for (row=0; row < this->height; row += this->sampling[HEIGHT_IDX]) { + unsigned int line_index = row * this->width; + + for (col=0; col < this->width; col += this->sampling[WIDTH_IDX]) { + unsigned int item_index = line_index + col; + + FloatIn value0 = slice1[item_index]; + + /* Test forward edges and add vertices in the current slice plane */ + if (col < (width - this->sampling[WIDTH_IDX])) { + FloatIn value = slice1[item_index + this->sampling[WIDTH_IDX]]; + + this->process_edge(value0, value, this->depth, row, col, 0, + slice0, slice1, 0); + } + + if (row < (height - this->sampling[HEIGHT_IDX])) { + /* Value from next line*/ + FloatIn value = slice1[item_index + this->width * this->sampling[HEIGHT_IDX]]; + + this->process_edge(value0, value, this->depth, row, col, 1, + slice0, slice1, 0); + } + + /* Test backward edges and add vertices in z direction */ + { + FloatIn value = slice0[item_index]; + + /* Expect forward edge, so pass: previous, current */ + this->process_edge(value, value0, + this->depth - this->sampling[DEPTH_IDX], + row, col, 2, + 0, slice0, slice1); + } + + } + } + + /* Loop over cubes to add triangle indices */ + for (row=0; row < this->height - this->sampling[HEIGHT_IDX]; row += this->sampling[HEIGHT_IDX]) { + for (col=0; col < this->width - this->sampling[WIDTH_IDX]; col += this->sampling[WIDTH_IDX]) { + unsigned char code = this->get_cell_code(slice0, slice1, + row, col); + + if (code == 0) { + continue; + } + + const int * edgeIndexPtr = &MCTriangleTable[code][0]; + for (; *edgeIndexPtr >= 0; edgeIndexPtr++) { + const unsigned int * offsets = \ + MCEdgeIndexToCoordOffsets[*edgeIndexPtr]; + + unsigned int edge_index = this->edge_index( + this->depth - this->sampling[DEPTH_IDX] + offsets[DEPTH_IDX] * this->sampling[DEPTH_IDX], + row + offsets[HEIGHT_IDX] * this->sampling[HEIGHT_IDX], + col + offsets[WIDTH_IDX] * this->sampling[WIDTH_IDX], + offsets[3]); + + /* Add vertex index to the list of indices */ + std::map<unsigned int, unsigned int>::iterator it, end; + if (offsets[DEPTH_IDX] == 0 && offsets[3] != 2) { + it = previous_edge_indices->find(edge_index); + end = previous_edge_indices->end(); + } else { + it = this->edge_indices->find(edge_index); + end = this->edge_indices->end(); + } + if (it == end) { + throw std::runtime_error( + "Internal error: cannot build triangle indices."); + } + else { + this->indices.push_back(it->second); + } + } + + } + } + + /* Clean-up previous slice cache */ + delete previous_edge_indices; + + this->depth += this->sampling[DEPTH_IDX]; +} + + +template <typename FloatIn, typename FloatOut> +void +MarchingCubes<FloatIn, FloatOut>::first_slice(const FloatIn * slice, + const FloatIn * next) +{ + assert(slice != NULL); + assert(next != NULL); + /* Init cache for this slice */ + this->edge_indices = new std::map<unsigned int, unsigned int>(); + + unsigned int row, col; + + /* Loop over slice, and add isosurface vertices in the slice plane */ + for (row=0; row < this->height; row += this->sampling[HEIGHT_IDX]) { + unsigned int line_index = row * this->width; + + for (col=0; col < this->width; col += this->sampling[WIDTH_IDX]) { + unsigned int item_index = line_index + col; + + /* For each point test forward edges */ + FloatIn value0 = slice[item_index]; + + if (col < (width - this->sampling[WIDTH_IDX])) { + FloatIn value = slice[item_index + this->sampling[WIDTH_IDX]]; + + this->process_edge(value0, value, this->depth, row, col, 0, + 0, slice, next); + } + + if (row < (height - this->sampling[HEIGHT_IDX])) { + /* Value from next line */ + FloatIn value = slice[item_index + this->width * this->sampling[HEIGHT_IDX]]; + + this->process_edge(value0, value, this->depth, row, col, 1, + 0, slice, next); + } + } + } + + this->depth += this->sampling[DEPTH_IDX]; +} + + +template <typename FloatIn, typename FloatOut> +inline unsigned int +MarchingCubes<FloatIn, FloatOut>::edge_index(const unsigned int depth, + const unsigned int row, + const unsigned int col, + const unsigned int direction) +{ + return ((depth * (this->height + 1) + row) * + (this->width + 1) + col) * 3 + direction; +} + + +template <typename FloatIn, typename FloatOut> +inline void +MarchingCubes<FloatIn, FloatOut>::process_edge(const FloatIn value0, + const FloatIn value, + const unsigned int depth, + const unsigned int row, + const unsigned int col, + const unsigned int direction, + const FloatIn * previous, + const FloatIn * current, + const FloatIn * next) +{ + assert(current != NULL); + + if ((value0 <= this->isolevel) ^ (value <= this->isolevel)) { + + /* Crossing iso-surface, store it */ + FloatIn offset = (this->isolevel - value0) / (value - value0); + + /* Store edge to vertex index correspondance */ + unsigned int edge_index = this->edge_index(depth, row, col, direction); + (*this->edge_indices)[edge_index] = this->vertices.size() / 3; + + /* Store vertex as (z, y, x) */ + if (direction == 0) { + this->vertices.push_back((FloatOut) depth); + this->vertices.push_back((FloatOut) row); + this->vertices.push_back( + (FloatOut) col + offset * this->sampling[WIDTH_IDX]); + } + else if (direction == 1) { + this->vertices.push_back((FloatOut) depth); + this->vertices.push_back( + (FloatOut) row + offset * this->sampling[HEIGHT_IDX]); + this->vertices.push_back((FloatOut) col); + } + else if (direction == 2) { + this->vertices.push_back( + (FloatOut) depth + offset * this->sampling[DEPTH_IDX]); + this->vertices.push_back((FloatOut) row); + this->vertices.push_back((FloatOut) col); + } else { + throw std::runtime_error( + "Internal error: dimension > 3, never event."); + } + + /* Store normal as (nz, ny, nx) */ + FloatOut nz, ny, nx; + const FloatIn * slice0 = (previous != 0) ? previous : current; + const FloatIn * slice1 = (previous != 0) ? current : next; + + unsigned int row_offset = this->width * this->sampling[HEIGHT_IDX]; + + if (direction == 0) { + { /* nz */ + unsigned int item, item_next_col; + + item = row * this->width + col; + if (col >= this->width - this->sampling[WIDTH_IDX]) { + /* For last column, use previous column */ + item -= this->sampling[WIDTH_IDX]; + } + item_next_col = item + this->sampling[WIDTH_IDX]; + + nz = ((1. - offset) * (slice1[item] - slice0[item]) + + offset * (slice1[item_next_col] - slice0[item_next_col])); + } + + { /* ny */ + unsigned int item, item_next_col; + + item = row * this->width + col; + if (row >= this->height - this->sampling[HEIGHT_IDX]) { + /* For last row, use previous row */ + item -= row_offset; + } + if (col >= this->width - this->sampling[WIDTH_IDX]) { + /* For last column, use previous column */ + item -= this->sampling[WIDTH_IDX]; + } + item_next_col = item + this->sampling[WIDTH_IDX]; + + ny = ((1. - offset) * (current[item + row_offset] - + current[item]) + + offset * (current[item_next_col + row_offset] - + current[item_next_col])); + } + + nx = value - value0; + + } else if (direction == 1) { + { /* nz */ + unsigned int item, item_next_row; + + item = row * this->width + col; + if (row >= this->height - this->sampling[HEIGHT_IDX]) { + /* For last row, use previous row */ + item -= row_offset; + } + item_next_row = item + row_offset; + + nz = ((1. - offset) * (slice1[item] - slice0[item]) + + offset * (slice1[item_next_row] - slice0[item_next_row])); + } + + ny = value - value0; + + { /* nx */ + unsigned int item, item_next_row; + + item = row * this->width + col; + if (row >= this->height - this->sampling[HEIGHT_IDX]) { + /* For last row, use previous row */ + item -= row_offset; + } + if (col >= this->width - this->sampling[WIDTH_IDX]) { + /* For last column, use previous column */ + item -= this->sampling[WIDTH_IDX]; + } + + item_next_row = item + row_offset; + + nx = ((1. - offset) * (current[item + this->sampling[WIDTH_IDX]] - current[item]) + + offset * (current[item_next_row + this->sampling[WIDTH_IDX]] - current[item_next_row])); + } + + } else { /* direction == 2 */ + assert(direction == 2); + /* Previous should always be 0, only here in case this changes */ + const FloatIn * other_slice = (previous != 0) ? previous : next; + + nz = value - value0; + + { /* ny */ + unsigned int item, item_next_row; + + item = row * this->width + col; + if (row >= this->height - this->sampling[HEIGHT_IDX]) { + /* For last row, use previous row */ + item -= row_offset; + } + item_next_row = item + row_offset; + + ny = ((1. - offset) * (current[item_next_row] - current[item]) + + offset * (other_slice[item_next_row] - other_slice[item])); + } + + { /* nx */ + unsigned int item; + + item = row * this->width + col; + if (col >= this->width - this->sampling[WIDTH_IDX]) { + /* For last column, use previous column */ + item -= this->sampling[WIDTH_IDX]; + } + const unsigned int item_next_col = item + this->sampling[WIDTH_IDX]; + + nx = ((1. - offset) * (current[item_next_col] - current[item]) + + offset * (other_slice[item_next_col] - other_slice[item])); + } + } + + /* apply sampling scaling */ + nz /= (FloatOut) this->sampling[0]; + ny /= (FloatOut) this->sampling[1]; + nx /= (FloatOut) this->sampling[2]; + + /* normalisation */ + FloatOut norm = sqrt(nz * nz + ny * ny + nx * nx); + if (this->invert_normals) { /* Normal inversion */ + norm *= -1.; + } + + if (norm != 0) { + nz /= norm; + ny /= norm; + nx /= norm; + } + this->normals.push_back(nz); + this->normals.push_back(ny); + this->normals.push_back(nx); + } +} + + +template <typename FloatIn, typename FloatOut> +inline unsigned char +MarchingCubes<FloatIn, FloatOut>::get_cell_code(const FloatIn * slice1, + const FloatIn * slice2, + const unsigned int row, + const unsigned int col) +{ + assert(slice1 != NULL); + assert(slice2 != NULL); + unsigned int item = row * this->width + col; + unsigned int item_next_row = item + this->width * this->sampling[HEIGHT_IDX]; + unsigned char code = 0; + + /* Cube convention for cell code: + * WARNING: This differ from layout in memory + * + * 4 +------+ 5 + * /| /| + * / | / | + * 7 +------+ 6| + * | | | | + * |0 +---|--+ 1 + * | / | / + * |/ |/ + * 3 +------+ 2 + * + */ + /* First slice */ + if (slice1[item] <= this->isolevel) { + code |= 1 << 0; + } + if (slice1[item + this->sampling[WIDTH_IDX]] <= this->isolevel) { + code |= 1 << 1; + } + if (slice1[item_next_row + this->sampling[WIDTH_IDX]] <= this->isolevel) { + code |= 1 << 2; + } + if (slice1[item_next_row] <= this->isolevel) { + code |= 1 << 3; + } + + /* Second slice */ + if (slice2[item] <= this->isolevel) { + code |= 1 << 4; + } + if (slice2[item + this->sampling[WIDTH_IDX]] <= this->isolevel) { + code |= 1 << 5; + } + if (slice2[item_next_row + this->sampling[WIDTH_IDX]] <= this->isolevel) { + code |= 1 << 6; + } + if (slice2[item_next_row] <= this->isolevel) { + code |= 1 << 7; + } + + return code; +} + +#endif /*__mc_HPP__*/ diff --git a/src/silx/math/marchingcubes/mc_lut.cpp b/src/silx/math/marchingcubes/mc_lut.cpp new file mode 100644 index 0000000..7998f1b --- /dev/null +++ b/src/silx/math/marchingcubes/mc_lut.cpp @@ -0,0 +1,316 @@ +# /*########################################################################## +# +# Copyright (c) 2015-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +#include "mc.hpp" + + +/** Gives edge index of triangles vertices for each of the 256 possible cubes. + * + * Table taken from http://paulbourke.net/geometry/polygonise/ + * Author: Cory Bloyd + * Originially this code is public domain, + * relicensed here as MIT to provide a license. + * + * The cube index is a bit mask of cube corners <= isoValue. + * See vertexOffset for the place of each corner in the bit mask. + */ +const int MCTriangleTable[256][16] = { + {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 8, 3, 9, 8, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 1, 2, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 2, 10, 0, 2, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 8, 3, 2, 10, 8, 10, 9, 8, -1, -1, -1, -1, -1, -1, -1}, + {3, 11, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 11, 2, 8, 11, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 9, 0, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 11, 2, 1, 9, 11, 9, 8, 11, -1, -1, -1, -1, -1, -1, -1}, + {3, 10, 1, 11, 10, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 10, 1, 0, 8, 10, 8, 11, 10, -1, -1, -1, -1, -1, -1, -1}, + {3, 9, 0, 3, 11, 9, 11, 10, 9, -1, -1, -1, -1, -1, -1, -1}, + {9, 8, 10, 10, 8, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 7, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 3, 0, 7, 3, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 9, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 1, 9, 4, 7, 1, 7, 3, 1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 4, 7, 3, 0, 4, 1, 2, 10, -1, -1, -1, -1, -1, -1, -1}, + {9, 2, 10, 9, 0, 2, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1}, + {2, 10, 9, 2, 9, 7, 2, 7, 3, 7, 9, 4, -1, -1, -1, -1}, + {8, 4, 7, 3, 11, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 4, 7, 11, 2, 4, 2, 0, 4, -1, -1, -1, -1, -1, -1, -1}, + {9, 0, 1, 8, 4, 7, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1}, + {4, 7, 11, 9, 4, 11, 9, 11, 2, 9, 2, 1, -1, -1, -1, -1}, + {3, 10, 1, 3, 11, 10, 7, 8, 4, -1, -1, -1, -1, -1, -1, -1}, + {1, 11, 10, 1, 4, 11, 1, 0, 4, 7, 11, 4, -1, -1, -1, -1}, + {4, 7, 8, 9, 0, 11, 9, 11, 10, 11, 0, 3, -1, -1, -1, -1}, + {4, 7, 11, 4, 11, 9, 9, 11, 10, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 4, 0, 8, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 5, 4, 1, 5, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {8, 5, 4, 8, 3, 5, 3, 1, 5, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 9, 5, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 8, 1, 2, 10, 4, 9, 5, -1, -1, -1, -1, -1, -1, -1}, + {5, 2, 10, 5, 4, 2, 4, 0, 2, -1, -1, -1, -1, -1, -1, -1}, + {2, 10, 5, 3, 2, 5, 3, 5, 4, 3, 4, 8, -1, -1, -1, -1}, + {9, 5, 4, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 11, 2, 0, 8, 11, 4, 9, 5, -1, -1, -1, -1, -1, -1, -1}, + {0, 5, 4, 0, 1, 5, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1}, + {2, 1, 5, 2, 5, 8, 2, 8, 11, 4, 8, 5, -1, -1, -1, -1}, + {10, 3, 11, 10, 1, 3, 9, 5, 4, -1, -1, -1, -1, -1, -1, -1}, + {4, 9, 5, 0, 8, 1, 8, 10, 1, 8, 11, 10, -1, -1, -1, -1}, + {5, 4, 0, 5, 0, 11, 5, 11, 10, 11, 0, 3, -1, -1, -1, -1}, + {5, 4, 8, 5, 8, 10, 10, 8, 11, -1, -1, -1, -1, -1, -1, -1}, + {9, 7, 8, 5, 7, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 3, 0, 9, 5, 3, 5, 7, 3, -1, -1, -1, -1, -1, -1, -1}, + {0, 7, 8, 0, 1, 7, 1, 5, 7, -1, -1, -1, -1, -1, -1, -1}, + {1, 5, 3, 3, 5, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 7, 8, 9, 5, 7, 10, 1, 2, -1, -1, -1, -1, -1, -1, -1}, + {10, 1, 2, 9, 5, 0, 5, 3, 0, 5, 7, 3, -1, -1, -1, -1}, + {8, 0, 2, 8, 2, 5, 8, 5, 7, 10, 5, 2, -1, -1, -1, -1}, + {2, 10, 5, 2, 5, 3, 3, 5, 7, -1, -1, -1, -1, -1, -1, -1}, + {7, 9, 5, 7, 8, 9, 3, 11, 2, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 7, 9, 7, 2, 9, 2, 0, 2, 7, 11, -1, -1, -1, -1}, + {2, 3, 11, 0, 1, 8, 1, 7, 8, 1, 5, 7, -1, -1, -1, -1}, + {11, 2, 1, 11, 1, 7, 7, 1, 5, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 8, 8, 5, 7, 10, 1, 3, 10, 3, 11, -1, -1, -1, -1}, + {5, 7, 0, 5, 0, 9, 7, 11, 0, 1, 0, 10, 11, 10, 0, -1}, + {11, 10, 0, 11, 0, 3, 10, 5, 0, 8, 0, 7, 5, 7, 0, -1}, + {11, 10, 5, 7, 11, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {10, 6, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 0, 1, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 8, 3, 1, 9, 8, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1}, + {1, 6, 5, 2, 6, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 6, 5, 1, 2, 6, 3, 0, 8, -1, -1, -1, -1, -1, -1, -1}, + {9, 6, 5, 9, 0, 6, 0, 2, 6, -1, -1, -1, -1, -1, -1, -1}, + {5, 9, 8, 5, 8, 2, 5, 2, 6, 3, 2, 8, -1, -1, -1, -1}, + {2, 3, 11, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 0, 8, 11, 2, 0, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 9, 2, 3, 11, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1}, + {5, 10, 6, 1, 9, 2, 9, 11, 2, 9, 8, 11, -1, -1, -1, -1}, + {6, 3, 11, 6, 5, 3, 5, 1, 3, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 11, 0, 11, 5, 0, 5, 1, 5, 11, 6, -1, -1, -1, -1}, + {3, 11, 6, 0, 3, 6, 0, 6, 5, 0, 5, 9, -1, -1, -1, -1}, + {6, 5, 9, 6, 9, 11, 11, 9, 8, -1, -1, -1, -1, -1, -1, -1}, + {5, 10, 6, 4, 7, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 3, 0, 4, 7, 3, 6, 5, 10, -1, -1, -1, -1, -1, -1, -1}, + {1, 9, 0, 5, 10, 6, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1}, + {10, 6, 5, 1, 9, 7, 1, 7, 3, 7, 9, 4, -1, -1, -1, -1}, + {6, 1, 2, 6, 5, 1, 4, 7, 8, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 5, 5, 2, 6, 3, 0, 4, 3, 4, 7, -1, -1, -1, -1}, + {8, 4, 7, 9, 0, 5, 0, 6, 5, 0, 2, 6, -1, -1, -1, -1}, + {7, 3, 9, 7, 9, 4, 3, 2, 9, 5, 9, 6, 2, 6, 9, -1}, + {3, 11, 2, 7, 8, 4, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1}, + {5, 10, 6, 4, 7, 2, 4, 2, 0, 2, 7, 11, -1, -1, -1, -1}, + {0, 1, 9, 4, 7, 8, 2, 3, 11, 5, 10, 6, -1, -1, -1, -1}, + {9, 2, 1, 9, 11, 2, 9, 4, 11, 7, 11, 4, 5, 10, 6, -1}, + {8, 4, 7, 3, 11, 5, 3, 5, 1, 5, 11, 6, -1, -1, -1, -1}, + {5, 1, 11, 5, 11, 6, 1, 0, 11, 7, 11, 4, 0, 4, 11, -1}, + {0, 5, 9, 0, 6, 5, 0, 3, 6, 11, 6, 3, 8, 4, 7, -1}, + {6, 5, 9, 6, 9, 11, 4, 7, 9, 7, 11, 9, -1, -1, -1, -1}, + {10, 4, 9, 6, 4, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 10, 6, 4, 9, 10, 0, 8, 3, -1, -1, -1, -1, -1, -1, -1}, + {10, 0, 1, 10, 6, 0, 6, 4, 0, -1, -1, -1, -1, -1, -1, -1}, + {8, 3, 1, 8, 1, 6, 8, 6, 4, 6, 1, 10, -1, -1, -1, -1}, + {1, 4, 9, 1, 2, 4, 2, 6, 4, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 8, 1, 2, 9, 2, 4, 9, 2, 6, 4, -1, -1, -1, -1}, + {0, 2, 4, 4, 2, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {8, 3, 2, 8, 2, 4, 4, 2, 6, -1, -1, -1, -1, -1, -1, -1}, + {10, 4, 9, 10, 6, 4, 11, 2, 3, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 2, 2, 8, 11, 4, 9, 10, 4, 10, 6, -1, -1, -1, -1}, + {3, 11, 2, 0, 1, 6, 0, 6, 4, 6, 1, 10, -1, -1, -1, -1}, + {6, 4, 1, 6, 1, 10, 4, 8, 1, 2, 1, 11, 8, 11, 1, -1}, + {9, 6, 4, 9, 3, 6, 9, 1, 3, 11, 6, 3, -1, -1, -1, -1}, + {8, 11, 1, 8, 1, 0, 11, 6, 1, 9, 1, 4, 6, 4, 1, -1}, + {3, 11, 6, 3, 6, 0, 0, 6, 4, -1, -1, -1, -1, -1, -1, -1}, + {6, 4, 8, 11, 6, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {7, 10, 6, 7, 8, 10, 8, 9, 10, -1, -1, -1, -1, -1, -1, -1}, + {0, 7, 3, 0, 10, 7, 0, 9, 10, 6, 7, 10, -1, -1, -1, -1}, + {10, 6, 7, 1, 10, 7, 1, 7, 8, 1, 8, 0, -1, -1, -1, -1}, + {10, 6, 7, 10, 7, 1, 1, 7, 3, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 6, 1, 6, 8, 1, 8, 9, 8, 6, 7, -1, -1, -1, -1}, + {2, 6, 9, 2, 9, 1, 6, 7, 9, 0, 9, 3, 7, 3, 9, -1}, + {7, 8, 0, 7, 0, 6, 6, 0, 2, -1, -1, -1, -1, -1, -1, -1}, + {7, 3, 2, 6, 7, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 3, 11, 10, 6, 8, 10, 8, 9, 8, 6, 7, -1, -1, -1, -1}, + {2, 0, 7, 2, 7, 11, 0, 9, 7, 6, 7, 10, 9, 10, 7, -1}, + {1, 8, 0, 1, 7, 8, 1, 10, 7, 6, 7, 10, 2, 3, 11, -1}, + {11, 2, 1, 11, 1, 7, 10, 6, 1, 6, 7, 1, -1, -1, -1, -1}, + {8, 9, 6, 8, 6, 7, 9, 1, 6, 11, 6, 3, 1, 3, 6, -1}, + {0, 9, 1, 11, 6, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {7, 8, 0, 7, 0, 6, 3, 11, 0, 11, 6, 0, -1, -1, -1, -1}, + {7, 11, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {7, 6, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 8, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 9, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {8, 1, 9, 8, 3, 1, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1}, + {10, 1, 2, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 3, 0, 8, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1}, + {2, 9, 0, 2, 10, 9, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1}, + {6, 11, 7, 2, 10, 3, 10, 8, 3, 10, 9, 8, -1, -1, -1, -1}, + {7, 2, 3, 6, 2, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {7, 0, 8, 7, 6, 0, 6, 2, 0, -1, -1, -1, -1, -1, -1, -1}, + {2, 7, 6, 2, 3, 7, 0, 1, 9, -1, -1, -1, -1, -1, -1, -1}, + {1, 6, 2, 1, 8, 6, 1, 9, 8, 8, 7, 6, -1, -1, -1, -1}, + {10, 7, 6, 10, 1, 7, 1, 3, 7, -1, -1, -1, -1, -1, -1, -1}, + {10, 7, 6, 1, 7, 10, 1, 8, 7, 1, 0, 8, -1, -1, -1, -1}, + {0, 3, 7, 0, 7, 10, 0, 10, 9, 6, 10, 7, -1, -1, -1, -1}, + {7, 6, 10, 7, 10, 8, 8, 10, 9, -1, -1, -1, -1, -1, -1, -1}, + {6, 8, 4, 11, 8, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 6, 11, 3, 0, 6, 0, 4, 6, -1, -1, -1, -1, -1, -1, -1}, + {8, 6, 11, 8, 4, 6, 9, 0, 1, -1, -1, -1, -1, -1, -1, -1}, + {9, 4, 6, 9, 6, 3, 9, 3, 1, 11, 3, 6, -1, -1, -1, -1}, + {6, 8, 4, 6, 11, 8, 2, 10, 1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 3, 0, 11, 0, 6, 11, 0, 4, 6, -1, -1, -1, -1}, + {4, 11, 8, 4, 6, 11, 0, 2, 9, 2, 10, 9, -1, -1, -1, -1}, + {10, 9, 3, 10, 3, 2, 9, 4, 3, 11, 3, 6, 4, 6, 3, -1}, + {8, 2, 3, 8, 4, 2, 4, 6, 2, -1, -1, -1, -1, -1, -1, -1}, + {0, 4, 2, 4, 6, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 9, 0, 2, 3, 4, 2, 4, 6, 4, 3, 8, -1, -1, -1, -1}, + {1, 9, 4, 1, 4, 2, 2, 4, 6, -1, -1, -1, -1, -1, -1, -1}, + {8, 1, 3, 8, 6, 1, 8, 4, 6, 6, 10, 1, -1, -1, -1, -1}, + {10, 1, 0, 10, 0, 6, 6, 0, 4, -1, -1, -1, -1, -1, -1, -1}, + {4, 6, 3, 4, 3, 8, 6, 10, 3, 0, 3, 9, 10, 9, 3, -1}, + {10, 9, 4, 6, 10, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 9, 5, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 4, 9, 5, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1}, + {5, 0, 1, 5, 4, 0, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1}, + {11, 7, 6, 8, 3, 4, 3, 5, 4, 3, 1, 5, -1, -1, -1, -1}, + {9, 5, 4, 10, 1, 2, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1}, + {6, 11, 7, 1, 2, 10, 0, 8, 3, 4, 9, 5, -1, -1, -1, -1}, + {7, 6, 11, 5, 4, 10, 4, 2, 10, 4, 0, 2, -1, -1, -1, -1}, + {3, 4, 8, 3, 5, 4, 3, 2, 5, 10, 5, 2, 11, 7, 6, -1}, + {7, 2, 3, 7, 6, 2, 5, 4, 9, -1, -1, -1, -1, -1, -1, -1}, + {9, 5, 4, 0, 8, 6, 0, 6, 2, 6, 8, 7, -1, -1, -1, -1}, + {3, 6, 2, 3, 7, 6, 1, 5, 0, 5, 4, 0, -1, -1, -1, -1}, + {6, 2, 8, 6, 8, 7, 2, 1, 8, 4, 8, 5, 1, 5, 8, -1}, + {9, 5, 4, 10, 1, 6, 1, 7, 6, 1, 3, 7, -1, -1, -1, -1}, + {1, 6, 10, 1, 7, 6, 1, 0, 7, 8, 7, 0, 9, 5, 4, -1}, + {4, 0, 10, 4, 10, 5, 0, 3, 10, 6, 10, 7, 3, 7, 10, -1}, + {7, 6, 10, 7, 10, 8, 5, 4, 10, 4, 8, 10, -1, -1, -1, -1}, + {6, 9, 5, 6, 11, 9, 11, 8, 9, -1, -1, -1, -1, -1, -1, -1}, + {3, 6, 11, 0, 6, 3, 0, 5, 6, 0, 9, 5, -1, -1, -1, -1}, + {0, 11, 8, 0, 5, 11, 0, 1, 5, 5, 6, 11, -1, -1, -1, -1}, + {6, 11, 3, 6, 3, 5, 5, 3, 1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 10, 9, 5, 11, 9, 11, 8, 11, 5, 6, -1, -1, -1, -1}, + {0, 11, 3, 0, 6, 11, 0, 9, 6, 5, 6, 9, 1, 2, 10, -1}, + {11, 8, 5, 11, 5, 6, 8, 0, 5, 10, 5, 2, 0, 2, 5, -1}, + {6, 11, 3, 6, 3, 5, 2, 10, 3, 10, 5, 3, -1, -1, -1, -1}, + {5, 8, 9, 5, 2, 8, 5, 6, 2, 3, 8, 2, -1, -1, -1, -1}, + {9, 5, 6, 9, 6, 0, 0, 6, 2, -1, -1, -1, -1, -1, -1, -1}, + {1, 5, 8, 1, 8, 0, 5, 6, 8, 3, 8, 2, 6, 2, 8, -1}, + {1, 5, 6, 2, 1, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 3, 6, 1, 6, 10, 3, 8, 6, 5, 6, 9, 8, 9, 6, -1}, + {10, 1, 0, 10, 0, 6, 9, 5, 0, 5, 6, 0, -1, -1, -1, -1}, + {0, 3, 8, 5, 6, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {10, 5, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 5, 10, 7, 5, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 5, 10, 11, 7, 5, 8, 3, 0, -1, -1, -1, -1, -1, -1, -1}, + {5, 11, 7, 5, 10, 11, 1, 9, 0, -1, -1, -1, -1, -1, -1, -1}, + {10, 7, 5, 10, 11, 7, 9, 8, 1, 8, 3, 1, -1, -1, -1, -1}, + {11, 1, 2, 11, 7, 1, 7, 5, 1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 1, 2, 7, 1, 7, 5, 7, 2, 11, -1, -1, -1, -1}, + {9, 7, 5, 9, 2, 7, 9, 0, 2, 2, 11, 7, -1, -1, -1, -1}, + {7, 5, 2, 7, 2, 11, 5, 9, 2, 3, 2, 8, 9, 8, 2, -1}, + {2, 5, 10, 2, 3, 5, 3, 7, 5, -1, -1, -1, -1, -1, -1, -1}, + {8, 2, 0, 8, 5, 2, 8, 7, 5, 10, 2, 5, -1, -1, -1, -1}, + {9, 0, 1, 5, 10, 3, 5, 3, 7, 3, 10, 2, -1, -1, -1, -1}, + {9, 8, 2, 9, 2, 1, 8, 7, 2, 10, 2, 5, 7, 5, 2, -1}, + {1, 3, 5, 3, 7, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 7, 0, 7, 1, 1, 7, 5, -1, -1, -1, -1, -1, -1, -1}, + {9, 0, 3, 9, 3, 5, 5, 3, 7, -1, -1, -1, -1, -1, -1, -1}, + {9, 8, 7, 5, 9, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {5, 8, 4, 5, 10, 8, 10, 11, 8, -1, -1, -1, -1, -1, -1, -1}, + {5, 0, 4, 5, 11, 0, 5, 10, 11, 11, 3, 0, -1, -1, -1, -1}, + {0, 1, 9, 8, 4, 10, 8, 10, 11, 10, 4, 5, -1, -1, -1, -1}, + {10, 11, 4, 10, 4, 5, 11, 3, 4, 9, 4, 1, 3, 1, 4, -1}, + {2, 5, 1, 2, 8, 5, 2, 11, 8, 4, 5, 8, -1, -1, -1, -1}, + {0, 4, 11, 0, 11, 3, 4, 5, 11, 2, 11, 1, 5, 1, 11, -1}, + {0, 2, 5, 0, 5, 9, 2, 11, 5, 4, 5, 8, 11, 8, 5, -1}, + {9, 4, 5, 2, 11, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 5, 10, 3, 5, 2, 3, 4, 5, 3, 8, 4, -1, -1, -1, -1}, + {5, 10, 2, 5, 2, 4, 4, 2, 0, -1, -1, -1, -1, -1, -1, -1}, + {3, 10, 2, 3, 5, 10, 3, 8, 5, 4, 5, 8, 0, 1, 9, -1}, + {5, 10, 2, 5, 2, 4, 1, 9, 2, 9, 4, 2, -1, -1, -1, -1}, + {8, 4, 5, 8, 5, 3, 3, 5, 1, -1, -1, -1, -1, -1, -1, -1}, + {0, 4, 5, 1, 0, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {8, 4, 5, 8, 5, 3, 9, 0, 5, 0, 3, 5, -1, -1, -1, -1}, + {9, 4, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 11, 7, 4, 9, 11, 9, 10, 11, -1, -1, -1, -1, -1, -1, -1}, + {0, 8, 3, 4, 9, 7, 9, 11, 7, 9, 10, 11, -1, -1, -1, -1}, + {1, 10, 11, 1, 11, 4, 1, 4, 0, 7, 4, 11, -1, -1, -1, -1}, + {3, 1, 4, 3, 4, 8, 1, 10, 4, 7, 4, 11, 10, 11, 4, -1}, + {4, 11, 7, 9, 11, 4, 9, 2, 11, 9, 1, 2, -1, -1, -1, -1}, + {9, 7, 4, 9, 11, 7, 9, 1, 11, 2, 11, 1, 0, 8, 3, -1}, + {11, 7, 4, 11, 4, 2, 2, 4, 0, -1, -1, -1, -1, -1, -1, -1}, + {11, 7, 4, 11, 4, 2, 8, 3, 4, 3, 2, 4, -1, -1, -1, -1}, + {2, 9, 10, 2, 7, 9, 2, 3, 7, 7, 4, 9, -1, -1, -1, -1}, + {9, 10, 7, 9, 7, 4, 10, 2, 7, 8, 7, 0, 2, 0, 7, -1}, + {3, 7, 10, 3, 10, 2, 7, 4, 10, 1, 10, 0, 4, 0, 10, -1}, + {1, 10, 2, 8, 7, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 9, 1, 4, 1, 7, 7, 1, 3, -1, -1, -1, -1, -1, -1, -1}, + {4, 9, 1, 4, 1, 7, 0, 8, 1, 8, 7, 1, -1, -1, -1, -1}, + {4, 0, 3, 7, 4, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {4, 8, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {9, 10, 8, 10, 11, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 9, 3, 9, 11, 11, 9, 10, -1, -1, -1, -1, -1, -1, -1}, + {0, 1, 10, 0, 10, 8, 8, 10, 11, -1, -1, -1, -1, -1, -1, -1}, + {3, 1, 10, 11, 3, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 2, 11, 1, 11, 9, 9, 11, 8, -1, -1, -1, -1, -1, -1, -1}, + {3, 0, 9, 3, 9, 11, 1, 2, 9, 2, 11, 9, -1, -1, -1, -1}, + {0, 2, 11, 8, 0, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {3, 2, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 3, 8, 2, 8, 10, 10, 8, 9, -1, -1, -1, -1, -1, -1, -1}, + {9, 10, 2, 0, 9, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {2, 3, 8, 2, 8, 10, 0, 1, 8, 1, 10, 8, -1, -1, -1, -1}, + {1, 10, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {1, 3, 8, 9, 1, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 9, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {0, 3, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1} +}; + + +/** List edge origin and direction for each edge index in [0-12). + * + * For each edge, gives the first vertices as 3 coordinates from the origin + * of the cube and the direction of the edge as the 4th value. + */ +const unsigned int MCEdgeIndexToCoordOffsets[12][4] = { + {0, 0, 0, 0}, + {0, 0, 1, 1}, + {0, 1, 0, 0}, + {0, 0, 0, 1}, + {1, 0, 0, 0}, + {1, 0, 1, 1}, + {1, 1, 0, 0}, + {1, 0, 0, 1}, + {0, 0, 0, 2}, + {0, 0, 1, 2}, + {0, 1, 1, 2}, + {0, 1, 0, 2} +}; diff --git a/src/silx/math/math_compatibility.pxd b/src/silx/math/math_compatibility.pxd new file mode 100644 index 0000000..ddaa550 --- /dev/null +++ b/src/silx/math/math_compatibility.pxd @@ -0,0 +1,35 @@ +# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+
+# Provides Visual Studio 2008 missing math functions/macros
+
+cdef extern from "math_compatibility.h":
+ double asinh(double x) nogil
+ bint isnan(double x) nogil
+ bint isfinite(double x) nogil
+ long int lrint(double x) nogil
+
+ double INFINITY
+ double NAN
diff --git a/src/silx/math/mc.pxd b/src/silx/math/mc.pxd new file mode 100644 index 0000000..b1c81e7 --- /dev/null +++ b/src/silx/math/mc.pxd @@ -0,0 +1,51 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +from libcpp.vector cimport vector as std_vector +from libcpp cimport bool + +cdef extern from "mc.hpp": + cdef cppclass MarchingCubes[FloatIn, FloatOut]: + MarchingCubes(FloatIn level) except + + void process(FloatIn * data, + unsigned int depth, + unsigned int height, + unsigned int width) except + + void set_slice_size(unsigned int height, + unsigned int width) + void process_slice(FloatIn * slice0, + FloatIn * slice1) except + + void finish_process() + void reset() + + unsigned int depth + unsigned int height + unsigned int width + unsigned int sampling[3] + FloatIn isolevel + bool invert_normals + std_vector[FloatOut] vertices + std_vector[FloatOut] normals + std_vector[unsigned int] indices diff --git a/src/silx/math/medianfilter/__init__.py b/src/silx/math/medianfilter/__init__.py new file mode 100644 index 0000000..2b05f06 --- /dev/null +++ b/src/silx/math/medianfilter/__init__.py @@ -0,0 +1,30 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["H. Payno"] +__license__ = "MIT" +__date__ = "02/05/2017" + + +from .medianfilter import (medfilt, medfilt1d, medfilt2d) diff --git a/src/silx/math/medianfilter/include/median_filter.hpp b/src/silx/math/medianfilter/include/median_filter.hpp new file mode 100644 index 0000000..7e42980 --- /dev/null +++ b/src/silx/math/medianfilter/include/median_filter.hpp @@ -0,0 +1,284 @@ +/*########################################################################## +# +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +// __authors__ = ["H. Payno"] +// __license__ = "MIT" +// __date__ = "10/02/2017" + +#ifndef MEDIAN_FILTER +#define MEDIAN_FILTER + +#include <vector> +#include <assert.h> +#include <algorithm> +#include <signal.h> +#include <iostream> +#include <cmath> +#include <cfloat> + +/* Needed for pytohn2.7 on Windows... */ +#ifndef INFINITY +#define INFINITY (DBL_MAX+DBL_MAX) +#endif + +#ifndef NAN +#define NAN (INFINITY-INFINITY) +#endif + +// Modes for the median filter +enum MODE{ + NEAREST=0, + REFLECT=1, + MIRROR=2, + SHRINK=3, + CONSTANT=4, +}; + +// Simple function browsing a deque and registering the min and max values +// and if those values are unique or not +template<typename T> +void getMinMax(std::vector<T>& v, T& min, T&max, + typename std::vector<T>::const_iterator end){ + // init min and max values + typename std::vector<T>::const_iterator it = v.begin(); + if (v.size() == 0){ + raise(SIGINT); + }else{ + min = max = *it; + } + it++; + + // Browse all the deque + while(it!=end){ + // check if repeated (should always be before min/max setting) + T value = *it; + if(value > max) max = value; + if(value < min) min = value; + + it++; + } +} + + +// apply the median filter only on limited part of the vector +// In case of even number of elements (either due to NaNs in the window +// or for image borders in shrink mode): +// the highest of the 2 central values is returned +template<typename T> +inline T median(std::vector<T>& v, int window_size) { + int pivot = window_size / 2; + std::nth_element(v.begin(), v.begin() + pivot, v.begin()+window_size); + return v[pivot]; +} + + +// return the index into 0, (length_max - 1) in reflect mode +inline int reflect(int index, int length_max){ + int res = index; + // if the index is negative get the positive symmetrical value + if(res < 0){ + res += 1; + res = -res; + } + // then apply the reflect algorithm. Frequency is 2 max length + res = res % (2*length_max); + if(res >= length_max){ + res = 2*length_max - res -1; + res = res % length_max; + } + return res; +} + +// return the index into 0, (length_max - 1) in mirror mode +inline int mirror(int index, int length_max){ + int res = index; + // if the index is negative get the positive symmetrical value + if(res < 0){ + res = -res; + } + int rightLimit = length_max -1; + // apply the redundancy each two right limit + res = res % (2*rightLimit); + if(res >= length_max){ + int distToRedundancy = (2*rightLimit) - res; + res = distToRedundancy; + } + return res; +} + +/* Provide a way to access NaN that also works for integers*/ + +template<typename T> +inline T NotANumber(void) { + assert(false); //This should never be called + return 0; +} + +template<> +inline float NotANumber<float>(void) { return NAN; } + +template<> +inline double NotANumber<double>(void) { return NAN; } + + +// Browse the column of pixel_x +template<typename T> +void median_filter( + const T* input, + T* output, + int* kernel_dim, // two values : 0:width, 1:height + int* image_dim, // two values : 0:width, 1:height + int y_pixel, // the x pixel to process + int x_pixel_range_min, + int x_pixel_range_max, + bool conditional, + int pMode, + T cval) { + + assert(kernel_dim[0] > 0); + assert(kernel_dim[1] > 0); + assert(y_pixel >= 0); + assert(image_dim[0] > 0); + assert(image_dim[1] > 0); + assert(y_pixel >= 0); + assert(y_pixel < image_dim[0]); + assert(x_pixel_range_max < image_dim[1]); + assert(x_pixel_range_min <= x_pixel_range_max); + // kernel odd assertion + assert((kernel_dim[0] - 1)%2 == 0); + assert((kernel_dim[1] - 1)%2 == 0); + + // # this should be move up to avoid calculation each time + int halfKernel_x = (kernel_dim[1] - 1) / 2; + int halfKernel_y = (kernel_dim[0] - 1) / 2; + + MODE mode = static_cast<MODE>(pMode); + + // init buffer + std::vector<T> window_values(kernel_dim[0]*kernel_dim[1]); + + bool not_horizontal_border = (y_pixel >= halfKernel_y && y_pixel < image_dim[0] - halfKernel_y); + + for(int x_pixel=x_pixel_range_min; x_pixel <= x_pixel_range_max; x_pixel ++ ){ + typename std::vector<T>::iterator it = window_values.begin(); + // fill the vector + + if (not_horizontal_border && + x_pixel >= halfKernel_x && x_pixel < image_dim[1] - halfKernel_x) { + //This is not a border, just fill it + for(int win_y=y_pixel-halfKernel_y; win_y<= y_pixel+halfKernel_y; win_y++) { + for(int win_x = x_pixel-halfKernel_x; win_x <= x_pixel+halfKernel_x; win_x++){ + T value = input[win_y*image_dim[1] + win_x]; + if (value == value) { // Ignore NaNs + *it = value; + ++it; + } + } + } + + } else { // This is a border, handle the special case + for(int win_y=y_pixel-halfKernel_y; win_y<= y_pixel+halfKernel_y; win_y++) + { + for(int win_x = x_pixel-halfKernel_x; win_x <= x_pixel+halfKernel_x; win_x++) + { + T value = 0; + int index_x = win_x; + int index_y = win_y; + + switch(mode){ + case NEAREST: + index_x = std::min(std::max(win_x, 0), image_dim[1] - 1); + index_y = std::min(std::max(win_y, 0), image_dim[0] - 1); + value = input[index_y*image_dim[1] + index_x]; + break; + + case REFLECT: + index_x = reflect(win_x, image_dim[1]); + index_y = reflect(win_y, image_dim[0]); + value = input[index_y*image_dim[1] + index_x]; + break; + + case MIRROR: + index_x = mirror(win_x, image_dim[1]); + // deal with 1d case + if(win_y == 0 && image_dim[0] == 1){ + index_y = 0; + }else{ + index_y = mirror(win_y, image_dim[0]); + } + value = input[index_y*image_dim[1] + index_x]; + break; + + case SHRINK: + if ((index_x < 0) || (index_x > image_dim[1] -1) || + (index_y < 0) || (index_y > image_dim[0] -1)) { + continue; + } + value = input[index_y*image_dim[1] + index_x]; + break; + case CONSTANT: + if ((index_x < 0) || (index_x > image_dim[1] -1) || + (index_y < 0) || (index_y > image_dim[0] -1)) { + value = cval; + } else { + value = input[index_y*image_dim[1] + index_x]; + } + break; + } + + if (value == value) { // Ignore NaNs + *it = value; + ++it; + } + } + } + } + + //window_size can be smaller than kernel size in shrink mode or if there is NaNs + int window_size = std::distance(window_values.begin(), it); + + if (window_size == 0) { + // Window is empty, this is the case when all values are NaNs + output[image_dim[1]*y_pixel + x_pixel] = NotANumber<T>(); + } else { + // apply the median value if needed for this pixel + const T currentPixelValue = input[image_dim[1]*y_pixel + x_pixel]; + if (conditional == true){ + typename std::vector<T>::iterator window_end = window_values.begin() + window_size; + T min = 0; + T max = 0; + getMinMax(window_values, min, max, window_end); + // NaNs are propagated through unchanged + if ((currentPixelValue == max) || (currentPixelValue == min)){ + output[image_dim[1]*y_pixel + x_pixel] = median<T>(window_values, window_size); + }else{ + output[image_dim[1]*y_pixel + x_pixel] = currentPixelValue; + } + }else{ + output[image_dim[1]*y_pixel + x_pixel] = median<T>(window_values, window_size); + } + } + } +} + +#endif // MEDIAN_FILTER diff --git a/src/silx/math/medianfilter/median_filter.pxd b/src/silx/math/medianfilter/median_filter.pxd new file mode 100644 index 0000000..2fc0283 --- /dev/null +++ b/src/silx/math/medianfilter/median_filter.pxd @@ -0,0 +1,42 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +from libcpp cimport bool + +# pyx +cdef extern from "median_filter.hpp": + cdef extern void median_filter[T](const T* image, + T* output, + int* kernel_dim, + int* image_dim, + int x_pixel_range_min, + int x_pixel_range_max, + int y_pixel_range_min, + int y_pixel_range_max, + bool conditional, + T cval) nogil; + + cdef extern int reflect(int index, int length_max); + cdef extern int mirror(int index, int length_max); diff --git a/src/silx/math/medianfilter/medianfilter.pyx b/src/silx/math/medianfilter/medianfilter.pyx new file mode 100644 index 0000000..fe05a78 --- /dev/null +++ b/src/silx/math/medianfilter/medianfilter.pyx @@ -0,0 +1,496 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides median filter function for 1D and 2D arrays. +""" + +__authors__ = ["H. Payno", "J. Kieffer"] +__license__ = "MIT" +__date__ = "02/05/2017" + + +from cython.parallel import prange +cimport cython +cimport silx.math.medianfilter.median_filter as median_filter +import numpy +cimport numpy as cnumpy +from libcpp cimport bool + +import numbers + +ctypedef unsigned long uint64 +ctypedef unsigned int uint32 +ctypedef unsigned short uint16 + + +MODES = {'nearest': 0, 'reflect': 1, 'mirror': 2, 'shrink': 3, 'constant': 4} + + +def medfilt1d(data, + kernel_size=3, + bool conditional=False, + mode='nearest', + cval=0): + """Function computing the median filter of the given input. + + Behavior at boundaries: the algorithm is reducing the size of the + window/kernel for pixels at boundaries (there is no mirroring). + + Not-a-Number (NaN) float values are ignored. + If the window only contains NaNs, it evaluates to NaN. + + In event of an even number of valid values in the window (either + because of NaN values or on image border in shrink mode), + the highest of the 2 central sorted values is taken. + + :param numpy.ndarray data: the array for which we want to apply + the median filter. Should be 1d. + :param kernel_size: the dimension of the kernel. + :type kernel_size: int + :param bool conditional: True if we want to apply a conditional median + filtering. + :param str mode: the algorithm used to determine how values at borders + are determined: 'nearest', 'reflect', 'mirror', 'shrink', 'constant' + :param cval: Value used outside borders in 'constant' mode + + :returns: the array with the median value for each pixel. + """ + return medfilt(data, kernel_size, conditional, mode, cval) + + +def medfilt2d(image, + kernel_size=3, + bool conditional=False, + mode='nearest', + cval=0): + """Function computing the median filter of the given input. + Behavior at boundaries: the algorithm is reducing the size of the + window/kernel for pixels at boundaries (there is no mirroring). + + Not-a-Number (NaN) float values are ignored. + If the window only contains NaNs, it evaluates to NaN. + + In event of an even number of valid values in the window (either + because of NaN values or on image border in shrink mode), + the highest of the 2 central sorted values is taken. + + :param numpy.ndarray data: the array for which we want to apply + the median filter. Should be 2d. + :param kernel_size: the dimension of the kernel. + :type kernel_size: For 1D should be an int for 2D should be a tuple or + a list of (kernel_height, kernel_width) + :param bool conditional: True if we want to apply a conditional median + filtering. + :param str mode: the algorithm used to determine how values at borders + are determined: 'nearest', 'reflect', 'mirror', 'shrink', 'constant' + :param cval: Value used outside borders in 'constant' mode + + :returns: the array with the median value for each pixel. + """ + return medfilt(image, kernel_size, conditional, mode, cval) + + +def medfilt(data, + kernel_size=3, + bool conditional=False, + mode='nearest', + cval=0): + """Function computing the median filter of the given input. + Behavior at boundaries: the algorithm is reducing the size of the + window/kernel for pixels at boundaries (there is no mirroring). + + Not-a-Number (NaN) float values are ignored. + If the window only contains NaNs, it evaluates to NaN. + + In event of an even number of valid values in the window (either + because of NaN values or on image border in shrink mode), + the highest of the 2 central sorted values is taken. + + :param numpy.ndarray data: the array for which we want to apply + the median filter. Should be 1d or 2d. + :param kernel_size: the dimension of the kernel. + :type kernel_size: For 1D should be an int for 2D should be a tuple or + a list of (kernel_height, kernel_width) + :param bool conditional: True if we want to apply a conditional median + filtering. + :param str mode: the algorithm used to determine how values at borders + are determined: 'nearest', 'reflect', 'mirror', 'shrink', 'constant' + :param cval: Value used outside borders in 'constant' mode + + :returns: the array with the median value for each pixel. + """ + if mode not in MODES: + err = 'Requested mode %s is unknown.' % mode + raise ValueError(err) + + if data.ndim > 2: + raise ValueError( + "Invalid data shape. Dimension of the array should be 1 or 2") + + # Handle case of scalar kernel size + if isinstance(kernel_size, numbers.Integral): + kernel_size = [kernel_size] * data.ndim + + assert len(kernel_size) == data.ndim + + # Convert 1D arrays to 2D + reshaped = False + if len(data.shape) == 1: + data = data.reshape(1, data.shape[0]) + kernel_size = [1, kernel_size[0]] + reshaped = True + + # simple median filter apply into a 2D buffer + output_buffer = numpy.zeros_like(data) + check(data, output_buffer) + + ker_dim = numpy.array(kernel_size, dtype=numpy.int32) + + if data.dtype == numpy.float64: + medfilterfc = _median_filter_float64 + elif data.dtype == numpy.float32: + medfilterfc = _median_filter_float32 + elif data.dtype == numpy.int64: + medfilterfc = _median_filter_int64 + elif data.dtype == numpy.uint64: + medfilterfc = _median_filter_uint64 + elif data.dtype == numpy.int32: + medfilterfc = _median_filter_int32 + elif data.dtype == numpy.uint32: + medfilterfc = _median_filter_uint32 + elif data.dtype == numpy.int16: + medfilterfc = _median_filter_int16 + elif data.dtype == numpy.uint16: + medfilterfc = _median_filter_uint16 + else: + raise ValueError("%s type is not managed by the median filter" % data.dtype) + + medfilterfc(input_buffer=data, + output_buffer=output_buffer, + kernel_size=ker_dim, + conditional=conditional, + mode=MODES[mode], + cval=cval) + + if reshaped: + output_buffer.shape = -1 # Convert to 1D array + + return output_buffer + + +def check(input_buffer, output_buffer): + """Simple check on the two buffers to make sure we can apply the median filter + """ + if (input_buffer.flags['C_CONTIGUOUS'] is False): + raise ValueError('<input_buffer> must be a C_CONTIGUOUS numpy array.') + + if (output_buffer.flags['C_CONTIGUOUS'] is False): + raise ValueError('<output_buffer> must be a C_CONTIGUOUS numpy array.') + + if not (len(input_buffer.shape) <= 2): + raise ValueError('<input_buffer> dimension must mo higher than 2.') + + if not (len(output_buffer.shape) <= 2): + raise ValueError('<output_buffer> dimension must mo higher than 2.') + + if not(input_buffer.dtype == output_buffer.dtype): + raise ValueError('input buffer and output_buffer must be of the same type') + + if not (input_buffer.shape == output_buffer.shape): + raise ValueError('input buffer and output_buffer must be of the same dimension and same dimension') + + +######### implementations of the include/median_filter.hpp function ############ +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def reflect(int index, int length_max): + """find the correct index into [0, length_max-1] for index in reflect mode + + :param int index: the index to move into [0, length_max-1] in reflect mode + :param int length_max: the higher bound limit + """ + return median_filter.reflect(index, length_max) + + +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def mirror(int index, int length_max): + """find the correct index into [0, length_max-1] for index in mirror mode + + :param int index: the index to move into [0, length_max-1] in mirror mode + :param int length_max: the higher bound limit + """ + return median_filter.mirror(index, length_max) + + +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def _median_filter_float32(float[:, ::1] input_buffer not None, + float[:, ::1] output_buffer not None, + cnumpy.int32_t[::1] kernel_size not None, + bool conditional, + int mode, + float cval): + + cdef: + int y = 0 + int image_dim = input_buffer.shape[1] - 1 + int[2] buffer_shape + buffer_shape[0] = input_buffer.shape[0] + buffer_shape[1] = input_buffer.shape[1] + + for y in prange(input_buffer.shape[0], nogil=True): + median_filter.median_filter[float](<float*> & input_buffer[0,0], + <float*> & output_buffer[0,0], + <int*>& kernel_size[0], + <int*>buffer_shape, + y, + 0, + image_dim, + conditional, + mode, + cval) + + +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def _median_filter_float64(double[:, ::1] input_buffer not None, + double[:, ::1] output_buffer not None, + cnumpy.int32_t[::1] kernel_size not None, + bool conditional, + int mode, + double cval): + + cdef: + int y = 0 + int image_dim = input_buffer.shape[1] - 1 + int[2] buffer_shape + buffer_shape[0] = input_buffer.shape[0] + buffer_shape[1] = input_buffer.shape[1] + + for y in prange(input_buffer.shape[0], nogil=True): + median_filter.median_filter[double](<double*> & input_buffer[0, 0], + <double*> & output_buffer[0, 0], + <int*>&kernel_size[0], + <int*>buffer_shape, + y, + 0, + image_dim, + conditional, + mode, + cval) + + +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def _median_filter_int64(cnumpy.int64_t[:, ::1] input_buffer not None, + cnumpy.int64_t[:, ::1] output_buffer not None, + cnumpy.int32_t[::1] kernel_size not None, + bool conditional, + int mode, + cnumpy.int64_t cval): + + cdef: + int y = 0 + int image_dim = input_buffer.shape[1] - 1 + int[2] buffer_shape + buffer_shape[0] = input_buffer.shape[0] + buffer_shape[1] = input_buffer.shape[1] + + for y in prange(input_buffer.shape[0], nogil=True): + median_filter.median_filter[long](<long*> & input_buffer[0,0], + <long*> & output_buffer[0, 0], + <int*>&kernel_size[0], + <int*>buffer_shape, + y, + 0, + image_dim, + conditional, + mode, + cval) + +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def _median_filter_uint64(cnumpy.uint64_t[:, ::1] input_buffer not None, + cnumpy.uint64_t[:, ::1] output_buffer not None, + cnumpy.int32_t[::1] kernel_size not None, + bool conditional, + int mode, + cnumpy.uint64_t cval): + + cdef: + int y = 0 + int image_dim = input_buffer.shape[1] - 1 + int[2] buffer_shape + buffer_shape[0] = input_buffer.shape[0] + buffer_shape[1] = input_buffer.shape[1] + + for y in prange(input_buffer.shape[0], nogil=True): + median_filter.median_filter[uint64](<uint64*> & input_buffer[0,0], + <uint64*> & output_buffer[0, 0], + <int*>&kernel_size[0], + <int*>buffer_shape, + y, + 0, + image_dim, + conditional, + mode, + cval) + + +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def _median_filter_int32(cnumpy.int32_t[:, ::1] input_buffer not None, + cnumpy.int32_t[:, ::1] output_buffer not None, + cnumpy.int32_t[::1] kernel_size not None, + bool conditional, + int mode, + cnumpy.int32_t cval): + + cdef: + int y = 0 + int image_dim = input_buffer.shape[1] - 1 + int[2] buffer_shape + buffer_shape[0] = input_buffer.shape[0] + buffer_shape[1] = input_buffer.shape[1] + + for y in prange(input_buffer.shape[0], nogil=True): + median_filter.median_filter[int](<int*> & input_buffer[0,0], + <int*> & output_buffer[0, 0], + <int*>&kernel_size[0], + <int*>buffer_shape, + y, + 0, + image_dim, + conditional, + mode, + cval) + + +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def _median_filter_uint32(cnumpy.uint32_t[:, ::1] input_buffer not None, + cnumpy.uint32_t[:, ::1] output_buffer not None, + cnumpy.int32_t[::1] kernel_size not None, + bool conditional, + int mode, + cnumpy.uint32_t cval): + + cdef: + int y = 0 + int image_dim = input_buffer.shape[1] - 1 + int[2] buffer_shape + buffer_shape[0] = input_buffer.shape[0] + buffer_shape[1] = input_buffer.shape[1] + + for y in prange(input_buffer.shape[0], nogil=True): + median_filter.median_filter[uint32](<uint32*> & input_buffer[0,0], + <uint32*> & output_buffer[0, 0], + <int*>&kernel_size[0], + <int*>buffer_shape, + y, + 0, + image_dim, + conditional, + mode, + cval) + + +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def _median_filter_int16(cnumpy.int16_t[:, ::1] input_buffer not None, + cnumpy.int16_t[:, ::1] output_buffer not None, + cnumpy.int32_t[::1] kernel_size not None, + bool conditional, + int mode, + cnumpy.int16_t cval): + + cdef: + int y = 0 + int image_dim = input_buffer.shape[1] - 1 + int[2] buffer_shape + buffer_shape[0] = input_buffer.shape[0] + buffer_shape[1] = input_buffer.shape[1] + + for y in prange(input_buffer.shape[0], nogil=True): + median_filter.median_filter[short](<short*> & input_buffer[0,0], + <short*> & output_buffer[0, 0], + <int*>&kernel_size[0], + <int*>buffer_shape, + y, + 0, + image_dim, + conditional, + mode, + cval) + + +@cython.cdivision(True) +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.initializedcheck(False) +def _median_filter_uint16( + cnumpy.uint16_t[:, ::1] input_buffer not None, + cnumpy.uint16_t[:, ::1] output_buffer not None, + cnumpy.int32_t[::1] kernel_size not None, + bool conditional, + int mode, + cnumpy.uint16_t cval): + + cdef: + int y = 0 + int image_dim = input_buffer.shape[1] - 1 + int[2] buffer_shape, + buffer_shape[0] = input_buffer.shape[0] + buffer_shape[1] = input_buffer.shape[1] + + for y in prange(input_buffer.shape[0], nogil=True): + median_filter.median_filter[uint16](<uint16*> & input_buffer[0, 0], + <uint16*> & output_buffer[0, 0], + <int*>&kernel_size[0], + <int*>buffer_shape, + y, + 0, + image_dim, + conditional, + mode, + cval) diff --git a/src/silx/math/medianfilter/setup.py b/src/silx/math/medianfilter/setup.py new file mode 100644 index 0000000..d228357 --- /dev/null +++ b/src/silx/math/medianfilter/setup.py @@ -0,0 +1,59 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["D. Naudet"] +__license__ = "MIT" +__date__ = "02/05/2017" + + +import numpy + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('medianfilter', parent_package, top_path) + config.add_subpackage('test') + + # ===================================== + # median filter + # ===================================== + medfilt_src = ['medianfilter.pyx'] + medfilt_inc = ['include', numpy.get_include()] + extra_link_args = ['-fopenmp'] + extra_compile_args = ['-fopenmp'] + config.add_extension('medianfilter', + sources=medfilt_src, + include_dirs=[medfilt_inc], + language='c++', + extra_link_args=extra_link_args, + extra_compile_args=extra_compile_args) + + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + + setup(configuration=configuration)
\ No newline at end of file diff --git a/src/silx/math/medianfilter/test/__init__.py b/src/silx/math/medianfilter/test/__init__.py new file mode 100644 index 0000000..71f8e95 --- /dev/null +++ b/src/silx/math/medianfilter/test/__init__.py @@ -0,0 +1,23 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ diff --git a/src/silx/math/medianfilter/test/benchmark.py b/src/silx/math/medianfilter/test/benchmark.py new file mode 100644 index 0000000..81e893e --- /dev/null +++ b/src/silx/math/medianfilter/test/benchmark.py @@ -0,0 +1,122 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Tests of the median filter""" + +__authors__ = ["H. Payno"] +__license__ = "MIT" +__date__ = "02/05/2017" + +from silx.gui import qt +from silx.math.medianfilter import medfilt2d as medfilt2d_silx +import numpy +import numpy.random +from timeit import Timer +from silx.gui.plot import Plot1D +import logging + +try: + import scipy +except: + scipy = None +else: + import scipy.ndimage + +try: + import PyMca5.PyMca as pymca +except: + pymca = None +else: + from PyMca5.PyMca.median import medfilt2d as medfilt2d_pymca + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class BenchmarkMedianFilter(object): + """Simple benchmark of the median fiter silx vs scipy""" + + NB_ITER = 3 + + def __init__(self, imageWidth, kernels): + self.img = numpy.random.rand(imageWidth, imageWidth) + self.kernels = kernels + + self.run() + + def run(self): + self.execTime = {} + for kernel in self.kernels: + self.execTime[kernel] = self.bench(kernel) + + def bench(self, width): + def execSilx(): + medfilt2d_silx(self.img, width) + + def execScipy(): + scipy.ndimage.median_filter(input=self.img, + size=width, + mode='nearest') + + def execPymca(): + medfilt2d_pymca(self.img, width) + + execTime = {} + + t = Timer(execSilx) + execTime["silx"] = t.timeit(BenchmarkMedianFilter.NB_ITER) + logger.info( + 'exec time silx (kernel size = %s) is %s' % (width, execTime["silx"])) + + if scipy is not None: + t = Timer(execScipy) + execTime["scipy"] = t.timeit(BenchmarkMedianFilter.NB_ITER) + logger.info( + 'exec time scipy (kernel size = %s) is %s' % (width, execTime["scipy"])) + if pymca is not None: + t = Timer(execPymca) + execTime["pymca"] = t.timeit(BenchmarkMedianFilter.NB_ITER) + logger.info( + 'exec time pymca (kernel size = %s) is %s' % (width, execTime["pymca"])) + + return execTime + + def getExecTimeFor(self, id): + res = [] + for k in self.kernels: + res.append(self.execTime[k][id]) + return res + + +app = qt.QApplication([]) +kernels = [3, 5, 7, 11, 15] +benchmark = BenchmarkMedianFilter(imageWidth=1000, kernels=kernels) +plot = Plot1D() +plot.addCurve(x=kernels, y=benchmark.getExecTimeFor("silx"), legend='silx') +if scipy is not None: + plot.addCurve(x=kernels, y=benchmark.getExecTimeFor("scipy"), legend='scipy') +if pymca is not None: + plot.addCurve(x=kernels, y=benchmark.getExecTimeFor("pymca"), legend='pymca') +plot.show() +app.exec() +del app diff --git a/src/silx/math/medianfilter/test/test_medianfilter.py b/src/silx/math/medianfilter/test/test_medianfilter.py new file mode 100644 index 0000000..a4e3021 --- /dev/null +++ b/src/silx/math/medianfilter/test/test_medianfilter.py @@ -0,0 +1,722 @@ +# coding: utf-8 +# ########################################################################## +# Copyright (C) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################ +"""Tests of the median filter""" + +__authors__ = ["H. Payno"] +__license__ = "MIT" +__date__ = "17/01/2018" + +import unittest +import numpy +from silx.math.medianfilter import medfilt2d, medfilt1d +from silx.math.medianfilter.medianfilter import reflect, mirror +from silx.math.medianfilter.medianfilter import MODES as silx_mf_modes +from silx.utils.testutils import ParametricTestCase +try: + import scipy + import scipy.misc +except: + scipy = None +else: + import scipy.ndimage + +import logging +_logger = logging.getLogger(__name__) + +RANDOM_FLOAT_MAT = numpy.array([ + [0.05564293, 0.62717157, 0.75002406, 0.40555336, 0.70278975], + [0.76532598, 0.02839148, 0.05272484, 0.65166994, 0.42161216], + [0.23067427, 0.74219128, 0.56049024, 0.44406320, 0.28773158], + [0.81025249, 0.20303021, 0.68382382, 0.46372299, 0.81281709], + [0.94691602, 0.07813661, 0.81651256, 0.84220106, 0.33623165]]) + +RANDOM_INT_MAT = numpy.array([ + [0, 5, 2, 6, 1], + [2, 3, 1, 7, 1], + [9, 8, 6, 7, 8], + [5, 6, 8, 2, 4]]) + + +class TestMedianFilterNearest(ParametricTestCase): + """Unit tests for the median filter in nearest mode""" + + def testFilter3_100(self): + """Test median filter on a 10x10 matrix with a 3x3 kernel.""" + dataIn = numpy.arange(100, dtype=numpy.int32) + dataIn = dataIn.reshape((10, 10)) + + dataOut = medfilt2d(image=dataIn, + kernel_size=(3, 3), + conditional=False, + mode='nearest') + self.assertTrue(dataOut[0, 0] == 1) + self.assertTrue(dataOut[9, 0] == 90) + self.assertTrue(dataOut[9, 9] == 98) + + self.assertTrue(dataOut[0, 9] == 9) + self.assertTrue(dataOut[0, 4] == 5) + self.assertTrue(dataOut[9, 4] == 93) + self.assertTrue(dataOut[4, 4] == 44) + + def testFilter3_9(self): + "Test median filter on a 3x3 matrix with a 3x3 kernel." + dataIn = numpy.array([0, -1, 1, + 12, 6, -2, + 100, 4, 12], + dtype=numpy.int16) + dataIn = dataIn.reshape((3, 3)) + dataOut = medfilt2d(image=dataIn, + kernel_size=(3, 3), + conditional=False, + mode='nearest') + self.assertTrue(dataOut.shape == dataIn.shape) + self.assertTrue(dataOut[1, 1] == 4) + self.assertTrue(dataOut[0, 0] == 0) + self.assertTrue(dataOut[0, 1] == 0) + self.assertTrue(dataOut[1, 0] == 6) + + def testFilterWidthOne(self): + """Make sure a filter of one by one give the same result as the input + """ + dataIn = numpy.arange(100, dtype=numpy.int32) + dataIn = dataIn.reshape((10, 10)) + + dataOut = medfilt2d(image=dataIn, + kernel_size=(1, 1), + conditional=False, + mode='nearest') + + self.assertTrue(numpy.array_equal(dataIn, dataOut)) + + def testFilter3_1d(self): + """Test binding and result of the 1d filter""" + self.assertTrue(numpy.array_equal( + medfilt1d(RANDOM_INT_MAT[0], kernel_size=3, conditional=False, + mode='nearest'), + [0, 2, 5, 2, 1]) + ) + + def testFilter3Conditionnal(self): + """Test that the conditional filter apply correctly in a 10x10 matrix + with a 3x3 kernel + """ + dataIn = numpy.arange(100, dtype=numpy.int32) + dataIn = dataIn.reshape((10, 10)) + + dataOut = medfilt2d(image=dataIn, + kernel_size=(3, 3), + conditional=True, + mode='nearest') + self.assertTrue(dataOut[0, 0] == 1) + self.assertTrue(dataOut[0, 1] == 1) + self.assertTrue(numpy.array_equal(dataOut[1:8, 1:8], dataIn[1:8, 1:8])) + self.assertTrue(dataOut[9, 9] == 98) + + def testFilter3_1D(self): + """Simple test of a 3x3 median filter on a 1D array""" + dataIn = numpy.arange(100, dtype=numpy.int32) + + dataOut = medfilt2d(image=dataIn, + kernel_size=(5), + conditional=False, + mode='nearest') + + self.assertTrue(dataOut[0] == 0) + self.assertTrue(dataOut[9] == 9) + self.assertTrue(dataOut[99] == 99) + + def testNaNs(self): + """Test median filter on image with NaNs in nearest mode""" + # Data with a NaN in first corner + nan_corner = numpy.arange(100.).reshape(10, 10) + nan_corner[0, 0] = numpy.nan + output = medfilt2d( + nan_corner, kernel_size=3, conditional=False, mode='nearest') + self.assertEqual(output[0, 0], 10) + self.assertEqual(output[0, 1], 2) + self.assertEqual(output[1, 0], 11) + self.assertEqual(output[1, 1], 12) + + # Data with some NaNs + some_nans = numpy.arange(100.).reshape(10, 10) + some_nans[0, 1] = numpy.nan + some_nans[1, 1] = numpy.nan + some_nans[1, 0] = numpy.nan + output = medfilt2d( + some_nans, kernel_size=3, conditional=False, mode='nearest') + self.assertEqual(output[0, 0], 0) + self.assertEqual(output[0, 1], 2) + self.assertEqual(output[1, 0], 20) + self.assertEqual(output[1, 1], 20) + + +class TestMedianFilterReflect(ParametricTestCase): + """Unit test for the median filter in reflect mode""" + + def testArange9(self): + """Test from a 3x3 window to RANDOM_FLOAT_MAT""" + img = numpy.arange(9, dtype=numpy.int32) + img = img.reshape(3, 3) + kernel = (3, 3) + res = medfilt2d(image=img, + kernel_size=kernel, + conditional=False, + mode='reflect') + self.assertTrue( + numpy.array_equal(res.ravel(), [1, 2, 2, 3, 4, 5, 6, 6, 7])) + + def testRandom10(self): + """Test a (5, 3) window to a RANDOM_FLOAT_MAT""" + kernel = (5, 3) + + thRes = numpy.array([ + [0.23067427, 0.56049024, 0.56049024, 0.4440632, 0.42161216], + [0.23067427, 0.62717157, 0.56049024, 0.56049024, 0.46372299], + [0.62717157, 0.62717157, 0.56049024, 0.56049024, 0.4440632], + [0.76532598, 0.68382382, 0.56049024, 0.56049024, 0.42161216], + [0.81025249, 0.68382382, 0.56049024, 0.68382382, 0.46372299]]) + + res = medfilt2d(image=RANDOM_FLOAT_MAT, + kernel_size=kernel, + conditional=False, + mode='reflect') + + self.assertTrue(numpy.array_equal(thRes, res)) + + def testApplyReflect1D(self): + """Test the reflect function used for the median filter in reflect mode + """ + # test for inside values + self.assertTrue(reflect(2, 3) == 2) + # test for boundaries values + self.assertTrue(reflect(3, 3) == 2) + self.assertTrue(reflect(4, 3) == 1) + self.assertTrue(reflect(5, 3) == 0) + self.assertTrue(reflect(6, 3) == 0) + self.assertTrue(reflect(7, 3) == 1) + self.assertTrue(reflect(-1, 3) == 0) + self.assertTrue(reflect(-2, 3) == 1) + self.assertTrue(reflect(-3, 3) == 2) + self.assertTrue(reflect(-4, 3) == 2) + self.assertTrue(reflect(-5, 3) == 1) + self.assertTrue(reflect(-6, 3) == 0) + self.assertTrue(reflect(-7, 3) == 0) + + def testRandom10Conditionnal(self): + """Test the median filter in reflect mode and with the conditionnal + option""" + kernel = (3, 1) + + thRes = numpy.array([ + [0.05564293, 0.62717157, 0.75002406, 0.40555336, 0.70278975], + [0.23067427, 0.62717157, 0.56049024, 0.44406320, 0.42161216], + [0.76532598, 0.20303021, 0.56049024, 0.46372299, 0.42161216], + [0.81025249, 0.20303021, 0.68382382, 0.46372299, 0.33623165], + [0.94691602, 0.07813661, 0.81651256, 0.84220106, 0.33623165]]) + + res = medfilt2d(image=RANDOM_FLOAT_MAT, + kernel_size=kernel, + conditional=True, + mode='reflect') + self.assertTrue(numpy.array_equal(thRes, res)) + + def testNaNs(self): + """Test median filter on image with NaNs in reflect mode""" + # Data with a NaN in first corner + nan_corner = numpy.arange(100.).reshape(10, 10) + nan_corner[0, 0] = numpy.nan + output = medfilt2d( + nan_corner, kernel_size=3, conditional=False, mode='reflect') + self.assertEqual(output[0, 0], 10) + self.assertEqual(output[0, 1], 2) + self.assertEqual(output[1, 0], 11) + self.assertEqual(output[1, 1], 12) + + # Data with some NaNs + some_nans = numpy.arange(100.).reshape(10, 10) + some_nans[0, 1] = numpy.nan + some_nans[1, 1] = numpy.nan + some_nans[1, 0] = numpy.nan + output = medfilt2d( + some_nans, kernel_size=3, conditional=False, mode='reflect') + self.assertEqual(output[0, 0], 0) + self.assertEqual(output[0, 1], 2) + self.assertEqual(output[1, 0], 20) + self.assertEqual(output[1, 1], 20) + + def testFilter3_1d(self): + """Test binding and result of the 1d filter""" + self.assertTrue(numpy.array_equal( + medfilt1d(RANDOM_INT_MAT[0], kernel_size=5, conditional=False, + mode='reflect'), + [2, 2, 2, 2, 2]) + ) + + +class TestMedianFilterMirror(ParametricTestCase): + """Unit test for the median filter in mirror mode + """ + + def testApplyMirror1D(self): + """Test the reflect function used for the median filter in mirror mode + """ + # test for inside values + self.assertTrue(mirror(2, 3) == 2) + # test for boundaries values + self.assertTrue(mirror(4, 4) == 2) + self.assertTrue(mirror(5, 4) == 1) + self.assertTrue(mirror(6, 4) == 0) + self.assertTrue(mirror(7, 4) == 1) + self.assertTrue(mirror(8, 4) == 2) + self.assertTrue(mirror(-1, 4) == 1) + self.assertTrue(mirror(-2, 4) == 2) + self.assertTrue(mirror(-3, 4) == 3) + self.assertTrue(mirror(-4, 4) == 2) + self.assertTrue(mirror(-5, 4) == 1) + self.assertTrue(mirror(-6, 4) == 0) + + def testRandom10(self): + """Test a (5, 3) window to a random array""" + kernel = (3, 5) + + thRes = numpy.array([ + [0.05272484, 0.40555336, 0.42161216, 0.42161216, 0.42161216], + [0.56049024, 0.56049024, 0.4440632, 0.4440632, 0.4440632], + [0.56049024, 0.46372299, 0.46372299, 0.46372299, 0.46372299], + [0.68382382, 0.56049024, 0.56049024, 0.46372299, 0.56049024], + [0.68382382, 0.46372299, 0.68382382, 0.46372299, 0.68382382]]) + + res = medfilt2d(image=RANDOM_FLOAT_MAT, + kernel_size=kernel, + conditional=False, + mode='mirror') + + self.assertTrue(numpy.array_equal(thRes, res)) + + def testRandom10Conditionnal(self): + """Test the median filter in reflect mode and with the conditionnal + option""" + kernel = (1, 3) + + thRes = numpy.array([ + [0.62717157, 0.62717157, 0.62717157, 0.70278975, 0.40555336], + [0.02839148, 0.05272484, 0.05272484, 0.42161216, 0.65166994], + [0.74219128, 0.56049024, 0.56049024, 0.44406320, 0.44406320], + [0.20303021, 0.68382382, 0.46372299, 0.68382382, 0.46372299], + [0.07813661, 0.81651256, 0.81651256, 0.81651256, 0.84220106]]) + + res = medfilt2d(image=RANDOM_FLOAT_MAT, + kernel_size=kernel, + conditional=True, + mode='mirror') + + self.assertTrue(numpy.array_equal(thRes, res)) + + def testNaNs(self): + """Test median filter on image with NaNs in mirror mode""" + # Data with a NaN in first corner + nan_corner = numpy.arange(100.).reshape(10, 10) + nan_corner[0, 0] = numpy.nan + output = medfilt2d( + nan_corner, kernel_size=3, conditional=False, mode='mirror') + self.assertEqual(output[0, 0], 11) + self.assertEqual(output[0, 1], 11) + self.assertEqual(output[1, 0], 11) + self.assertEqual(output[1, 1], 12) + + # Data with some NaNs + some_nans = numpy.arange(100.).reshape(10, 10) + some_nans[0, 1] = numpy.nan + some_nans[1, 1] = numpy.nan + some_nans[1, 0] = numpy.nan + output = medfilt2d( + some_nans, kernel_size=3, conditional=False, mode='mirror') + self.assertEqual(output[0, 0], 0) + self.assertEqual(output[0, 1], 12) + self.assertEqual(output[1, 0], 21) + self.assertEqual(output[1, 1], 20) + + def testFilter3_1d(self): + """Test binding and result of the 1d filter""" + self.assertTrue(numpy.array_equal( + medfilt1d(RANDOM_INT_MAT[0], kernel_size=5, conditional=False, + mode='mirror'), + [2, 5, 2, 5, 2]) + ) + +class TestMedianFilterShrink(ParametricTestCase): + """Unit test for the median filter in mirror mode + """ + + def testRandom_3x3(self): + """Test the median filter in shrink mode and with the conditionnal + option""" + kernel = (3, 3) + + thRes = numpy.array([ + [0.62717157, 0.62717157, 0.62717157, 0.65166994, 0.65166994], + [0.62717157, 0.56049024, 0.56049024, 0.44406320, 0.44406320], + [0.74219128, 0.56049024, 0.46372299, 0.46372299, 0.46372299], + [0.74219128, 0.68382382, 0.56049024, 0.56049024, 0.46372299], + [0.81025249, 0.81025249, 0.68382382, 0.81281709, 0.81281709]]) + + res = medfilt2d(image=RANDOM_FLOAT_MAT, + kernel_size=kernel, + conditional=False, + mode='shrink') + + self.assertTrue(numpy.array_equal(thRes, res)) + + def testBounds(self): + """Test the median filter in shrink mode with 3 different kernels + which should return the same result due to the large values of kernels + used. + """ + kernel1 = (1, 9) + kernel2 = (1, 11) + kernel3 = (1, 21) + + thRes = numpy.array([[2, 2, 2, 2, 2], + [2, 2, 2, 2, 2], + [8, 8, 8, 8, 8], + [5, 5, 5, 5, 5]]) + + resK1 = medfilt2d(image=RANDOM_INT_MAT, + kernel_size=kernel1, + conditional=False, + mode='shrink') + + resK2 = medfilt2d(image=RANDOM_INT_MAT, + kernel_size=kernel2, + conditional=False, + mode='shrink') + + resK3 = medfilt2d(image=RANDOM_INT_MAT, + kernel_size=kernel3, + conditional=False, + mode='shrink') + + self.assertTrue(numpy.array_equal(resK1, thRes)) + self.assertTrue(numpy.array_equal(resK2, resK1)) + self.assertTrue(numpy.array_equal(resK3, resK1)) + + def testRandom_3x3Conditionnal(self): + """Test the median filter in reflect mode and with the conditionnal + option""" + kernel = (3, 3) + + thRes = numpy.array([ + [0.05564293, 0.62717157, 0.62717157, 0.40555336, 0.65166994], + [0.62717157, 0.56049024, 0.05272484, 0.65166994, 0.42161216], + [0.23067427, 0.74219128, 0.56049024, 0.44406320, 0.46372299], + [0.81025249, 0.20303021, 0.68382382, 0.46372299, 0.81281709], + [0.81025249, 0.81025249, 0.81651256, 0.81281709, 0.81281709]]) + + res = medfilt2d(image=RANDOM_FLOAT_MAT, + kernel_size=kernel, + conditional=True, + mode='shrink') + + self.assertTrue(numpy.array_equal(res, thRes)) + + def testRandomInt(self): + """Test 3x3 kernel on RANDOM_INT_MAT + """ + kernel = (3, 3) + + thRes = numpy.array([[3, 2, 5, 2, 6], + [5, 3, 6, 6, 7], + [6, 6, 6, 6, 7], + [8, 8, 7, 7, 7]]) + + resK1 = medfilt2d(image=RANDOM_INT_MAT, + kernel_size=kernel, + conditional=False, + mode='shrink') + + self.assertTrue(numpy.array_equal(resK1, thRes)) + + def testNaNs(self): + """Test median filter on image with NaNs in shrink mode""" + # Data with a NaN in first corner + nan_corner = numpy.arange(100.).reshape(10, 10) + nan_corner[0, 0] = numpy.nan + output = medfilt2d( + nan_corner, kernel_size=3, conditional=False, mode='shrink') + self.assertEqual(output[0, 0], 10) + self.assertEqual(output[0, 1], 10) + self.assertEqual(output[1, 0], 11) + self.assertEqual(output[1, 1], 12) + + # Data with some NaNs + some_nans = numpy.arange(100.).reshape(10, 10) + some_nans[0, 1] = numpy.nan + some_nans[1, 1] = numpy.nan + some_nans[1, 0] = numpy.nan + output = medfilt2d( + some_nans, kernel_size=3, conditional=False, mode='shrink') + self.assertEqual(output[0, 0], 0) + self.assertEqual(output[0, 1], 2) + self.assertEqual(output[1, 0], 20) + self.assertEqual(output[1, 1], 20) + + def testFilter3_1d(self): + """Test binding and result of the 1d filter""" + self.assertTrue(numpy.array_equal( + medfilt1d(RANDOM_INT_MAT[0], kernel_size=3, conditional=False, + mode='shrink'), + [5, 2, 5, 2, 6]) + ) + +class TestMedianFilterConstant(ParametricTestCase): + """Unit test for the median filter in constant mode + """ + + def testRandom10(self): + """Test a (5, 3) window to a random array""" + kernel = (3, 5) + + thRes = numpy.array([ + [0., 0.02839148, 0.05564293, 0.02839148, 0.], + [0.05272484, 0.40555336, 0.4440632, 0.42161216, 0.28773158], + [0.05272484, 0.44406320, 0.46372299, 0.42161216, 0.28773158], + [0.20303021, 0.46372299, 0.56049024, 0.44406320, 0.33623165], + [0., 0.07813661, 0.33623165, 0.07813661, 0.]]) + + res = medfilt2d(image=RANDOM_FLOAT_MAT, + kernel_size=kernel, + conditional=False, + mode='constant') + + self.assertTrue(numpy.array_equal(thRes, res)) + + RANDOM_FLOAT_MAT = numpy.array([ + [0.05564293, 0.62717157, 0.75002406, 0.40555336, 0.70278975], + [0.76532598, 0.02839148, 0.05272484, 0.65166994, 0.42161216], + [0.23067427, 0.74219128, 0.56049024, 0.44406320, 0.28773158], + [0.81025249, 0.20303021, 0.68382382, 0.46372299, 0.81281709], + [0.94691602, 0.07813661, 0.81651256, 0.84220106, 0.33623165]]) + + def testRandom10Conditionnal(self): + """Test the median filter in reflect mode and with the conditionnal + option""" + kernel = (1, 3) + + print(RANDOM_FLOAT_MAT) + + thRes = numpy.array([ + [0.05564293, 0.62717157, 0.62717157, 0.70278975, 0.40555336], + [0.02839148, 0.05272484, 0.05272484, 0.42161216, 0.42161216], + [0.23067427, 0.56049024, 0.56049024, 0.44406320, 0.28773158], + [0.20303021, 0.68382382, 0.46372299, 0.68382382, 0.46372299], + [0.07813661, 0.81651256, 0.81651256, 0.81651256, 0.33623165]]) + + res = medfilt2d(image=RANDOM_FLOAT_MAT, + kernel_size=kernel, + conditional=True, + mode='constant') + + self.assertTrue(numpy.array_equal(thRes, res)) + + def testNaNs(self): + """Test median filter on image with NaNs in constant mode""" + # Data with a NaN in first corner + nan_corner = numpy.arange(100.).reshape(10, 10) + nan_corner[0, 0] = numpy.nan + output = medfilt2d(nan_corner, + kernel_size=3, + conditional=False, + mode='constant', + cval=0) + self.assertEqual(output[0, 0], 0) + self.assertEqual(output[0, 1], 2) + self.assertEqual(output[1, 0], 10) + self.assertEqual(output[1, 1], 12) + + # Data with some NaNs + some_nans = numpy.arange(100.).reshape(10, 10) + some_nans[0, 1] = numpy.nan + some_nans[1, 1] = numpy.nan + some_nans[1, 0] = numpy.nan + output = medfilt2d(some_nans, + kernel_size=3, + conditional=False, + mode='constant', + cval=0) + self.assertEqual(output[0, 0], 0) + self.assertEqual(output[0, 1], 0) + self.assertEqual(output[1, 0], 0) + self.assertEqual(output[1, 1], 20) + + def testFilter3_1d(self): + """Test binding and result of the 1d filter""" + self.assertTrue(numpy.array_equal( + medfilt1d(RANDOM_INT_MAT[0], kernel_size=5, conditional=False, + mode='constant'), + [0, 2, 2, 2, 1]) + ) + +class TestGeneralExecution(ParametricTestCase): + """Some general test on median filter application""" + + def testTypes(self): + """Test that all needed types have their implementation of the median + filter + """ + for mode in silx_mf_modes: + for testType in [numpy.float32, numpy.float64, numpy.int16, + numpy.uint16, numpy.int32, numpy.int64, + numpy.uint64]: + with self.subTest(mode=mode, type=testType): + data = (numpy.random.rand(10, 10) * 65000).astype(testType) + out = medfilt2d(image=data, + kernel_size=(3, 3), + conditional=False, + mode=mode) + self.assertTrue(out.dtype.type is testType) + + def testInputDataIsNotModify(self): + """Make sure input data is not modify by the median filter""" + dataIn = numpy.arange(100, dtype=numpy.int32) + dataIn = dataIn.reshape((10, 10)) + dataInCopy = dataIn.copy() + + for mode in silx_mf_modes: + with self.subTest(mode=mode): + medfilt2d(image=dataIn, + kernel_size=(3, 3), + conditional=False, + mode=mode) + self.assertTrue(numpy.array_equal(dataIn, dataInCopy)) + + def testAllNaNs(self): + """Test median filter on image all NaNs""" + all_nans = numpy.empty((10, 10), dtype=numpy.float32) + all_nans[:] = numpy.nan + + for mode in silx_mf_modes: + for conditional in (True, False): + with self.subTest(mode=mode, conditional=conditional): + output = medfilt2d( + all_nans, + kernel_size=3, + conditional=conditional, + mode=mode, + cval=numpy.nan) + self.assertTrue(numpy.all(numpy.isnan(output))) + + def testConditionalWithNaNs(self): + """Test that NaNs are propagated through conditional median filter""" + for mode in silx_mf_modes: + with self.subTest(mode=mode): + image = numpy.ones((10, 10), dtype=numpy.float32) + nan_mask = numpy.zeros_like(image, dtype=bool) + nan_mask[0, 0] = True + nan_mask[4, :] = True + nan_mask[6, 4] = True + image[nan_mask] = numpy.nan + output = medfilt2d( + image, + kernel_size=3, + conditional=True, + mode=mode) + out_isnan = numpy.isnan(output) + self.assertTrue(numpy.all(out_isnan[nan_mask])) + self.assertFalse( + numpy.any(out_isnan[numpy.logical_not(nan_mask)])) + + +def _getScipyAndSilxCommonModes(): + """return the mode which are comparable between silx and scipy""" + modes = silx_mf_modes.copy() + del modes['shrink'] + return modes + + +@unittest.skipUnless(scipy is not None, "scipy not available") +class TestVsScipy(ParametricTestCase): + """Compare scipy.ndimage.median_filter vs silx.math.medianfilter + on comparable + """ + def testWithArange(self): + """Test vs scipy with different kernels on arange matrix""" + data = numpy.arange(10000, dtype=numpy.int32) + data = data.reshape(100, 100) + + kernels = [(3, 7), (7, 5), (1, 1), (3, 3)] + modesToTest = _getScipyAndSilxCommonModes() + for kernel in kernels: + for mode in modesToTest: + with self.subTest(kernel=kernel, mode=mode): + resScipy = scipy.ndimage.median_filter(input=data, + size=kernel, + mode=mode) + resSilx = medfilt2d(image=data, + kernel_size=kernel, + conditional=False, + mode=mode) + + self.assertTrue(numpy.array_equal(resScipy, resSilx)) + + def testRandomMatrice(self): + """Test vs scipy with different kernels on RANDOM_FLOAT_MAT""" + kernels = [(3, 7), (7, 5), (1, 1), (3, 3)] + modesToTest = _getScipyAndSilxCommonModes() + for kernel in kernels: + for mode in modesToTest: + with self.subTest(kernel=kernel, mode=mode): + resScipy = scipy.ndimage.median_filter(input=RANDOM_FLOAT_MAT, + size=kernel, + mode=mode) + + resSilx = medfilt2d(image=RANDOM_FLOAT_MAT, + kernel_size=kernel, + conditional=False, + mode=mode) + + self.assertTrue(numpy.array_equal(resScipy, resSilx)) + + def testAscentOrLena(self): + """Test vs scipy with """ + if hasattr(scipy.misc, 'ascent'): + img = scipy.misc.ascent() + else: + img = scipy.misc.lena() + + kernels = [(3, 1), (3, 5), (5, 9), (9, 3)] + modesToTest = _getScipyAndSilxCommonModes() + + for kernel in kernels: + for mode in modesToTest: + with self.subTest(kernel=kernel, mode=mode): + resScipy = scipy.ndimage.median_filter(input=img, + size=kernel, + mode=mode) + + resSilx = medfilt2d(image=img, + kernel_size=kernel, + conditional=False, + mode=mode) + + self.assertTrue(numpy.array_equal(resScipy, resSilx)) diff --git a/src/silx/math/setup.py b/src/silx/math/setup.py new file mode 100644 index 0000000..1c30e6e --- /dev/null +++ b/src/silx/math/setup.py @@ -0,0 +1,99 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["D. Naudet"] +__license__ = "MIT" +__date__ = "27/03/2017" + +import os.path + +import numpy + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('math', parent_package, top_path) + config.add_subpackage('test') + config.add_subpackage('fit') + config.add_subpackage('medianfilter') + config.add_subpackage('fft') + + # ===================================== + # histogramnd + # ===================================== + histo_src = [os.path.join('histogramnd', 'src', 'histogramnd_c.c'), + 'chistogramnd.pyx'] + histo_inc = [os.path.join('histogramnd', 'include'), + numpy.get_include()] + + config.add_extension('chistogramnd', + sources=histo_src, + include_dirs=histo_inc, + language='c') + + # ===================================== + # histogramnd_lut + # ===================================== + config.add_extension('chistogramnd_lut', + sources=['chistogramnd_lut.pyx'], + include_dirs=histo_inc, + language='c') + # ===================================== + # marching cubes + # ===================================== + mc_src = [os.path.join('marchingcubes', 'mc_lut.cpp'), + 'marchingcubes.pyx'] + config.add_extension('marchingcubes', + sources=mc_src, + include_dirs=['marchingcubes', numpy.get_include()], + language='c++') + + # min/max + config.add_extension('combo', + sources=['combo.pyx'], + include_dirs=['include'], + language='c') + + config.add_extension('_colormap', + sources=["_colormap.pyx"], + language='c', + include_dirs=['include', numpy.get_include()], + extra_link_args=['-fopenmp'], + extra_compile_args=['-fopenmp']) + + config.add_extension('interpolate', + sources=["interpolate.pyx"], + language='c', + include_dirs=['include', numpy.get_include()], + extra_link_args=['-fopenmp'], + extra_compile_args=['-fopenmp']) + + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + + setup(configuration=configuration) diff --git a/src/silx/math/test/__init__.py b/src/silx/math/test/__init__.py new file mode 100644 index 0000000..ad9836c --- /dev/null +++ b/src/silx/math/test/__init__.py @@ -0,0 +1,23 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ diff --git a/src/silx/math/test/benchmark_combo.py b/src/silx/math/test/benchmark_combo.py new file mode 100644 index 0000000..c12f590 --- /dev/null +++ b/src/silx/math/test/benchmark_combo.py @@ -0,0 +1,192 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Benchmarks of the combo module""" + +from __future__ import division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/01/2018" + + +import logging +import os.path +import time +import unittest + +import numpy + +from silx.test.utils import temp_dir +from silx.utils.testutils import ParametricTestCase + +from silx.math import combo + +_logger = logging.getLogger(__name__) +_logger.setLevel(logging.DEBUG) + + +class TestBenchmarkMinMax(ParametricTestCase): + """Benchmark of min max combo""" + + DTYPES = ('float32', 'float64', + 'int8', 'int16', 'int32', 'int64', + 'uint8', 'uint16', 'uint32', 'uint64') + + ARANGE = 'ascent', 'descent', 'random' + + EXPONENT = 3, 4, 5, 6, 7 + + def test_benchmark_min_max(self): + """Benchmark min_max without min positive. + + Compares with: + + - numpy.nanmin, numpy.nanmax and + - numpy.argmin, numpy.argmax + + It runs bench for different types, different data size and 3 + data sets: increasing , decreasing and random data. + """ + durations = {'min/max': [], 'argmin/max': [], 'combo': []} + + _logger.info('Benchmark against argmin/argmax and nanmin/nanmax') + + for dtype in self.DTYPES: + for arange in self.ARANGE: + for exponent in self.EXPONENT: + size = 10**exponent + with self.subTest(dtype=dtype, size=size, arange=arange): + if arange == 'ascent': + data = numpy.arange(0, size, 1, dtype=dtype) + elif arange == 'descent': + data = numpy.arange(size, 0, -1, dtype=dtype) + else: + if dtype in ('float32', 'float64'): + data = numpy.random.random(size) + else: + data = numpy.random.randint(10**6, size=size) + data = numpy.array(data, dtype=dtype) + + start = time.time() + ref_min = numpy.nanmin(data) + ref_max = numpy.nanmax(data) + durations['min/max'].append(time.time() - start) + + start = time.time() + ref_argmin = numpy.argmin(data) + ref_argmax = numpy.argmax(data) + durations['argmin/max'].append(time.time() - start) + + start = time.time() + result = combo.min_max(data, min_positive=False) + durations['combo'].append(time.time() - start) + + _logger.info( + '%s-%s-10**%d\tx%.2f argmin/max x%.2f min/max', + dtype, arange, exponent, + durations['argmin/max'][-1] / durations['combo'][-1], + durations['min/max'][-1] / durations['combo'][-1]) + + self.assertEqual(result.minimum, ref_min) + self.assertEqual(result.maximum, ref_max) + self.assertEqual(result.argmin, ref_argmin) + self.assertEqual(result.argmax, ref_argmax) + + self.show_results('min/max', durations, 'combo') + + def test_benchmark_min_pos(self): + """Benchmark min_max wit min positive. + + Compares with: + + - numpy.nanmin(data[data > 0]); numpy.nanmin(pos); numpy.nanmax(pos) + + It runs bench for different types, different data size and 3 + data sets: increasing , decreasing and random data. + """ + durations = {'min/max': [], 'combo': []} + + _logger.info('Benchmark against min, max, positive min') + + for dtype in self.DTYPES: + for arange in self.ARANGE: + for exponent in self.EXPONENT: + size = 10**exponent + with self.subTest(dtype=dtype, size=size, arange=arange): + if arange == 'ascent': + data = numpy.arange(0, size, 1, dtype=dtype) + elif arange == 'descent': + data = numpy.arange(size, 0, -1, dtype=dtype) + else: + if dtype in ('float32', 'float64'): + data = numpy.random.random(size) + else: + data = numpy.random.randint(10**6, size=size) + data = numpy.array(data, dtype=dtype) + + start = time.time() + ref_min_positive = numpy.nanmin(data[data > 0]) + ref_min = numpy.nanmin(data) + ref_max = numpy.nanmax(data) + durations['min/max'].append(time.time() - start) + + start = time.time() + result = combo.min_max(data, min_positive=True) + durations['combo'].append(time.time() - start) + + _logger.info( + '%s-%s-10**%d\tx%.2f min/minpos/max', + dtype, arange, exponent, + durations['min/max'][-1] / durations['combo'][-1]) + + self.assertEqual(result.min_positive, ref_min_positive) + self.assertEqual(result.minimum, ref_min) + self.assertEqual(result.maximum, ref_max) + + self.show_results('min/max/min positive', durations, 'combo') + + def show_results(self, title, durations, ref_key): + try: + from matplotlib import pyplot + except ImportError: + _logger.warning('matplotlib not available') + return + + pyplot.title(title) + pyplot.xlabel('-'.join(self.DTYPES)) + pyplot.ylabel('duration (sec)') + for label, values in durations.items(): + pyplot.semilogy(values, label=label) + pyplot.legend() + pyplot.show() + + pyplot.title(title) + pyplot.xlabel('-'.join(self.DTYPES)) + pyplot.ylabel('Duration ratio') + ref = numpy.array(durations[ref_key]) + for label, values in durations.items(): + values = numpy.array(values) + pyplot.plot(values/ref, label=label + ' / ' + ref_key) + pyplot.legend() + pyplot.show() diff --git a/src/silx/math/test/histo_benchmarks.py b/src/silx/math/test/histo_benchmarks.py new file mode 100644 index 0000000..7d3216d --- /dev/null +++ b/src/silx/math/test/histo_benchmarks.py @@ -0,0 +1,269 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +histogramnd benchmarks, vs numpy.histogramdd (bin counts and weights). +""" + +import numpy as np + +import time + +from silx.math import histogramnd + + +def print_times(t0s, t1s, t2s, t3s): + c_times = t1s - t0s + np_times = t2s - t1s + np_w_times = t3s - t2s + + time_txt = 'min : {0: <7.3f}; max : {1: <7.3f}; avg : {2: <7.3f}' + + print('\tTimes :') + print('\tC : ' + time_txt.format(c_times.min(), + c_times.max(), + c_times.mean())) + print('\tNP : ' + time_txt.format(np_times.min(), + np_times.max(), + np_times.mean())) + print('\tNP(W) : ' + time_txt.format(np_w_times.min(), + np_w_times.max(), + np_w_times.mean())) + + +def commpare_results(txt, + times, + result_c, + result_np, + result_np_w, + sample, + weights, + raise_ex=False): + + if result_np: + hits_cmp = np.array_equal(result_c[0], result_np[0]) + else: + hits_cmp = None + + if result_np_w and result_c[1] is not None: + weights_cmp = np.array_equal(result_c[1], result_np_w[0]) + else: + weights_cmp = None + + if((hits_cmp is not None and not hits_cmp) or + (weights_cmp is not None and not weights_cmp)): + err_txt = (txt + ' : results arent the same : ' + 'hits : {0}, ' + 'weights : {1}.' + ''.format('OK' if hits_cmp else 'NOK', + 'OK' if weights_cmp else 'NOK')) + print('\t' + err_txt) + if raise_ex: + raise ValueError(err_txt) + return False + + result_txt = ' : results OK. c : {0: <7.3f};'.format(times[0]) + if result_np or result_np_w: + result_txt += (' np : {0: <7.3f}; ' + 'np (weights) {1: <7.3f}.' + ''.format(times[1], times[2])) + print('\t' + txt + result_txt) + return True + + +def benchmark(n_loops, + sample_shape, + sample_rng, + weights_rng, + histo_range, + n_bins, + weight_min, + weight_max, + last_bin_closed, + dtype=np.double, + do_weights=True, + do_numpy=True): + + int_min = 0 + int_max = 100000 + + sample = np.random.randint(int_min, + high=int_max, + size=sample_shape).astype(np.double) + sample = (sample_rng[0] + + (sample - int_min) * + (sample_rng[1] - sample_rng[0]) / + (int_max - int_min)) + sample = sample.astype(dtype) + + if do_weights: + weights = np.random.randint(int_min, + high=int_max, + size=(ssetup.pyample_shape[0],)) + weights = weights.astype(np.double) + weights = (weights_rng[0] + + (weights - int_min) * + (weights_rng[1] - weights_rng[0]) / + (int_max - int_min)) + else: + weights = None + + t0s = [] + t1s = [] + t2s = [] + t3s = [] + + for i in range(n_loops): + t0s.append(time.time()) + result_c = histogramnd(sample, + histo_range, + n_bins, + weights=weights, + weight_min=weight_min, + weight_max=weight_max, + last_bin_closed=last_bin_closed) + t1s.append(time.time()) + if do_numpy: + result_np = np.histogramdd(sample, + bins=n_bins, + range=histo_range) + t2s.append(time.time()) + result_np_w = np.histogramdd(sample, + bins=n_bins, + range=histo_range, + weights=weights) + t3s.append(time.time()) + else: + result_np = None + result_np_w = None + t2s.append(0) + t3s.append(0) + + commpare_results('Run {0}'.format(i), + [t1s[-1] - t0s[-1], t2s[-1] - t1s[-1], t3s[-1] - t2s[-1]], + result_c, + result_np, + result_np_w, + sample, + weights) + + print_times(np.array(t0s), np.array(t1s), np.array(t2s), np.array(t3s)) + + +def run_benchmark(dtype=np.double, + do_weights=True, + do_numpy=True): + n_loops = 5 + + weights_rng = [0., 100.] + sample_rng = [0., 100.] + + weight_min = None + weight_max = None + last_bin_closed = True + + # ==================================================== + # ==================================================== + # 1D + # ==================================================== + # ==================================================== + + print('==========================') + print(' 1D [{0}]'.format(dtype)) + print('==========================') + sample_shape = (10**7,) + histo_range = [[0., 100.]] + n_bins = 30 + + benchmark(n_loops, + sample_shape, + sample_rng, + weights_rng, + histo_range, + n_bins, + weight_min, + weight_max, + last_bin_closed, + dtype=dtype, + do_weights=True, + do_numpy=do_numpy) + + # ==================================================== + # ==================================================== + # 2D + # ==================================================== + # ==================================================== + + print('==========================') + print(' 2D [{0}]'.format(dtype)) + print('==========================') + sample_shape = (10**7, 2) + histo_range = [[0., 100.], [0., 100.]] + n_bins = 30 + + benchmark(n_loops, + sample_shape, + sample_rng, + weights_rng, + histo_range, + n_bins, + weight_min, + weight_max, + last_bin_closed, + dtype=dtype, + do_weights=True, + do_numpy=do_numpy) + + # ==================================================== + # ==================================================== + # 3D + # ==================================================== + # ==================================================== + + print('==========================') + print(' 3D [{0}]'.format(dtype)) + print('==========================') + sample_shape = (10**7, 3) + histo_range = np.array([[0., 100.], [0., 100.], [0., 100.]]) + n_bins = 30 + + benchmark(n_loops, + sample_shape, + sample_rng, + weights_rng, + histo_range, + n_bins, + weight_min, + weight_max, + last_bin_closed, + dtype=dtype, + do_weights=True, + do_numpy=do_numpy) + +if __name__ == '__main__': + types = (np.double, np.int32, np.float32,) + + for t in types: + run_benchmark(t, + do_weights=True, + do_numpy=True) diff --git a/src/silx/math/test/test_HistogramndLut_nominal.py b/src/silx/math/test/test_HistogramndLut_nominal.py new file mode 100644 index 0000000..52e003c --- /dev/null +++ b/src/silx/math/test/test_HistogramndLut_nominal.py @@ -0,0 +1,571 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Nominal tests of the HistogramndLut function. +""" + +import unittest + +import numpy as np + +from silx.math import HistogramndLut + + +def _get_bin_edges(histo_range, n_bins, n_dims): + edges = [] + for i_dim in range(n_dims): + edges.append(histo_range[i_dim, 0] + + np.arange(n_bins[i_dim] + 1) * + (histo_range[i_dim, 1] - histo_range[i_dim, 0]) / + n_bins[i_dim]) + return tuple(edges) + + +# ============================================================== +# ============================================================== +# ============================================================== + + +class _TestHistogramndLut_nominal(unittest.TestCase): + """ + Unit tests of the HistogramndLut class. + """ + __test__ = False # ignore abstract class + + ndims = None + + def setUp(self): + ndims = self.ndims + if ndims is None: + self.skipTest("Abstract class") + self.tested_dim = ndims-1 + + if ndims is None: + raise ValueError('ndims class member not set.') + + sample = np.array([5.5, -3.3, + 0., -0.5, + 3.3, 8.8, + -7.7, 6.0, + -4.0]) + + weights = np.array([500.5, -300.3, + 0.01, -0.5, + 300.3, 800.8, + -700.7, 600.6, + -400.4]) + + n_elems = len(sample) + + if ndims == 1: + shape = (n_elems,) + else: + shape = (n_elems, ndims) + + self.sample = np.zeros(shape=shape, dtype=sample.dtype) + if ndims == 1: + self.sample = sample + else: + self.sample[..., ndims-1] = sample + + self.weights = weights + + # the tests are performed along one dimension, + # all the other bins indices along the other dimensions + # are expected to be 2 + # (e.g : when testing a 2D sample : [0, x] will go into + # bin [2, y] because of the bin ranges [-2, 2] and n_bins = 4 + # for the first dimension) + self.other_axes_index = 2 + self.histo_range = np.repeat([[-2., 2.]], ndims, axis=0) + self.histo_range[ndims-1] = [-4., 6.] + + self.n_bins = np.array([4]*ndims) + self.n_bins[ndims-1] = 5 + + if ndims == 1: + def fill_histo(h, v, dim, op=None): + if op: + h[:] = op(h[:], v) + else: + h[:] = v + self.fill_histo = fill_histo + else: + def fill_histo(h, v, dim, op=None): + idx = [self.other_axes_index]*len(h.shape) + idx[dim] = slice(0, None) + idx = tuple(idx) + if op: + h[idx] = op(h[idx], v) + else: + h[idx] = v + self.fill_histo = fill_histo + + def test_nominal_bin_edges(self): + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + bin_edges = instance.bins_edges + + expected_edges = _get_bin_edges(self.histo_range, + self.n_bins, + self.ndims) + + for i_edges, edges in enumerate(expected_edges): + self.assertTrue(np.array_equal(bin_edges[i_edges], + expected_edges[i_edges]), + msg='Testing bin_edges for dim {0}' + ''.format(i_edges+1)) + + def test_nominal_histo_range(self): + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + histo_range = instance.histo_range + + self.assertTrue(np.array_equal(histo_range, self.histo_range)) + + def test_nominal_last_bin_closed(self): + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + last_bin_closed = instance.last_bin_closed + + self.assertEqual(last_bin_closed, False) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins, + last_bin_closed=True) + + last_bin_closed = instance.last_bin_closed + + self.assertEqual(last_bin_closed, True) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins, + last_bin_closed=False) + + last_bin_closed = instance.last_bin_closed + + self.assertEqual(last_bin_closed, False) + + def test_nominal_n_bins_array(self): + + test_n_bins = np.arange(self.ndims) + 10 + instance = HistogramndLut(self.sample, + self.histo_range, + test_n_bins) + + n_bins = instance.n_bins + + self.assertTrue(np.array_equal(test_n_bins, n_bins)) + + def test_nominal_n_bins_scalar(self): + + test_n_bins = 10 + expected_n_bins = np.array([test_n_bins] * self.ndims) + instance = HistogramndLut(self.sample, + self.histo_range, + test_n_bins) + + n_bins = instance.n_bins + + self.assertTrue(np.array_equal(expected_n_bins, n_bins)) + + def test_nominal_histo_ref(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + instance.accumulate(self.weights) + + histo = instance.histo() + w_histo = instance.weighted_histo() + histo_ref = instance.histo(copy=False) + w_histo_ref = instance.weighted_histo(copy=False) + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + self.assertTrue(np.array_equal(histo_ref, expected_h)) + self.assertTrue(np.array_equal(w_histo_ref, expected_c)) + + histo_ref[0, ...] = histo_ref[0, ...] + 10 + w_histo_ref[0, ...] = w_histo_ref[0, ...] + 20 + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + self.assertFalse(np.array_equal(histo_ref, expected_h)) + self.assertFalse(np.array_equal(w_histo_ref, expected_c)) + + histo_2 = instance.histo() + w_histo_2 = instance.weighted_histo() + + self.assertFalse(np.array_equal(histo_2, expected_h)) + self.assertFalse(np.array_equal(w_histo_2, expected_c)) + self.assertTrue(np.array_equal(histo_2, histo_ref)) + self.assertTrue(np.array_equal(w_histo_2, w_histo_ref)) + + def test_nominal_accumulate_once(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + instance.accumulate(self.weights) + + histo = instance.histo() + w_histo = instance.weighted_histo() + + self.assertEqual(w_histo.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + self.assertTrue(np.array_equal(instance.histo(), expected_h)) + self.assertTrue(np.array_equal(instance.weighted_histo(), + expected_c)) + + def test_nominal_accumulate_twice(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + # calling accumulate twice + expected_h *= 2 + expected_c *= 2 + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + instance.accumulate(self.weights) + + instance.accumulate(self.weights) + + histo = instance.histo() + w_histo = instance.weighted_histo() + + self.assertEqual(w_histo.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + self.assertTrue(np.array_equal(instance.histo(), expected_h)) + self.assertTrue(np.array_equal(instance.weighted_histo(), + expected_c)) + + def test_nominal_apply_lut_once(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + histo, w_histo = instance.apply_lut(self.weights) + + self.assertEqual(w_histo.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + self.assertEqual(instance.histo(), None) + self.assertEqual(instance.weighted_histo(), None) + + def test_nominal_apply_lut_twice(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + # calling apply_lut twice + expected_h *= 2 + expected_c *= 2 + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + histo, w_histo = instance.apply_lut(self.weights) + histo_2, w_histo_2 = instance.apply_lut(self.weights, + histo=histo, + weighted_histo=w_histo) + + self.assertEqual(id(histo), id(histo_2)) + self.assertEqual(id(w_histo), id(w_histo_2)) + self.assertEqual(w_histo.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + self.assertEqual(instance.histo(), None) + self.assertEqual(instance.weighted_histo(), None) + + def test_nominal_accumulate_last_bin_closed(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 2]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 1101.1]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins, + last_bin_closed=True) + + instance.accumulate(self.weights) + + histo = instance.histo() + w_histo = instance.weighted_histo() + + self.assertEqual(w_histo.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + + def test_nominal_accumulate_weight_min_max(self): + """ + """ + weight_min = -299.9 + weight_max = 499.9 + + expected_h_tpl = np.array([0, 1, 1, 1, 0]) + expected_c_tpl = np.array([0., -0.5, 0.01, 300.3, 0.]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + instance.accumulate(self.weights, + weight_min=weight_min, + weight_max=weight_max) + + histo = instance.histo() + w_histo = instance.weighted_histo() + + self.assertEqual(w_histo.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + + def test_nominal_accumulate_forced_int32(self): + """ + double weights, int32 weighted_histogram + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700, 0, 0, 300, 500]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins, + dtype=np.int32) + + instance.accumulate(self.weights) + + histo = instance.histo() + w_histo = instance.weighted_histo() + + self.assertEqual(w_histo.dtype, np.int32) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + + def test_nominal_accumulate_forced_float32(self): + """ + int32 weights, float32 weighted_histogram + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700., 0., 0., 300., 500.]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.float32) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins, + dtype=np.float32) + + instance.accumulate(self.weights.astype(np.int32)) + + histo = instance.histo() + w_histo = instance.weighted_histo() + + self.assertEqual(w_histo.dtype, np.float32) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + + def test_nominal_accumulate_int32(self): + """ + int32 weights + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700, 0, 0, 300, 500]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.int32) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + instance.accumulate(self.weights.astype(np.int32)) + + histo = instance.histo() + w_histo = instance.weighted_histo() + + self.assertEqual(w_histo.dtype, np.int32) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + + def test_nominal_accumulate_int32_double(self): + """ + int32 weights + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700, 0, 0, 300, 500]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.int32) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + instance = HistogramndLut(self.sample, + self.histo_range, + self.n_bins) + + instance.accumulate(self.weights.astype(np.int32)) + instance.accumulate(self.weights) + + histo = instance.histo() + w_histo = instance.weighted_histo() + + expected_h *= 2 + expected_c *= 2 + + self.assertEqual(w_histo.dtype, np.int32) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(w_histo, expected_c)) + + def testNoneNativeTypes(self): + type = self.sample.dtype.newbyteorder("B") + sampleB = self.sample.astype(type) + + type = self.sample.dtype.newbyteorder("L") + sampleL = self.sample.astype(type) + + histo_inst = HistogramndLut(sampleB, + self.histo_range, + self.n_bins) + + histo_inst = HistogramndLut(sampleL, + self.histo_range, + self.n_bins) + + +class TestHistogramndLut_nominal_1d(_TestHistogramndLut_nominal): + __test__ = True # because _TestHistogramndLut_nominal is ignored + ndims = 1 + + +class TestHistogramndLut_nominal_2d(_TestHistogramndLut_nominal): + __test__ = True # because _TestHistogramndLut_nominal is ignored + ndims = 2 + + +class TestHistogramndLut_nominal_3d(_TestHistogramndLut_nominal): + __test__ = True # because _TestHistogramndLut_nominal is ignored + ndims = 3 diff --git a/src/silx/math/test/test_calibration.py b/src/silx/math/test/test_calibration.py new file mode 100644 index 0000000..7158293 --- /dev/null +++ b/src/silx/math/test/test_calibration.py @@ -0,0 +1,145 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Tests of the calibration module""" + +from __future__ import division + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "14/05/2018" + + +import unittest + +import numpy + +from silx.math.calibration import NoCalibration, LinearCalibration, \ + ArrayCalibration, FunctionCalibration + + +X = numpy.array([3.14, 2.73, 1337]) + + +class TestNoCalibration(unittest.TestCase): + def setUp(self): + self.calib = NoCalibration() + + def testIsAffine(self): + self.assertTrue(self.calib.is_affine()) + + def testSlope(self): + self.assertEqual(self.calib.get_slope(), 1.) + + def testYIntercept(self): + self.assertEqual(self.calib(0.), + 0.) + + def testCall(self): + self.assertTrue(numpy.array_equal(self.calib(X), X)) + + +class TestLinearCalibration(unittest.TestCase): + def setUp(self): + self.y_intercept = 1.5 + self.slope = 2.5 + self.calib = LinearCalibration(y_intercept=self.y_intercept, + slope=self.slope) + + def testIsAffine(self): + self.assertTrue(self.calib.is_affine()) + + def testSlope(self): + self.assertEqual(self.calib.get_slope(), self.slope) + + def testYIntercept(self): + self.assertEqual(self.calib(0.), + self.y_intercept) + + def testCall(self): + self.assertTrue(numpy.array_equal(self.calib(X), + self.y_intercept + self.slope * X)) + + +class TestArrayCalibration(unittest.TestCase): + def setUp(self): + self.arr = numpy.array([45.2, 25.3, 666., -8.]) + self.calib = ArrayCalibration(self.arr) + self.affine_calib = ArrayCalibration([0.1, 0.2, 0.3]) + + def testIsAffine(self): + self.assertFalse(self.calib.is_affine()) + self.assertTrue(self.affine_calib.is_affine()) + + def testSlope(self): + with self.assertRaises(AttributeError): + self.calib.get_slope() + self.assertEqual(self.affine_calib.get_slope(), + 0.1) + + def testYIntercept(self): + self.assertEqual(self.calib(0), + self.arr[0]) + + def testCall(self): + with self.assertRaises(ValueError): + # X is an array with a different shape + self.calib(X) + + with self.assertRaises(ValueError): + # floats are not valid indices + self.calib(3.14) + + self.assertTrue( + numpy.array_equal(self.calib([1, 2, 3, 4]), + self.arr)) + + for idx, value in enumerate(self.arr): + self.assertEqual(self.calib(idx), value) + + +class TestFunctionCalibration(unittest.TestCase): + def setUp(self): + self.non_affine_fun = numpy.sin + self.non_affine_calib = FunctionCalibration(self.non_affine_fun) + + self.affine_fun = lambda x: 52. * x + 0.01 + self.affine_calib = FunctionCalibration(self.affine_fun, + is_affine=True) + + def testIsAffine(self): + self.assertFalse(self.non_affine_calib.is_affine()) + self.assertTrue(self.affine_calib.is_affine()) + + def testSlope(self): + with self.assertRaises(AttributeError): + self.non_affine_calib.get_slope() + self.assertAlmostEqual(self.affine_calib.get_slope(), + 52.) + + def testCall(self): + for x in X: + self.assertAlmostEqual(self.non_affine_calib(x), + self.non_affine_fun(x)) + self.assertAlmostEqual(self.affine_calib(x), + self.affine_fun(x)) diff --git a/src/silx/math/test/test_colormap.py b/src/silx/math/test/test_colormap.py new file mode 100644 index 0000000..0b0ec59 --- /dev/null +++ b/src/silx/math/test/test_colormap.py @@ -0,0 +1,269 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Test for colormap mapping implementation""" + +from __future__ import division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "16/05/2018" + + +import logging +import sys + +import numpy + +from silx.utils.testutils import ParametricTestCase +from silx.math import colormap + + +_logger = logging.getLogger(__name__) + + +class TestNormalization(ParametricTestCase): + """Test silx.math.colormap.Normalization sub classes""" + + def _testCodec(self, normalization, rtol=1e-5): + """Test apply/revert for normalizations""" + test_data = (numpy.arange(1, 10, dtype=numpy.int32), + numpy.linspace(1., 100., 1000, dtype=numpy.float32), + numpy.linspace(-1., 1., 100, dtype=numpy.float32), + 1., + 1) + + for index in range(len(test_data)): + with self.subTest(normalization=normalization, data_index=index): + data = test_data[index] + normalized = normalization.apply(data, 1., 100.) + result = normalization.revert(normalized, 1., 100.) + + self.assertTrue(numpy.array_equal( + numpy.isnan(normalized), numpy.isnan(result))) + + if isinstance(data, numpy.ndarray): + notNaN = numpy.logical_not(numpy.isnan(result)) + data = data[notNaN] + result = result[notNaN] + self.assertTrue(numpy.allclose(data, result, rtol=rtol)) + + def testLinearNormalization(self): + """Test for LinearNormalization""" + normalization = colormap.LinearNormalization() + self._testCodec(normalization) + + def testLogarithmicNormalization(self): + """Test for LogarithmicNormalization""" + normalization = colormap.LogarithmicNormalization() + # relative tolerance is higher because of the log approximation + self._testCodec(normalization, rtol=1e-3) + + # Specific extra tests + self.assertTrue(numpy.isnan(normalization.apply(-1., 1., 100.))) + self.assertTrue(numpy.isnan(normalization.apply(numpy.nan, 1., 100.))) + self.assertEqual(normalization.apply(numpy.inf, 1., 100.), numpy.inf) + self.assertEqual(normalization.apply(0, 1., 100.), - numpy.inf) + + def testArcsinhNormalization(self): + """Test for ArcsinhNormalization""" + self._testCodec(colormap.ArcsinhNormalization()) + + def testSqrtNormalization(self): + """Test for SqrtNormalization""" + normalization = colormap.SqrtNormalization() + self._testCodec(normalization) + + # Specific extra tests + self.assertTrue(numpy.isnan(normalization.apply(-1., 0., 100.))) + self.assertTrue(numpy.isnan(normalization.apply(numpy.nan, 0., 100.))) + self.assertEqual(normalization.apply(numpy.inf, 0., 100.), numpy.inf) + self.assertEqual(normalization.apply(0, 0., 100.), 0.) + + +class TestColormap(ParametricTestCase): + """Test silx.math.colormap.cmap""" + + NORMALIZATIONS = ( + 'linear', + 'log', + 'arcsinh', + 'sqrt', + colormap.LinearNormalization(), + colormap.LogarithmicNormalization(), + colormap.GammaNormalization(2.), + colormap.GammaNormalization(0.5)) + + @staticmethod + def ref_colormap(data, colors, vmin, vmax, normalization, nan_color): + """Reference implementation of colormap + + :param numpy.ndarray data: Data to convert + :param numpy.ndarray colors: Color look-up-table + :param float vmin: Lower bound of the colormap range + :param float vmax: Upper bound of the colormap range + :param str normalization: Normalization to use + :param Union[numpy.ndarray, None] nan_color: Color to use for NaN + """ + norm_functions = {'linear': lambda v: v, + 'log': numpy.log10, + 'arcsinh': numpy.arcsinh, + 'sqrt': numpy.sqrt} + + if isinstance(normalization, str): + norm_function = norm_functions[normalization] + else: + def norm_function(value): + return normalization.apply(value, vmin, vmax) + + with numpy.errstate(divide='ignore', invalid='ignore'): + # Ignore divide by zero and invalid value encountered in log10, sqrt + norm_data, vmin, vmax = map(norm_function, (data, vmin, vmax)) + + if normalization == 'arcsinh' and sys.platform == 'win32': + # There is a difference of behavior of numpy.arcsinh + # between Windows and other OS for results of infinite values + # This makes Windows behaves as Linux and MacOS + norm_data[data == numpy.inf] = numpy.inf + norm_data[data == -numpy.inf] = -numpy.inf + + nb_colors = len(colors) + scale = nb_colors / (vmax - vmin) + + # Substraction must be done in float to avoid overflow with uint + indices = numpy.clip(scale * (norm_data - float(vmin)), + 0, nb_colors - 1) + indices[numpy.isnan(indices)] = nb_colors # Use an extra index for NaN + indices = indices.astype('uint') + + # Add NaN color to array + if nan_color is None: + nan_color = (0,) * colors.shape[-1] + colors = numpy.append(colors, numpy.atleast_2d(nan_color), axis=0) + + return colors[indices] + + def _test(self, data, colors, vmin, vmax, normalization, nan_color): + """Run test of colormap against alternative implementation + + :param numpy.ndarray data: Data to convert + :param numpy.ndarray colors: Color look-up-table + :param float vmin: Lower bound of the colormap range + :param float vmax: Upper bound of the colormap range + :param str normalization: Normalization to use + :param Union[numpy.ndarray, None] nan_color: Color to use for NaN + """ + image = colormap.cmap( + data, colors, vmin, vmax, normalization, nan_color) + + ref_image = self.ref_colormap( + data, colors, vmin, vmax, normalization, nan_color) + + self.assertTrue(numpy.allclose(ref_image, image)) + self.assertEqual(image.dtype, colors.dtype) + self.assertEqual(image.shape, data.shape + (colors.shape[-1],)) + + def test(self): + """Test all dtypes with finite data + + Test all supported types and endianness + """ + colors = numpy.zeros((256, 4), dtype=numpy.uint8) + colors[:, 0] = numpy.arange(len(colors)) + colors[:, 3] = 255 + + # Generates (u)int and floats types + dtypes = [e + k + i for e in '<>' for k in 'uif' for i in '1248' + if k != 'f' or i != '1'] + dtypes.append(numpy.dtype(numpy.longdouble).name) # Add long double + + for normalization in self.NORMALIZATIONS: + for dtype in dtypes: + with self.subTest(dtype=dtype, normalization=normalization): + _logger.info('normalization: %s, dtype: %s', + normalization, dtype) + data = numpy.arange(-5, 15, dtype=dtype).reshape(4, 5) + + self._test(data, colors, 1, 10, normalization, None) + + def test_not_finite(self): + """Test float data with not finite values""" + colors = numpy.zeros((256, 4), dtype=numpy.uint8) + colors[:, 0] = numpy.arange(len(colors)) + colors[:, 3] = 255 + + test_data = { # message: data + 'no finite values': (float('inf'), float('-inf'), float('nan')), + 'only NaN': (float('nan'), float('nan'), float('nan')), + 'mix finite/not finite': (float('inf'), float('-inf'), 1., float('nan')), + } + + for normalization in self.NORMALIZATIONS: + for msg, data in test_data.items(): + with self.subTest(msg, normalization=normalization): + _logger.info('normalization: %s, %s', normalization, msg) + data = numpy.array(data, dtype=numpy.float64) + self._test(data, colors, 1, 10, normalization, (0, 0, 0, 0)) + + def test_errors(self): + """Test raising exception for bad vmin, vmax, normalization parameters + """ + colors = numpy.zeros((256, 4), dtype=numpy.uint8) + colors[:, 0] = numpy.arange(len(colors)) + colors[:, 3] = 255 + + data = numpy.arange(10, dtype=numpy.float64) + + test_params = [ # (vmin, vmax, normalization) + (-1., 2., 'log'), + (0., 1., 'log'), + (1., 0., 'log'), + (-1., 1., 'sqrt'), + (1., -1., 'sqrt'), + ] + + for vmin, vmax, normalization in test_params: + with self.subTest( + vmin=vmin, vmax=vmax, normalization=normalization): + _logger.info('normalization: %s, range: [%f, %f]', + normalization, vmin, vmax) + with self.assertRaises(ValueError): + self._test(data, colors, vmin, vmax, normalization, None) + + +def test_apply_colormap(): + """Basic test of silx.math.colormap.apply_colormap""" + data = numpy.arange(256) + expected_colors = numpy.empty((256, 4), dtype=numpy.uint8) + expected_colors[:, :3] = numpy.arange(256, dtype=numpy.uint8).reshape(256, 1) + expected_colors[:, 3] = 255 + colors = colormap.apply_colormap( + data, + colormap="gray", + norm="linear", + autoscale="minmax", + vmin=None, + vmax=None, + gamma=1.0) + assert numpy.array_equal(colors, expected_colors) diff --git a/src/silx/math/test/test_combo.py b/src/silx/math/test/test_combo.py new file mode 100644 index 0000000..9a96923 --- /dev/null +++ b/src/silx/math/test/test_combo.py @@ -0,0 +1,207 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Tests of the combo module""" + +from __future__ import division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/01/2018" + + +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase + +from silx.math.combo import min_max + + +class TestMinMax(ParametricTestCase): + """Tests of min max combo""" + + FLOATING_DTYPES = 'float32', 'float64' + if hasattr(numpy, "float128"): + FLOATING_DTYPES += ('float128',) + SIGNED_INT_DTYPES = 'int8', 'int16', 'int32', 'int64' + UNSIGNED_INT_DTYPES = 'uint8', 'uint16', 'uint32', 'uint64' + DTYPES = FLOATING_DTYPES + SIGNED_INT_DTYPES + UNSIGNED_INT_DTYPES + + def _numpy_min_max(self, data, min_positive=False, finite=False): + """Reference numpy implementation of min_max + + :param numpy.ndarray data: Data set to use for test + :param bool min_positive: True to test with positive min + :param bool finite: True to only test finite values + """ + data = numpy.array(data, copy=False) + if data.size == 0: + raise ValueError('Zero-sized array') + + minimum = None + argmin = None + maximum = None + argmax = None + min_pos = None + argmin_pos = None + + if finite: + filtered_data = data[numpy.isfinite(data)] + else: + filtered_data = data + + if filtered_data.size > 0: + if numpy.all(numpy.isnan(filtered_data)): + minimum = numpy.nan + argmin = 0 + maximum = numpy.nan + argmax = 0 + else: + minimum = numpy.nanmin(filtered_data) + # nanargmin equivalent + argmin = numpy.where(data == minimum)[0][0] + maximum = numpy.nanmax(filtered_data) + # nanargmax equivalent + argmax = numpy.where(data == maximum)[0][0] + + if min_positive: + with numpy.errstate(invalid='ignore'): + # Ignore invalid value encountered in greater + pos_data = filtered_data[filtered_data > 0] + if pos_data.size > 0: + min_pos = numpy.min(pos_data) + argmin_pos = numpy.where(data == min_pos)[0][0] + + return minimum, min_pos, maximum, argmin, argmin_pos, argmax + + def _test_min_max(self, data, min_positive, finite=False): + """Compare min_max with numpy for the given dataset + + :param numpy.ndarray data: Data set to use for test + :param bool min_positive: True to test with positive min + :param bool finite: True to only test finite values + """ + minimum, min_pos, maximum, argmin, argmin_pos, argmax = \ + self._numpy_min_max(data, min_positive, finite) + + result = min_max(data, min_positive, finite) + + self.assertSimilar(minimum, result.minimum) + self.assertSimilar(min_pos, result.min_positive) + self.assertSimilar(maximum, result.maximum) + self.assertSimilar(argmin, result.argmin) + self.assertSimilar(argmin_pos, result.argmin_positive) + self.assertSimilar(argmax, result.argmax) + + def assertSimilar(self, a, b): + """Assert that a and b are both None or NaN or that a == b.""" + self.assertTrue((a is None and b is None) or + (numpy.isnan(a) and numpy.isnan(b)) or + a == b) + + def test_different_datasets(self): + """Test min_max with different numpy.arange datasets.""" + size = 1000 + + for dtype in self.DTYPES: + + tests = { + '0 to N': (0, 1), + 'N-1 to 0': (size - 1, -1)} + if dtype not in self.UNSIGNED_INT_DTYPES: + tests['N/2 to -N/2'] = size // 2, -1 + tests['0 to -N'] = 0, -1 + + for name, (start, step) in tests.items(): + for min_positive in (True, False): + with self.subTest(dtype=dtype, + min_positive=min_positive, + data=name): + data = numpy.arange( + start, start + step * size, step, dtype=dtype) + + self._test_min_max(data, min_positive) + + def test_nodata(self): + """Test min_max with None and empty array""" + for dtype in self.DTYPES: + with self.subTest(dtype=dtype): + with self.assertRaises(TypeError): + min_max(None) + + data = numpy.array((), dtype=dtype) + with self.assertRaises(ValueError): + min_max(data) + + NAN_TEST_DATA = [ + (float('nan'), float('nan')), # All NaNs + (float('nan'), 1.0), # NaN first and positive + (float('nan'), -1.0), # NaN first and negative + (1.0, 2.0, float('nan')), # NaN last and positive + (-1.0, -2.0, float('nan')), # NaN last and negative + (1.0, float('nan'), -1.0), # Some NaN + ] + + def test_nandata(self): + """Test min_max with NaN in data""" + for dtype in self.FLOATING_DTYPES: + for data in self.NAN_TEST_DATA: + with self.subTest(dtype=dtype, data=data): + data = numpy.array(data, dtype=dtype) + self._test_min_max(data, min_positive=True) + + INF_TEST_DATA = [ + [float('inf')] * 3, # All +inf + [float('-inf')] * 3, # All -inf + (float('inf'), float('-inf')), # + and - inf + (float('inf'), float('-inf'), float('nan')), # +/-inf, nan last + (float('nan'), float('-inf'), float('inf')), # +/-inf, nan first + (float('inf'), float('nan'), float('-inf')), # +/-inf, nan center + ] + + def test_infdata(self): + """Test min_max with inf.""" + for dtype in self.FLOATING_DTYPES: + for data in self.INF_TEST_DATA: + with self.subTest(dtype=dtype, data=data): + data = numpy.array(data, dtype=dtype) + self._test_min_max(data, min_positive=True) + + def test_finite(self): + """Test min_max with finite=True""" + tests = [ + (-1., 2., 0.), # Basic test + (float('nan'), float('inf'), float('-inf')), # NaN + Inf + (float('nan'), float('inf'), -2, float('-inf')), # NaN + Inf + 1 value + (float('inf'), -3, -2), # values + inf + ] + tests += self.INF_TEST_DATA + tests += self.NAN_TEST_DATA + + for dtype in self.FLOATING_DTYPES: + for data in tests: + with self.subTest(dtype=dtype, data=data): + data = numpy.array(data, dtype=dtype) + self._test_min_max(data, min_positive=True, finite=True) diff --git a/src/silx/math/test/test_histogramnd_error.py b/src/silx/math/test/test_histogramnd_error.py new file mode 100644 index 0000000..22304cb --- /dev/null +++ b/src/silx/math/test/test_histogramnd_error.py @@ -0,0 +1,519 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["D. Naudet"] +__license__ = "MIT" +__date__ = "01/02/2016" + +""" +Tests of the histogramnd function, error cases. +""" +import sys +import platform +import unittest + +import numpy as np + +from silx.math.chistogramnd import chistogramnd as histogramnd +from silx.math import Histogramnd + + +# ============================================================== +# ============================================================== +# ============================================================== + + +class _Test_chistogramnd_errors(unittest.TestCase): + """ + Unit tests of the chistogramnd error cases. + """ + __test__ = False # ignore abstract class + + def setUp(self): + self.skipTest("Abstract class") + + def test_weights_shape(self): + """ + """ + + for err_w_shape in self.err_weights_shapes: + test_msg = ('Testing invalid weights shape : {0}' + ''.format(err_w_shape)) + + err_weights = np.random.randint(0, + high=10, + size=err_w_shape) + err_weights = err_weights.astype(np.double) + + ex_str = None + try: + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=err_weights)[0:2] + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, + '<weights> must be an array whose length ' + 'is equal to the number of samples.') + + def test_histo_range_shape(self): + """ + """ + n_dims = 1 if len(self.s_shape) == 1 else self.s_shape[1] + expected_txt_tpl = ('<histo_range> error : expected {n_dims} sets ' + 'of lower and upper bin edges, ' + 'got the following instead : {histo_range}. ' + '(provided <sample> contains ' + '{n_dims}D values)') + + for err_histo_range in self.err_histo_range_shapes: + test_msg = ('Testing invalid histo_range shape : {0}' + ''.format(err_histo_range)) + + expected_txt = expected_txt_tpl.format(histo_range=err_histo_range, + n_dims=n_dims) + + ex_str = None + try: + histo, cumul = histogramnd(self.sample, + err_histo_range, + self.n_bins, + weights=self.weights)[0:2] + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, expected_txt, msg=test_msg) + + def test_nbins_shape(self): + """ + """ + + expected_txt = ('n_bins must be either a scalar (same number ' + 'of bins for all dimensions) or ' + 'an array (number of bins for each ' + 'dimension).') + + for err_n_bins in self.err_n_bins_shapes: + test_msg = ('Testing invalid n_bins shape : {0}' + ''.format(err_n_bins)) + + ex_str = None + try: + histo, cumul = histogramnd(self.sample, + self.histo_range, + err_n_bins, + weights=self.weights)[0:2] + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, expected_txt, msg=test_msg) + + def test_nbins_values(self): + """ + """ + expected_txt = ('<n_bins> : only positive values allowed.') + + for err_n_bins in self.err_n_bins_values: + test_msg = ('Testing invalid n_bins value : {0}' + ''.format(err_n_bins)) + + ex_str = None + try: + histo, cumul = histogramnd(self.sample, + self.histo_range, + err_n_bins, + weights=self.weights)[0:2] + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, expected_txt, msg=test_msg) + + def test_histo_shape(self): + """ + """ + for err_h_shape in self.err_histo_shapes: + + # windows & python 2.7 : numpy shapes are long values + if platform.system() == 'Windows': + version = (sys.version_info.major, sys.version_info.minor) + if version <= (2, 7): + err_h_shape = tuple([long(val) for val in err_h_shape]) + + test_msg = ('Testing invalid histo shape : {0}' + ''.format(err_h_shape)) + + expected_txt = ('Provided <histo> array doesn\'t have ' + 'a shape compatible with <n_bins> ' + ': should be {0} instead of {1}.' + ''.format(self.h_shape, err_h_shape)) + + histo = np.zeros(shape=err_h_shape, dtype=np.uint32) + + ex_str = None + try: + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + histo=histo)[0:2] + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, expected_txt, msg=test_msg) + + def test_histo_dtype(self): + """ + """ + for err_h_dtype in self.err_histo_dtypes: + test_msg = ('Testing invalid histo dtype : {0}' + ''.format(err_h_dtype)) + + histo = np.zeros(shape=self.h_shape, dtype=err_h_dtype) + + expected_txt = ('Provided <histo> array doesn\'t have ' + 'the expected type ' + ': should be {0} instead of {1}.' + ''.format(np.uint32, histo.dtype)) + + ex_str = None + try: + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + histo=histo)[0:2] + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, expected_txt, msg=test_msg) + + def test_weighted_histo_shape(self): + """ + """ + # using the same values as histo + for err_h_shape in self.err_histo_shapes: + + # windows & python 2.7 : numpy shapes are long values + if platform.system() == 'Windows': + version = (sys.version_info.major, sys.version_info.minor) + if version <= (2, 7): + err_h_shape = tuple([long(val) for val in err_h_shape]) + + test_msg = ('Testing invalid weighted_histo shape : {0}' + ''.format(err_h_shape)) + + expected_txt = ('Provided <weighted_histo> array doesn\'t have ' + 'a shape compatible with <n_bins> ' + ': should be {0} instead of {1}.' + ''.format(self.h_shape, err_h_shape)) + + cumul = np.zeros(shape=err_h_shape, dtype=np.double) + + ex_str = None + try: + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + weighted_histo=cumul)[0:2] + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, expected_txt, msg=test_msg) + + def test_cumul_dtype(self): + """ + """ + # using the same values as histo + for err_h_dtype in self.err_histo_dtypes: + test_msg = ('Testing invalid weighted_histo dtype : {0}' + ''.format(err_h_dtype)) + + cumul = np.zeros(shape=self.h_shape, dtype=err_h_dtype) + + expected_txt = ('Provided <weighted_histo> array doesn\'t have ' + 'the expected type ' + ': should be {0} or {1} instead of {2}.' + ''.format(np.float64, np.float32, cumul.dtype)) + + ex_str = None + try: + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + weighted_histo=cumul)[0:2] + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, expected_txt, msg=test_msg) + + def test_wh_histo_dtype(self): + """ + """ + # using the same values as histo + for err_h_dtype in self.err_histo_dtypes: + test_msg = ('Testing invalid wh_dtype dtype : {0}' + ''.format(err_h_dtype)) + + expected_txt = ('<wh_dtype> type not supported : {0}.' + ''.format(err_h_dtype)) + + ex_str = None + try: + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + wh_dtype=err_h_dtype)[0:2] + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, expected_txt, msg=test_msg) + + def test_unmanaged_dtypes(self): + """ + """ + for err_unmanaged_dtype in self.err_unmanaged_dtypes: + test_msg = ('Testing unmanaged dtypes : {0}' + ''.format(err_unmanaged_dtype)) + + sample = self.sample.astype(err_unmanaged_dtype[0]) + weights = self.weights.astype(err_unmanaged_dtype[1]) + + expected_txt = ('Case not supported - sample:{0} ' + 'and weights:{1}.' + ''.format(sample.dtype, + weights.dtype)) + + ex_str = None + try: + histogramnd(sample, + self.histo_range, + self.n_bins, + weights=weights) + except TypeError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str, msg=test_msg) + self.assertEqual(ex_str, expected_txt, msg=test_msg) + + def test_uncontiguous_histo(self): + """ + """ + # non contiguous array + shape = np.array(self.n_bins, ndmin=1) + shape[0] *= 2 + histo_tmp = np.zeros(shape) + histo = histo_tmp[::2, ...] + + expected_txt = ('<histo> must be a C_CONTIGUOUS numpy array.') + + ex_str = None + try: + histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + histo=histo) + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str) + self.assertEqual(ex_str, expected_txt) + + def test_uncontiguous_weighted_histo(self): + """ + """ + # non contiguous array + shape = np.array(self.n_bins, ndmin=1) + shape[0] *= 2 + cumul_tmp = np.zeros(shape) + cumul = cumul_tmp[::2, ...] + + expected_txt = ('<weighted_histo> must be a C_CONTIGUOUS numpy array.') + + ex_str = None + try: + histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + weighted_histo=cumul) + except ValueError as ex: + ex_str = str(ex) + + self.assertIsNotNone(ex_str) + self.assertEqual(ex_str, expected_txt) + + +class Test_chistogramnd_1D_errors(_Test_chistogramnd_errors): + """ + Unit tests of the 1D histogramnd error cases. + """ + __test__ = True # because _Test_chistogramnd_errors is ignored + + def setUp(self): + # nominal values + self.n_elements = 1000 + self.s_shape = (self.n_elements,) + self.w_shape = (self.n_elements,) + + self.histo_range = [0., 100.] + self.n_bins = 10 + + self.h_shape = (self.n_bins,) + + self.sample = np.random.randint(0, + high=10, + size=self.s_shape) + self.sample = self.sample.astype(np.double) + + self.weights = np.random.randint(0, + high=10, + size=self.w_shape) + self.weights = self.weights.astype(np.double) + + self.err_weights_shapes = ((self.n_elements+1,), + (self.n_elements-1,), + (self.n_elements-1, 3)) + self.err_histo_range_shapes = ([0.], + [0., 1., 2.], + [[0.], [1.]]) + self.err_n_bins_shapes = ([10, 2], + [[10], [2]]) + self.err_n_bins_values = (0, + [-10], + None) + self.err_histo_shapes = ((self.n_bins+1,), + (self.n_bins-1,), + (self.n_bins, self.n_bins)) + # these are used for testing the histo parameter as well + # as the weighted_histo parameter. + self.err_histo_dtypes = (np.uint16, + np.float16) + + self.err_unmanaged_dtypes = ((np.double, np.uint16), + (np.uint16, np.double), + (np.uint16, np.uint16)) + +class Test_chistogramnd_ND_range(unittest.TestCase): + """ + + """ + + def test_invalid_histo_range(self): + data = np.random.random((60, 60)) + nbins = 10 + + with self.assertRaises(ValueError): + histo_range = data.min(), np.inf + + Histogramnd(sample=data.ravel(), + histo_range=histo_range, + n_bins=nbins) + + histo_range = data.min(), np.nan + + Histogramnd(sample=data.ravel(), + histo_range=histo_range, + n_bins=nbins) + + +class Test_chistogramnd_ND_errors(_Test_chistogramnd_errors): + """ + Unit tests of the 3D histogramnd error cases. + """ + __test__ = True # because _Test_chistogramnd_errors is ignored + + def setUp(self): + # nominal values + self.n_elements = 1000 + self.s_shape = (self.n_elements, 3) + self.w_shape = (self.n_elements,) + + self.histo_range = [[0., 100.], [0., 100.], [0., 100.]] + self.n_bins = (10, 20, 30) + + self.h_shape = self.n_bins + + self.sample = np.random.randint(0, + high=10, + size=self.s_shape) + self.sample = self.sample.astype(np.double) + + self.weights = np.random.randint(0, + high=10, + size=self.w_shape) + self.weights = self.weights.astype(np.double) + + self.err_weights_shapes = ((self.n_elements+1,), + (self.n_elements-1,), + (self.n_elements-1, 3)) + self.err_histo_range_shapes = ([0.], + [0., 1.], + [[0., 10.], [0., 10.]], + [0., 10., 0, 10., 0, 10.]) + self.err_n_bins_shapes = ([10, 2], + [[10], [20], [30]]) + self.err_n_bins_values = (0, + [-10], + [10, 20, -4], + None, + [10, None, 30]) + self.err_histo_shapes = ((self.n_bins[0]+1, + self.n_bins[1], + self.n_bins[2]), + (self.n_bins[0], + self.n_bins[1], + self.n_bins[2]-1), + (self.n_bins[0], + self.n_bins[1]), + (self.n_bins[1], + self.n_bins[0], + self.n_bins[2]), + (self.n_bins[0], + self.n_bins[1], + self.n_bins[2], + 10) + ) + # these are used for testing the histo parameter as well + # as the weighted_histo parameter. + self.err_histo_dtypes = (np.uint16, + np.float16) + + self.err_unmanaged_dtypes = ((np.double, np.uint16), + (np.uint16, np.double), + (np.uint16, np.uint16)) diff --git a/src/silx/math/test/test_histogramnd_nominal.py b/src/silx/math/test/test_histogramnd_nominal.py new file mode 100644 index 0000000..031a772 --- /dev/null +++ b/src/silx/math/test/test_histogramnd_nominal.py @@ -0,0 +1,937 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Nominal tests of the histogramnd function. +""" + +import unittest +import pytest + +import numpy as np + +from silx.math.chistogramnd import chistogramnd as histogramnd +from silx.math import Histogramnd + + +def _get_bin_edges(histo_range, n_bins, n_dims): + edges = [] + for i_dim in range(n_dims): + edges.append(histo_range[i_dim, 0] + + np.arange(n_bins[i_dim] + 1) * + (histo_range[i_dim, 1] - histo_range[i_dim, 0]) / + n_bins[i_dim]) + return tuple(edges) + + +# ============================================================== +# ============================================================== +# ============================================================== + + +class _Test_chistogramnd_nominal(unittest.TestCase): + """ + Unit tests of the histogramnd function. + """ + __test__ = False # ignore abstract classe + + ndims = None + + def setUp(self): + if type(self).__name__.startswith("_"): + self.skipTest("Abstract class") + ndims = self.ndims + self.tested_dim = ndims-1 + + if ndims is None: + raise ValueError('ndims class member not set.') + + sample = np.array([5.5, -3.3, + 0., -0.5, + 3.3, 8.8, + -7.7, 6.0, + -4.0]) + + weights = np.array([500.5, -300.3, + 0.01, -0.5, + 300.3, 800.8, + -700.7, 600.6, + -400.4]) + + n_elems = len(sample) + + if ndims == 1: + shape = (n_elems,) + else: + shape = (n_elems, ndims) + + self.sample = np.zeros(shape=shape, dtype=sample.dtype) + if ndims == 1: + self.sample = sample + else: + self.sample[..., ndims-1] = sample + + self.weights = weights + + # the tests are performed along one dimension, + # all the other bins indices along the other dimensions + # are expected to be 2 + # (e.g : when testing a 2D sample : [0, x] will go into + # bin [2, y] because of the bin ranges [-2, 2] and n_bins = 4 + # for the first dimension) + self.other_axes_index = 2 + self.histo_range = np.repeat([[-2., 2.]], ndims, axis=0) + self.histo_range[ndims-1] = [-4., 6.] + + self.n_bins = np.array([4]*ndims) + self.n_bins[ndims-1] = 5 + + if ndims == 1: + def fill_histo(h, v, dim, op=None): + if op: + h[:] = op(h[:], v) + else: + h[:] = v + self.fill_histo = fill_histo + else: + def fill_histo(h, v, dim, op=None): + idx = [self.other_axes_index]*len(h.shape) + idx[dim] = slice(0, None) + idx = tuple(idx) + if op: + h[idx] = op(h[idx], v) + else: + h[idx] = v + self.fill_histo = fill_histo + + def test_nominal(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul, bin_edges = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights) + + expected_edges = _get_bin_edges(self.histo_range, + self.n_bins, + self.ndims) + + self.assertEqual(cumul.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + for i_edges, edges in enumerate(expected_edges): + self.assertTrue(np.array_equal(bin_edges[i_edges], + expected_edges[i_edges]), + msg='Testing bin_edges for dim {0}' + ''.format(i_edges+1)) + + def test_nominal_wh_dtype(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.float32) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul, bin_edges = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + wh_dtype=np.float32) + + self.assertEqual(cumul.dtype, np.float32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.allclose(cumul, expected_c)) + + def test_nominal_uncontiguous_sample(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + shape = list(self.sample.shape) + shape[0] *= 2 + sample = np.zeros(shape, dtype=self.sample.dtype) + uncontig_sample = sample[::2, ...] + uncontig_sample[:] = self.sample + + self.assertFalse(uncontig_sample.flags['C_CONTIGUOUS'], + msg='Making sure the array is not contiguous.') + + histo, cumul, bin_edges = histogramnd(uncontig_sample, + self.histo_range, + self.n_bins, + weights=self.weights) + + self.assertEqual(cumul.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + def test_nominal_uncontiguous_weights(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + shape = list(self.weights.shape) + shape[0] *= 2 + weights = np.zeros(shape, dtype=self.weights.dtype) + uncontig_weights = weights[::2, ...] + uncontig_weights[:] = self.weights + + self.assertFalse(uncontig_weights.flags['C_CONTIGUOUS'], + msg='Making sure the array is not contiguous.') + + histo, cumul, bin_edges = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=uncontig_weights) + + self.assertEqual(cumul.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + def test_nominal_wo_weights(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=None)[0:2] + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(cumul is None) + + def test_nominal_wo_weights_w_cumul(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + + # creating an array of ones just to make sure that + # it is not cleared by histogramnd + cumul_in = np.ones(self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=None, + weighted_histo=cumul_in)[0:2] + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(cumul is None) + self.assertTrue(np.array_equal(cumul_in, + np.ones(shape=self.n_bins, + dtype=np.double))) + + def test_nominal_wo_weights_w_histo(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + + # creating an array of ones just to make sure that + # it is not cleared by histogramnd + histo_in = np.ones(self.n_bins, dtype=np.uint32) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=None, + histo=histo_in)[0:2] + + self.assertTrue(np.array_equal(histo, expected_h + 1)) + self.assertTrue(cumul is None) + self.assertEqual(id(histo), id(histo_in)) + + def test_nominal_last_bin_closed(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 2]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 1101.1]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True)[0:2] + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + def test_int32_weights_double_weights_range(self): + """ + """ + weight_min = -299.9 # ===> will be cast to -299 + weight_max = 499.9 # ===> will be cast to 499 + + expected_h_tpl = np.array([0, 1, 1, 1, 0]) + expected_c_tpl = np.array([0., 0., 0., 300., 0.]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights.astype(np.int32), + weight_min=weight_min, + weight_max=weight_max)[0:2] + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + def test_reuse_histo(self): + """ + """ + + expected_h_tpl = np.array([2, 3, 2, 2, 2]) + expected_c_tpl = np.array([0.0, -7007, -5.0, 0.1, 3003.0]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights)[0:2] + + sample_2 = self.sample[:] + if len(sample_2.shape) == 1: + idx = (slice(0, None),) + else: + idx = slice(0, None), self.tested_dim + + sample_2[idx] += 2 + + histo_2, cumul = histogramnd(sample_2, # <==== !! + self.histo_range, + self.n_bins, + weights=10 * self.weights, # <==== !! + histo=histo)[0:2] + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + self.assertEqual(id(histo), id(histo_2)) + + def test_reuse_cumul(self): + """ + """ + + expected_h_tpl = np.array([0, 2, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -7007.5, -4.99, 300.4, 3503.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights)[0:2] + + sample_2 = self.sample[:] + if len(sample_2.shape) == 1: + idx = (slice(0, None),) + else: + idx = slice(0, None), self.tested_dim + + sample_2[idx] += 2 + + histo, cumul_2 = histogramnd(sample_2, # <==== !! + self.histo_range, + self.n_bins, + weights=10 * self.weights, # <==== !! + weighted_histo=cumul)[0:2] + + self.assertEqual(cumul.dtype, np.float64) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.allclose(cumul, expected_c, rtol=10e-15)) + self.assertEqual(id(cumul), id(cumul_2)) + + def test_reuse_cumul_float(self): + """ + """ + + expected_h_tpl = np.array([0, 2, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -7007.5, -4.99, 300.4, 3503.5], + dtype=np.float32) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights)[0:2] + + # converting the cumul array to float + cumul = cumul.astype(np.float32) + + sample_2 = self.sample[:] + if len(sample_2.shape) == 1: + idx = (slice(0, None),) + else: + idx = slice(0, None), self.tested_dim + + sample_2[idx] += 2 + + histo, cumul_2 = histogramnd(sample_2, # <==== !! + self.histo_range, + self.n_bins, + weights=10 * self.weights, # <==== !! + weighted_histo=cumul)[0:2] + + self.assertEqual(cumul.dtype, np.float32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertEqual(id(cumul), id(cumul_2)) + self.assertTrue(np.allclose(cumul, expected_c, rtol=10e-15)) + +class _Test_Histogramnd_nominal(unittest.TestCase): + """ + Unit tests of the Histogramnd class. + """ + __test__ = False # ignore abstract class + + ndims = None + + def setUp(self): + ndims = self.ndims + if ndims is None: + self.skipTest("Abstract class") + self.tested_dim = ndims-1 + + if ndims is None: + raise ValueError('ndims class member not set.') + + sample = np.array([5.5, -3.3, + 0., -0.5, + 3.3, 8.8, + -7.7, 6.0, + -4.0]) + + weights = np.array([500.5, -300.3, + 0.01, -0.5, + 300.3, 800.8, + -700.7, 600.6, + -400.4]) + + n_elems = len(sample) + + if ndims == 1: + shape = (n_elems,) + else: + shape = (n_elems, ndims) + + self.sample = np.zeros(shape=shape, dtype=sample.dtype) + if ndims == 1: + self.sample = sample + else: + self.sample[..., ndims-1] = sample + + self.weights = weights + + # the tests are performed along one dimension, + # all the other bins indices along the other dimensions + # are expected to be 2 + # (e.g : when testing a 2D sample : [0, x] will go into + # bin [2, y] because of the bin ranges [-2, 2] and n_bins = 4 + # for the first dimension) + self.other_axes_index = 2 + self.histo_range = np.repeat([[-2., 2.]], ndims, axis=0) + self.histo_range[ndims-1] = [-4., 6.] + + self.n_bins = np.array([4]*ndims) + self.n_bins[ndims-1] = 5 + + if ndims == 1: + def fill_histo(h, v, dim, op=None): + if op: + h[:] = op(h[:], v) + else: + h[:] = v + self.fill_histo = fill_histo + else: + def fill_histo(h, v, dim, op=None): + idx = [self.other_axes_index]*len(h.shape) + idx[dim] = slice(0, None) + idx = tuple(idx) + if op: + h[idx] = op(h[idx], v) + else: + h[idx] = v + self.fill_histo = fill_histo + + def test_nominal(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo = Histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights) + + histo, cumul, bin_edges = histo + + expected_edges = _get_bin_edges(self.histo_range, + self.n_bins, + self.ndims) + + self.assertEqual(cumul.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + for i_edges, edges in enumerate(expected_edges): + self.assertTrue(np.array_equal(bin_edges[i_edges], + expected_edges[i_edges]), + msg='Testing bin_edges for dim {0}' + ''.format(i_edges+1)) + + def test_nominal_wh_dtype(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.float32) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul, bin_edges = Histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + wh_dtype=np.float32) + + self.assertEqual(cumul.dtype, np.float32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.allclose(cumul, expected_c)) + + def test_nominal_uncontiguous_sample(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + shape = list(self.sample.shape) + shape[0] *= 2 + sample = np.zeros(shape, dtype=self.sample.dtype) + uncontig_sample = sample[::2, ...] + uncontig_sample[:] = self.sample + + self.assertFalse(uncontig_sample.flags['C_CONTIGUOUS'], + msg='Making sure the array is not contiguous.') + + histo, cumul, bin_edges = Histogramnd(uncontig_sample, + self.histo_range, + self.n_bins, + weights=self.weights) + + self.assertEqual(cumul.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + def test_nominal_uncontiguous_weights(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + shape = list(self.weights.shape) + shape[0] *= 2 + weights = np.zeros(shape, dtype=self.weights.dtype) + uncontig_weights = weights[::2, ...] + uncontig_weights[:] = self.weights + + self.assertFalse(uncontig_weights.flags['C_CONTIGUOUS'], + msg='Making sure the array is not contiguous.') + + histo, cumul, bin_edges = Histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=uncontig_weights) + + self.assertEqual(cumul.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + def test_nominal_wo_weights(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + + histo, cumul = Histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=None)[0:2] + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(cumul is None) + + def test_nominal_last_bin_closed(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 2]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 1101.1]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul = Histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True)[0:2] + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + def test_int32_weights_double_weights_range(self): + """ + """ + weight_min = -299.9 # ===> will be cast to -299 + weight_max = 499.9 # ===> will be cast to 499 + + expected_h_tpl = np.array([0, 1, 1, 1, 0]) + expected_c_tpl = np.array([0., 0., 0., 300., 0.]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo, cumul = Histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights.astype(np.int32), + weight_min=weight_min, + weight_max=weight_max)[0:2] + + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + def test_nominal_no_sample(self): + """ + """ + + histo_inst = Histogramnd(None, + self.histo_range, + self.n_bins) + + histo, weighted_histo, edges = histo_inst + + self.assertIsNone(histo) + self.assertIsNone(weighted_histo) + self.assertIsNone(edges) + self.assertIsNone(histo_inst.histo) + self.assertIsNone(histo_inst.weighted_histo) + self.assertIsNone(histo_inst.edges) + + def test_empty_init_accumulate(self): + """ + """ + expected_h_tpl = np.array([2, 1, 1, 1, 1]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo_inst = Histogramnd(None, + self.histo_range, + self.n_bins) + + histo_inst.accumulate(self.sample, + weights=self.weights) + + histo = histo_inst.histo + cumul = histo_inst.weighted_histo + bin_edges = histo_inst.edges + + expected_edges = _get_bin_edges(self.histo_range, + self.n_bins, + self.ndims) + + self.assertEqual(cumul.dtype, np.float64) + self.assertEqual(histo.dtype, np.uint32) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + for i_edges, edges in enumerate(expected_edges): + self.assertTrue(np.array_equal(bin_edges[i_edges], + expected_edges[i_edges]), + msg='Testing bin_edges for dim {0}' + ''.format(i_edges+1)) + + def test_accumulate(self): + """ + """ + + expected_h_tpl = np.array([2, 3, 2, 2, 2]) + expected_c_tpl = np.array([-700.7, -7007.5, -4.99, 300.4, 3503.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo_inst = Histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights) + + sample_2 = self.sample[:] + if len(sample_2.shape) == 1: + idx = (slice(0, None),) + else: + idx = slice(0, None), self.tested_dim + + sample_2[idx] += 2 + + histo_inst.accumulate(sample_2, # <==== !! + weights=10 * self.weights) # <==== !! + + histo = histo_inst.histo + cumul = histo_inst.weighted_histo + bin_edges = histo_inst.edges + + self.assertEqual(cumul.dtype, np.float64) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.allclose(cumul, expected_c, rtol=10e-15)) + + def test_accumulate_no_weights(self): + """ + """ + + expected_h_tpl = np.array([2, 3, 2, 2, 2]) + expected_c_tpl = np.array([-700.7, -0.5, 0.01, 300.3, 500.5]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo_inst = Histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights) + + sample_2 = self.sample[:] + if len(sample_2.shape) == 1: + idx = (slice(0, None),) + else: + idx = slice(0, None), self.tested_dim + + sample_2[idx] += 2 + + histo_inst.accumulate(sample_2) # <==== !! + + histo = histo_inst.histo + cumul = histo_inst.weighted_histo + bin_edges = histo_inst.edges + + self.assertEqual(cumul.dtype, np.float64) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.allclose(cumul, expected_c, rtol=10e-15)) + + def test_accumulate_no_weights_at_init(self): + """ + """ + + expected_h_tpl = np.array([2, 3, 2, 2, 2]) + expected_c_tpl = np.array([0.0, -700.7, -0.5, 0.01, 300.3]) + + expected_h = np.zeros(shape=self.n_bins, dtype=np.double) + expected_c = np.zeros(shape=self.n_bins, dtype=np.double) + + self.fill_histo(expected_h, expected_h_tpl, self.ndims-1) + self.fill_histo(expected_c, expected_c_tpl, self.ndims-1) + + histo_inst = Histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=None) # <==== !! + + cumul = histo_inst.weighted_histo + self.assertIsNone(cumul) + + sample_2 = self.sample[:] + if len(sample_2.shape) == 1: + idx = (slice(0, None),) + else: + idx = slice(0, None), self.tested_dim + + sample_2[idx] += 2 + + histo_inst.accumulate(sample_2, + weights=self.weights) # <==== !! + + histo = histo_inst.histo + cumul = histo_inst.weighted_histo + bin_edges = histo_inst.edges + + self.assertEqual(cumul.dtype, np.float64) + self.assertTrue(np.array_equal(histo, expected_h)) + self.assertTrue(np.array_equal(cumul, expected_c)) + + def testNoneNativeTypes(self): + type = self.sample.dtype.newbyteorder("B") + sampleB = self.sample.astype(type) + + type = self.sample.dtype.newbyteorder("L") + sampleL = self.sample.astype(type) + + histo_inst = Histogramnd(sampleB, + self.histo_range, + self.n_bins, + weights=self.weights) + + histo_inst = Histogramnd(sampleL, + self.histo_range, + self.n_bins, + weights=self.weights) + + +class Test_chistogram_nominal_1d(_Test_chistogramnd_nominal): + __test__ = True # because _Test_chistogramnd_nominal is ignored + ndims = 1 + + +class Test_chistogram_nominal_2d(_Test_chistogramnd_nominal): + __test__ = True # because _Test_chistogramnd_nominal is ignored + ndims = 2 + + +class Test_chistogram_nominal_3d(_Test_chistogramnd_nominal): + __test__ = True # because _Test_chistogramnd_nominal is ignored + ndims = 3 + + +class Test_Histogramnd_nominal_1d(_Test_Histogramnd_nominal): + __test__ = True # because _Test_chistogramnd_nominal is ignored + ndims = 1 + + +class Test_Histogramnd_nominal_2d(_Test_Histogramnd_nominal): + __test__ = True # because _Test_chistogramnd_nominal is ignored + ndims = 2 + + +class Test_Histogramnd_nominal_3d(_Test_Histogramnd_nominal): + __test__ = True # because _Test_chistogramnd_nominal is ignored + ndims = 3 diff --git a/src/silx/math/test/test_histogramnd_vs_np.py b/src/silx/math/test/test_histogramnd_vs_np.py new file mode 100644 index 0000000..d6a8d19 --- /dev/null +++ b/src/silx/math/test/test_histogramnd_vs_np.py @@ -0,0 +1,826 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Tests for the histogramnd function. +Results are compared to numpy's histogramdd. +""" + +import unittest +import operator + +import numpy as np + +from silx.math.chistogramnd import chistogramnd as histogramnd + +# ============================================================== +# ============================================================== +# ============================================================== + +_RTOL_DICT = {np.float64: 10**-13, + np.float32: 10**-5} + +# ============================================================== +# ============================================================== +# ============================================================== + + +def _add_values_to_array_if_missing(array, values, n_values): + max_in_col = np.any(array[:, ...] == values, axis=0) + + if len(array.shape) == 1: + if not max_in_col: + rnd_idx = np.random.randint(0, + high=len(array)-1, + size=(n_values,)) + array[rnd_idx] = values + else: + for i in range(len(max_in_col)): + if not max_in_col[i]: + rnd_idx = np.random.randint(0, + high=len(array)-1, + size=(n_values,)) + array[rnd_idx, i] = values[i] + + +def _get_values_index(array, values, op=operator.lt): + idx = op(array[:, ...], values) + if array.ndim > 1: + idx = np.all(idx, axis=1) + return np.where(idx)[0] + + +def _get_in_range_indices(array, + minvalues, + maxvalues, + minop=operator.ge, + maxop=operator.lt): + idx = np.logical_and(minop(array, minvalues), + maxop(array, maxvalues)) + if array.ndim > 1: + idx = np.all(idx, axis=1) + return np.where(idx)[0] + + +class _TestHistogramnd(unittest.TestCase): + """ + Unit tests of the histogramnd function. + """ + __test__ = False # ignore abstract class + + sample_rng = None + weights_rng = None + n_dims = None + + filter_min = None + filter_max = None + + histo_range = None + n_bins = None + + dtype_sample = None + dtype_weights = None + + def generate_data(self): + + self.longMessage = True + + int_min = 0 + int_max = 100000 + n_elements = 10**5 + + if self.n_dims == 1: + shape = (n_elements,) + else: + shape = (n_elements, self.n_dims,) + + self.rng_state = np.random.get_state() + + self.state_msg = ('Current RNG state :\n' + '{0}'.format(self.rng_state)) + + sample = np.random.randint(int_min, + high=int_max, + size=shape) + + sample = sample.astype(self.dtype_sample) + sample = (self.sample_rng[0] + + (sample-int_min) * + (self.sample_rng[1]-self.sample_rng[0]) / + (int_max-int_min)).astype(self.dtype_sample) + + weights = np.random.randint(int_min, + high=int_max, + size=(n_elements,)) + weights = weights.astype(self.dtype_weights) + weights = (self.weights_rng[0] + + (weights-int_min) * + (self.weights_rng[1]-self.weights_rng[0]) / + (int_max-int_min)).astype(self.dtype_weights) + + # !!!!!!!!!!!!!!!!!!!!!!!!!!!! + # !!!!!!!!!!!!!!!!!!!!!!!!!!!! + # the bins range are cast to the same type as the sample + # in order to get the same results as numpy + # (which doesnt cast the range) + self.histo_range = np.array(self.histo_range).astype(self.dtype_sample) + + # adding some values that are equal to the max + # in order to test the opened/closed last bin + bins_max = [b[1] for b in self.histo_range] + _add_values_to_array_if_missing(sample, + bins_max, + 100) + + # adding some values that are equal to the min weight value + # in order to test the filters + _add_values_to_array_if_missing(weights, + self.weights_rng[0], + 100) + + # adding some values that are equal to the max weight value + # in order to test the filters + _add_values_to_array_if_missing(weights, + self.weights_rng[1], + 100) + + return sample, weights + + def setUp(self): + if type(self).__name__.startswith("_"): + self.skipTest("Abstract class") + self.sample, self.weights = self.generate_data() + self.rtol = _RTOL_DICT.get(self.dtype_weights, None) + + def array_compare(self, ar_a, ar_b): + if self.rtol is None: + return np.array_equal(ar_a, ar_b) + return np.allclose(ar_a, ar_b, self.rtol) + + def test_bin_ranges(self): + """ + + """ + result_c = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True) + + result_np = np.histogramdd(self.sample, + bins=self.n_bins, + range=self.histo_range) + + for i_edges, edges in enumerate(result_c[2]): + # allclose for now until I can try with the latest version (TBD) + # of numpy + self.assertTrue(np.allclose(edges, + result_np[1][i_edges]), + msg='{0}. Testing bin_edges for dim {1}.' + ''.format(self.state_msg, i_edges+1)) + + def test_last_bin_closed(self): + """ + + """ + result_c = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True) + + result_np = np.histogramdd(self.sample, + bins=self.n_bins, + range=self.histo_range) + + result_np_w = np.histogramdd(self.sample, + bins=self.n_bins, + range=self.histo_range, + weights=self.weights) + + # comparing "hits" + hits_cmp = np.array_equal(result_c[0], + result_np[0]) + # comparing weights + weights_cmp = np.array_equal(result_c[1], + result_np_w[0]) + + self.assertTrue(hits_cmp, msg=self.state_msg) + self.assertTrue(weights_cmp, msg=self.state_msg) + + bins_min = [rng[0] for rng in self.histo_range] + bins_max = [rng[1] for rng in self.histo_range] + inrange_idx = _get_in_range_indices(self.sample, + bins_min, + bins_max, + minop=operator.ge, + maxop=operator.le) + + self.assertEqual(result_c[0].sum(), inrange_idx.shape[0], + msg=self.state_msg) + + # we have to sum the weights using the same precision as the + # histogramnd function + weights_sum = self.weights[inrange_idx].astype(result_c[1].dtype).sum() + self.assertTrue(self.array_compare(result_c[1].sum(), weights_sum), + msg=self.state_msg) + + def test_last_bin_open(self): + """ + + """ + result_c = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=False) + + bins_max = [rng[1] for rng in self.histo_range] + filtered_idx = _get_values_index(self.sample, bins_max) + + result_np = np.histogramdd(self.sample[filtered_idx], + bins=self.n_bins, + range=self.histo_range) + + result_np_w = np.histogramdd(self.sample[filtered_idx], + bins=self.n_bins, + range=self.histo_range, + weights=self.weights[filtered_idx]) + + # comparing "hits" + hits_cmp = np.array_equal(result_c[0], result_np[0]) + # comparing weights + weights_cmp = np.array_equal(result_c[1], + result_np_w[0]) + + self.assertTrue(hits_cmp, msg=self.state_msg) + self.assertTrue(weights_cmp, msg=self.state_msg) + + bins_min = [rng[0] for rng in self.histo_range] + bins_max = [rng[1] for rng in self.histo_range] + inrange_idx = _get_in_range_indices(self.sample, + bins_min, + bins_max, + minop=operator.ge, + maxop=operator.lt) + + self.assertEqual(result_c[0].sum(), len(inrange_idx), + msg=self.state_msg) + # we have to sum the weights using the same precision as the + # histogramnd function + weights_sum = self.weights[inrange_idx].astype(result_c[1].dtype).sum() + self.assertTrue(self.array_compare(result_c[1].sum(), weights_sum), + msg=self.state_msg) + + def test_filter_min(self): + """ + + """ + result_c = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True, + weight_min=self.filter_min) + + # !!!!!!!!!!!!!!!!!!!!!!!!!!!! + filter_min = self.dtype_weights(self.filter_min) + + weight_idx = _get_values_index(self.weights, + filter_min, # <------ !!! + operator.ge) + + result_np = np.histogramdd(self.sample[weight_idx], + bins=self.n_bins, + range=self.histo_range) + + result_np_w = np.histogramdd(self.sample[weight_idx], + bins=self.n_bins, + range=self.histo_range, + weights=self.weights[weight_idx]) + + # comparing "hits" + hits_cmp = np.array_equal(result_c[0], + result_np[0]) + # comparing weights + weights_cmp = np.array_equal(result_c[1], result_np_w[0]) + + self.assertTrue(hits_cmp, msg=self.state_msg) + self.assertTrue(weights_cmp, msg=self.state_msg) + + bins_min = [rng[0] for rng in self.histo_range] + bins_max = [rng[1] for rng in self.histo_range] + inrange_idx = _get_in_range_indices(self.sample[weight_idx], + bins_min, + bins_max, + minop=operator.ge, + maxop=operator.le) + + inrange_idx = weight_idx[inrange_idx] + + self.assertEqual(result_c[0].sum(), len(inrange_idx), + msg=self.state_msg) + + # we have to sum the weights using the same precision as the + # histogramnd function + weights_sum = self.weights[inrange_idx].astype(result_c[1].dtype).sum() + self.assertTrue(self.array_compare(result_c[1].sum(), weights_sum), + msg=self.state_msg) + + def test_filter_max(self): + """ + + """ + result_c = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True, + weight_max=self.filter_max) + + # !!!!!!!!!!!!!!!!!!!!!!!!!!!! + filter_max = self.dtype_weights(self.filter_max) + + weight_idx = _get_values_index(self.weights, + filter_max, # <------ !!! + operator.le) + + result_np = np.histogramdd(self.sample[weight_idx], + bins=self.n_bins, + range=self.histo_range) + + result_np_w = np.histogramdd(self.sample[weight_idx], + bins=self.n_bins, + range=self.histo_range, + weights=self.weights[weight_idx]) + + # comparing "hits" + hits_cmp = np.array_equal(result_c[0], + result_np[0]) + # comparing weights + weights_cmp = np.array_equal(result_c[1], result_np_w[0]) + + self.assertTrue(hits_cmp, msg=self.state_msg) + self.assertTrue(weights_cmp, msg=self.state_msg) + + bins_min = [rng[0] for rng in self.histo_range] + bins_max = [rng[1] for rng in self.histo_range] + inrange_idx = _get_in_range_indices(self.sample[weight_idx], + bins_min, + bins_max, + minop=operator.ge, + maxop=operator.le) + + inrange_idx = weight_idx[inrange_idx] + + self.assertEqual(result_c[0].sum(), len(inrange_idx), + msg=self.state_msg) + + # we have to sum the weights using the same precision as the + # histogramnd function + weights_sum = self.weights[inrange_idx].astype(result_c[1].dtype).sum() + self.assertTrue(self.array_compare(result_c[1].sum(), weights_sum), + msg=self.state_msg) + + def test_filter_minmax(self): + """ + + """ + result_c = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True, + weight_min=self.filter_min, + weight_max=self.filter_max) + + # !!!!!!!!!!!!!!!!!!!!!!!!!!!! + filter_min = self.dtype_weights(self.filter_min) + filter_max = self.dtype_weights(self.filter_max) + + weight_idx = _get_in_range_indices(self.weights, + filter_min, # <------ !!! + filter_max, # <------ !!! + minop=operator.ge, + maxop=operator.le) + + result_np = np.histogramdd(self.sample[weight_idx], + bins=self.n_bins, + range=self.histo_range) + + result_np_w = np.histogramdd(self.sample[weight_idx], + bins=self.n_bins, + range=self.histo_range, + weights=self.weights[weight_idx]) + + # comparing "hits" + hits_cmp = np.array_equal(result_c[0], + result_np[0]) + # comparing weights + weights_cmp = np.array_equal(result_c[1], result_np_w[0]) + + self.assertTrue(hits_cmp) + self.assertTrue(weights_cmp) + + bins_min = [rng[0] for rng in self.histo_range] + bins_max = [rng[1] for rng in self.histo_range] + inrange_idx = _get_in_range_indices(self.sample[weight_idx], + bins_min, + bins_max, + minop=operator.ge, + maxop=operator.le) + + inrange_idx = weight_idx[inrange_idx] + + self.assertEqual(result_c[0].sum(), len(inrange_idx), + msg=self.state_msg) + + # we have to sum the weights using the same precision as the + # histogramnd function + weights_sum = self.weights[inrange_idx].astype(result_c[1].dtype).sum() + self.assertTrue(self.array_compare(result_c[1].sum(), weights_sum), + msg=self.state_msg) + + def test_reuse_histo(self): + """ + + """ + result_c_1 = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True) + + result_np_1 = np.histogramdd(self.sample, + bins=self.n_bins, + range=self.histo_range) + + np.histogramdd(self.sample, + bins=self.n_bins, + range=self.histo_range, + weights=self.weights) + + sample_2, weights_2 = self.generate_data() + + result_c_2 = histogramnd(sample_2, + self.histo_range, + self.n_bins, + weights=weights_2, + last_bin_closed=True, + histo=result_c_1[0]) + + result_np_2 = np.histogramdd(sample_2, + bins=self.n_bins, + range=self.histo_range) + + result_np_w_2 = np.histogramdd(sample_2, + bins=self.n_bins, + range=self.histo_range, + weights=weights_2) + + # comparing "hits" + hits_cmp = np.array_equal(result_c_2[0], + result_np_1[0] + + result_np_2[0]) + # comparing weights + weights_cmp = np.array_equal(result_c_2[1], + result_np_w_2[0]) + + self.assertTrue(hits_cmp, msg=self.state_msg) + self.assertTrue(weights_cmp, msg=self.state_msg) + + def test_reuse_cumul(self): + """ + + """ + result_c = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True) + + np.histogramdd(self.sample, + bins=self.n_bins, + range=self.histo_range) + + result_np_w = np.histogramdd(self.sample, + bins=self.n_bins, + range=self.histo_range, + weights=self.weights) + + sample_2, weights_2 = self.generate_data() + + result_c_2 = histogramnd(sample_2, + self.histo_range, + self.n_bins, + weights=weights_2, + last_bin_closed=True, + weighted_histo=result_c[1]) + + result_np_2 = np.histogramdd(sample_2, + bins=self.n_bins, + range=self.histo_range) + + result_np_w_2 = np.histogramdd(sample_2, + bins=self.n_bins, + range=self.histo_range, + weights=weights_2) + + # comparing "hits" + hits_cmp = np.array_equal(result_c_2[0], + result_np_2[0]) + # comparing weights + + self.assertTrue(hits_cmp, msg=self.state_msg) + self.assertTrue(self.array_compare(result_c_2[1], + result_np_w[0] + result_np_w_2[0]), + msg=self.state_msg) + + def test_reuse_cumul_float(self): + """ + + """ + n_bins = np.array(self.n_bins, ndmin=1) + if len(self.sample.shape) == 2: + if len(n_bins) == self.sample.shape[1]: + shp = tuple([x for x in n_bins]) + else: + shp = (self.n_bins,) * self.sample.shape[1] + cumul = np.zeros(shp, dtype=np.float32) + else: + shp = (self.n_bins,) + cumul = np.zeros(shp, dtype=np.float32) + + result_c_1 = histogramnd(self.sample, + self.histo_range, + self.n_bins, + weights=self.weights, + last_bin_closed=True, + weighted_histo=cumul) + + result_np_1 = np.histogramdd(self.sample, + bins=self.n_bins, + range=self.histo_range) + + result_np_w_1 = np.histogramdd(self.sample, + bins=self.n_bins, + range=self.histo_range, + weights=self.weights) + + # comparing "hits" + hits_cmp = np.array_equal(result_c_1[0], + result_np_1[0]) + + self.assertTrue(hits_cmp, msg=self.state_msg) + self.assertEqual(result_c_1[1].dtype, np.float32, msg=self.state_msg) + + bins_min = [rng[0] for rng in self.histo_range] + bins_max = [rng[1] for rng in self.histo_range] + inrange_idx = _get_in_range_indices(self.sample, + bins_min, + bins_max, + minop=operator.ge, + maxop=operator.le) + weights_sum = \ + self.weights[inrange_idx].astype(np.float32).sum(dtype=np.float64) + self.assertTrue(np.allclose(result_c_1[1].sum(dtype=np.float64), + weights_sum), msg=self.state_msg) + self.assertTrue(np.allclose(result_c_1[1].sum(dtype=np.float64), + result_np_w_1[0].sum(dtype=np.float64)), + msg=self.state_msg) + + +class _TestHistogramnd_1d(_TestHistogramnd): + """ + Unit tests of the 1D histogramnd function. + """ + sample_rng = [-55., 100.] + weights_rng = [-70., 150.] + n_dims = 1 + filter_min = -15.6 + filter_max = 85.7 + + histo_range = [[-30.2, 90.3]] + n_bins = 30 + + dtype = None + + +class _TestHistogramnd_2d(_TestHistogramnd): + """ + Unit tests of the 1D histogramnd function. + """ + sample_rng = [-50.2, 100.99] + weights_rng = [70., 150.] + n_dims = 2 + filter_min = 81.7 + filter_max = 135.3 + + histo_range = [[10., 90.], [20., 70.]] + n_bins = 30 + + dtype = None + + +class _TestHistogramnd_3d(_TestHistogramnd): + """ + Unit tests of the 1D histogramnd function. + """ + sample_rng = [10.2, 200.9] + weights_rng = [0., 100.] + n_dims = 3 + filter_min = 31.5 + filter_max = 83.7 + + histo_range = [[30.8, 150.2], [20.1, 90.9], [10.1, 195.]] + n_bins = 30 + + dtype = None + + +# ################################################################ +# ################################################################ +# ################################################################ +# ################################################################ + + +class TestHistogramnd_1d_double_double(_TestHistogramnd_1d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.double + dtype_weights = np.double + + +class TestHistogramnd_1d_double_float(_TestHistogramnd_1d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.double + dtype_weights = np.float32 + + +class TestHistogramnd_1d_double_int32(_TestHistogramnd_1d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.double + dtype_weights = np.int32 + + +class TestHistogramnd_1d_float_double(_TestHistogramnd_1d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.float32 + dtype_weights = np.double + + +class TestHistogramnd_1d_float_float(_TestHistogramnd_1d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.float32 + dtype_weights = np.float32 + + +class TestHistogramnd_1d_float_int32(_TestHistogramnd_1d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.float32 + dtype_weights = np.int32 + + +class TestHistogramnd_1d_int32_double(_TestHistogramnd_1d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.int32 + dtype_weights = np.double + + +class TestHistogramnd_1d_int32_float(_TestHistogramnd_1d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.int32 + dtype_weights = np.float32 + + +class TestHistogramnd_1d_int32_int32(_TestHistogramnd_1d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.int32 + dtype_weights = np.int32 + + +class TestHistogramnd_2d_double_double(_TestHistogramnd_2d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.double + dtype_weights = np.double + + +class TestHistogramnd_2d_double_float(_TestHistogramnd_2d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.double + dtype_weights = np.float32 + + +class TestHistogramnd_2d_double_int32(_TestHistogramnd_2d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.double + dtype_weights = np.int32 + + +class TestHistogramnd_2d_float_double(_TestHistogramnd_2d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.float32 + dtype_weights = np.double + + +class TestHistogramnd_2d_float_float(_TestHistogramnd_2d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.float32 + dtype_weights = np.float32 + + +class TestHistogramnd_2d_float_int32(_TestHistogramnd_2d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.float32 + dtype_weights = np.int32 + + +class TestHistogramnd_2d_int32_double(_TestHistogramnd_2d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.int32 + dtype_weights = np.double + + +class TestHistogramnd_2d_int32_float(_TestHistogramnd_2d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.int32 + dtype_weights = np.float32 + + +class TestHistogramnd_2d_int32_int32(_TestHistogramnd_2d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.int32 + dtype_weights = np.int32 + + +class TestHistogramnd_3d_double_double(_TestHistogramnd_3d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.double + dtype_weights = np.double + + +class TestHistogramnd_3d_double_float(_TestHistogramnd_3d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.double + dtype_weights = np.float32 + + +class TestHistogramnd_3d_double_int32(_TestHistogramnd_3d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.double + dtype_weights = np.int32 + + +class TestHistogramnd_3d_float_double(_TestHistogramnd_3d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.float32 + dtype_weights = np.double + + +class TestHistogramnd_3d_float_float(_TestHistogramnd_3d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.float32 + dtype_weights = np.float32 + + +class TestHistogramnd_3d_float_int32(_TestHistogramnd_3d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.float32 + dtype_weights = np.int32 + + +class TestHistogramnd_3d_int32_double(_TestHistogramnd_3d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.int32 + dtype_weights = np.double + + +class TestHistogramnd_3d_int32_float(_TestHistogramnd_3d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.int32 + dtype_weights = np.float32 + + +class TestHistogramnd_3d_int32_int32(_TestHistogramnd_3d): + __test__ = True # because _TestHistogramnd is ignored + dtype_sample = np.int32 + dtype_weights = np.int32 diff --git a/src/silx/math/test/test_interpolate.py b/src/silx/math/test/test_interpolate.py new file mode 100644 index 0000000..146449d --- /dev/null +++ b/src/silx/math/test/test_interpolate.py @@ -0,0 +1,125 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Test for interpolate module""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "11/07/2019" + + +import unittest + +import numpy +try: + from scipy.interpolate import interpn +except ImportError: + interpn = None + +from silx.utils.testutils import ParametricTestCase +from silx.math import interpolate + + +@unittest.skipUnless(interpn is not None, "scipy missing") +class TestInterp3d(ParametricTestCase): + """Test silx.math.interpolate.interp3d""" + + @staticmethod + def ref_interp3d(data, points): + """Reference implementation of interp3d based on scipy + + :param numpy.ndarray data: 3D floating dataset + :param numpy.ndarray points: Array of points of shape (N, 3) + """ + return interpn( + [numpy.arange(dim, dtype=data.dtype) for dim in data.shape], + data, + points, + method='linear') + + def test_random_data(self): + """Test interp3d with random data""" + size = 32 + npoints = 10 + + ref_data = numpy.random.random((size, size, size)) + ref_points = numpy.random.random(npoints*3).reshape(npoints, 3) * (size -1) + + for dtype in (numpy.float32, numpy.float64): + data = ref_data.astype(dtype) + points = ref_points.astype(dtype) + ref_result = self.ref_interp3d(data, points) + + for method in (u'linear', u'linear_omp'): + with self.subTest(method=method): + result = interpolate.interp3d(data, points, method=method) + self.assertTrue(numpy.allclose(ref_result, result)) + + def test_notfinite_data(self): + """Test interp3d with NaN and inf""" + data = numpy.ones((3, 3, 3), dtype=numpy.float64) + data[0, 0, 0] = numpy.nan + data[2, 2, 2] = numpy.inf + points = numpy.array([(0.5, 0.5, 0.5), + (1.5, 1.5, 1.5)]) + + for method in (u'linear', u'linear_omp'): + with self.subTest(method=method): + result = interpolate.interp3d( + data, points, method=method) + self.assertTrue(numpy.isnan(result[0])) + self.assertTrue(result[1] == numpy.inf) + + def test_points_outside(self): + """Test interp3d with points outside the volume""" + data = numpy.ones((4, 4, 4), dtype=numpy.float64) + points = numpy.array([(-0.1, -0.1, -0.1), + (3.1, 3.1, 3.1), + (-0.1, 1., 1.), + (1., 1., 3.1)]) + + for method in (u'linear', u'linear_omp'): + for fill_value in (numpy.nan, 0., -1.): + with self.subTest(method=method): + result = interpolate.interp3d( + data, points, method=method, fill_value=fill_value) + if numpy.isnan(fill_value): + self.assertTrue(numpy.all(numpy.isnan(result))) + else: + self.assertTrue(numpy.all(numpy.equal(result, fill_value))) + + def test_integer_points(self): + """Test interp3d with integer points coord""" + data = numpy.arange(4**3, dtype=numpy.float64).reshape(4, 4, 4) + points = numpy.array([(0., 0., 0.), + (0., 0., 1.), + (2., 3., 0.), + (3., 3., 3.)]) + + ref_result = data[tuple(points.T.astype(numpy.int32))] + + for method in (u'linear', u'linear_omp'): + with self.subTest(method=method): + result = interpolate.interp3d(data, points, method=method) + self.assertTrue(numpy.allclose(ref_result, result)) diff --git a/src/silx/math/test/test_marchingcubes.py b/src/silx/math/test/test_marchingcubes.py new file mode 100644 index 0000000..5e2b193 --- /dev/null +++ b/src/silx/math/test/test_marchingcubes.py @@ -0,0 +1,174 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Tests of the marchingcubes module""" + +from __future__ import division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/01/2018" + +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase + +from silx.math import marchingcubes + + +class TestMarchingCubes(ParametricTestCase): + """Tests of marching cubes""" + + def assertAllClose(self, array1, array2, msg=None, + rtol=1e-05, atol=1e-08): + """Assert that the 2 numpy.ndarrays are almost equal. + + :param str msg: Message to provide when assert fails + :param float rtol: Relative tolerance, see :func:`numpy.allclose` + :param float atol: Absolute tolerance, see :func:`numpy.allclose` + """ + if not numpy.allclose(array1, array2, rtol, atol): + raise self.failureException(msg) + + def test_cube(self): + """Unit tests with a single cube""" + + # No isosurface + cube_zero = numpy.zeros((2, 2, 2), dtype=numpy.float32) + + result = marchingcubes.MarchingCubes(cube_zero, 1.) + self.assertEqual(result.shape, cube_zero.shape) + self.assertEqual(result.isolevel, 1.) + self.assertEqual(result.invert_normals, True) + + vertices, normals, indices = result + self.assertEqual(len(vertices), 0) + self.assertEqual(len(normals), 0) + self.assertEqual(len(indices), 0) + + # Cube array dimensions: shape = (dim 0, dim 1, dim2) + # + # dim 0 (Z) + # ^ + # | + # 4 +------+ 5 + # /| /| + # / | / | + # 6 +------+ 7| + # | | | | + # |0 +---|--+ 1 -> dim 2 (X) + # | / | / + # |/ |/ + # 2 +------+ 3 + # / + # dim 1 (Y) + + # isosurface perpendicular to dim 0 (Z) + cube = numpy.array( + (((0., 0.), (0., 0.)), + ((1., 1.), (1., 1.))), dtype=numpy.float32) + level = 0.5 + vertices, normals, indices = marchingcubes.MarchingCubes( + cube, level, invert_normals=False) + self.assertAllClose(vertices[:, 0], level) + self.assertAllClose(normals, (1., 0., 0.)) + self.assertEqual(len(indices), 2) + + # isosurface perpendicular to dim 1 (Y) + cube = numpy.array( + (((0., 0.), (1., 1.)), + ((0., 0.), (1., 1.))), dtype=numpy.float32) + level = 0.2 + vertices, normals, indices = marchingcubes.MarchingCubes(cube, level) + self.assertAllClose(vertices[:, 1], level) + self.assertAllClose(normals, (0., -1., 0.)) + self.assertEqual(len(indices), 2) + + # isosurface perpendicular to dim 2 (X) + cube = numpy.array( + (((0., 1.), (0., 1.)), + ((0., 1.), (0., 1.))), dtype=numpy.float32) + level = 0.9 + vertices, normals, indices = marchingcubes.MarchingCubes( + cube, level, invert_normals=False) + self.assertAllClose(vertices[:, 2], level) + self.assertAllClose(normals, (0., 0., 1.)) + self.assertEqual(len(indices), 2) + + # isosurface normal in dim1, dim 0 (Y, Z) plane + cube = numpy.array( + (((0., 0.), (0., 0.)), + ((0., 0.), (1., 1.))), dtype=numpy.float32) + level = 0.5 + vertices, normals, indices = marchingcubes.MarchingCubes(cube, level) + self.assertAllClose(normals[:, 2], 0.) + self.assertEqual(len(indices), 2) + + def test_sampling(self): + """Test different sampling, comparing to reference without sampling""" + isolevel = 0.5 + size = 9 + chessboard = numpy.zeros((size, size, size), dtype=numpy.float32) + chessboard.reshape(-1)[::2] = 1 # OK as long as dimensions are odd + + ref_result = marchingcubes.MarchingCubes(chessboard, isolevel) + + samplings = [ + (2, 1, 1), + (1, 2, 1), + (1, 1, 2), + (2, 2, 2), + (3, 3, 3), + (1, 3, 1), + (1, 1, 3), + ] + + for sampling in samplings: + with self.subTest(sampling=sampling): + sampling = numpy.array(sampling) + + data = 1e6 * numpy.ones( + sampling * size, dtype=numpy.float32) + # Copy ref chessboard in data according to sampling + data[::sampling[0], ::sampling[1], ::sampling[2]] = chessboard + + result = marchingcubes.MarchingCubes(data, isolevel, + sampling=sampling) + # Compare vertices normalized with shape + self.assertAllClose( + ref_result.get_vertices() / ref_result.shape, + result.get_vertices() / result.shape, + atol=0., rtol=0.) + + # Compare normals + # This comparison only works for normals aligned with axes + # otherwise non uniform sampling would make different normals + self.assertAllClose(ref_result.get_normals(), + result.get_normals(), + atol=0., rtol=0.) + + self.assertAllClose(ref_result.get_indices(), + result.get_indices(), + atol=0., rtol=0.) |