Module ctsimu.image_analysis.isrb

Expand source code
# -*- coding: UTF-8 -*-
# From Bendix Hartlaub's iSRb tool

from warnings import warn

import numpy as np
#from skimage.measure import profile_line   # toolbox implements its own line profle measurement routine
from scipy.optimize import curve_fit, minimize
from scipy.signal import find_peaks, peak_widths
from time import time

import copy

class OrderError(Exception):
    '''Raise on inappropiate execution order. Correct order is:
    __init__(), profile(), calc_dips(), interpolate()'''
    def __init__(self, message):
        super().__init__(message)
        return

class ResultError(Exception):
    '''Raise on bad calculation results, if unable to continue'''
    def __init__(self, message):
        super().__init__(message)
        return

class Interpolation(object):
    '''Manage Interpolation
    The interpolation process is separated into 4 parts:
    __init__()    - open image
    profile()     - measure profile in image
    calc_dips()   - calculate dips
    interpolate() - calculate isrb from dips
    
    Each part (except _init__) can be re-executed to estimate suitable 
    variables. The Interpolation object saves parameters additional to 
    the parameters needed for further calculations. They simplify
    finding fitting parameters for the calculation.
    '''
    
    def __init__(self, im, pixelsize, SOD=1000, SDD=1000, wire_length = 15,
        wire_spacing = [0.8, 0.63, 0.5, 0.4, 0.32, 0.25, 0.2, 0.16, 0.13, 0.1, 0.08, 0.063, 0.05]):
        '''Create the Interpolation instance with the image representing 
        array and other necessary parameters.
        
        Parameters
        ----------
        im : array-like, shape (n, m)
            The array representing the gray/intensity scale image 
            (background is assumed to have high intensity, bright 
            gray scales).
        pixelsize : scalar
            The pixelsize of the image in milimeters. Only square 
            pixels are possible.
        SOD : float, optional
            Source Object Distance of the scan setup in milimeters.
        SDD : float, optional
            Source Detector Distance of the scan setup in milimeters.
        wire_length : scalar
            Length of the wires in mm
        wire_spacing : array-like, shape (n, )
            Spacing that the wire pairs hold, acending (matches the 
            wire diameter)
            '''
        
        # calculating the scaling of the duplex wires
        if SDD == 0:
            raise ValueError('SDD cannot be 0')
        if SOD == 0:
            raise ValueError('SOD cannot be 0')
        self.scale = SDD/SOD
        if self.scale < 1:
            raise ValueError('SOD cannot be larger than SDD')
        
        # initializing required variables
        self.im = np.asarray(im, dtype = np.float64)
        self.pxsize = float(pixelsize)
        
        # the space inbetween that the wire pairs hold and the wire length
        self.wire_spacing = np.asarray(wire_spacing)
        self.wire_len = wire_length
        
        # variables that will later be set
        self.measure = None # ndarray
        self.dips = None    # ndarray
        self.dip20 = None   # scalar
        self.criticalIndex = None   # Index of last wire pair with dip > 20%.
        
        # variable not necessary to pass between functions but useful to investigate
        self.coords = None  # tuple of profile line properties
        self.peaks = None   # tuples of ndarray, shape (2, x)
        self.max_peaks = None
        self.despike = None
        self.bg = None
        self.dipsoi = None
        self.inter = None

        # Interpolation fit results:
        self.a = 0
        self.b = 0
        self.c = 0

        return
    
    
    
    def quadratic(x, a, b, c):
        '''quadratic function'''
        
        return a*x**2 + b*x + c
    
    def inverted_quadratic(y, a, b, c):
        '''solve quadratic function ax^2+bx+c=y
        
        Returns
        -------
        tuple
            Both possible solutions for x'''
        
        return (-b/(2*a) + np.sqrt((b/a)**2 /4 - (c-y)/a),
                -b/(2*a) - np.sqrt((b/a)**2 /4 - (c-y)/a))
    
    
    
    def _bivariance(self, phi, rho, start, def_kwargs):
        '''Calculate the variance of the vertically calculated variance 
        of the region of interest
        
        Parameters
        ----------
        phi : scalar
            Angle between the profile line and the horizontal plane
        rho : scalar
            Length of the profile line (pixel coordinates)
        start : array-like, shape (2, )
            Pixel coordinate of the profile line start
        def_kwargs : dict
            Options specifying the profile line
        
        Returns
        -------
        bivar : scalar
            variance of vertical variance
        '''
        
        # scipy minimization passes arrays
        phi = phi[0]
        
        def_kwargs['reduce_func'] = np.var
        stop = np.array(start) + rho * np.array((np.cos(phi), np.sin(phi)))
        # matrix indeces differ from cartesian coordinates
        var = profile_line(self.im, start[::-1], stop[::-1], **def_kwargs)
        bivar = np.var(var)
        return bivar
    
    
    
    def profile(self, start, stop, polar_coord = False, optimize = False, rel_width = 0.6):
        '''Measure intensity along a profile line (area) in the loaded image.
        
        Parameters
        ----------
        start : array-like, shape (2, )
            Pixel coordinate of the profile line start.
        stop : array-like, shape (2, )
            Coordinate of the profile line stop. Look at polar_coord for more info.
        polar_coord : bool, optional
            True: 'stop' is treated as polar coordinate (rho, phi), relative to 'start'.
                  Angle in degree, counter-clockwise.
            False: 'stop' is treated as cartesian coordinate (x, y)
        optimize : bool, optional
            If True, optimize the angle (phi) of 'stop' (regardless of coordinate type).
            Optimize by minimizig the variance of vertical variance of the region of 
            interest with the Powell's Method.
        rel_width : scalar, optional
            Relative portion of the wire length used for measuring. values ranging
            from 0.3 to 0.6 are recommended.
        
        Returns
        -------
        None
            Write the intensity profile along the scan line to
            self.measure : ndarray, shape (n, )
            on execution
        
        General Settings
        ----------------
         - linewidth = width * wire_length
         - bi-quadratic filtering for off-pixel coordinates
         - nearest filter for coordinates outside of the image
         - arithmetic mean for aggregation of pixels perpendicular to the line
        
        Notes
        -----
        The possibility of non-square pixels:
        The transformation between pixel-coordinates and cartesian 
        distance-coordinates is not a conform map, if the pixels are not squares. 
        Therefore the measured pixels perpendicular to the profile line in the pixel 
        coordinates would not be perpendicular in distance coordinates.
        
        The measurement is based on skimage.measure.profile_line(). For
        further informations, check out
        https://scikit-image.org/docs/stable/api/skimage.measure.html#skimage.measure.profile_line'''
        
        try:
            rel_width = float(rel_width)
        except (TypeError, ValueError):
            raise TypeError("'width' must be float type.")
        if rel_width < 0.3 or rel_width > 0.6:
            warn("Expected 0.3 <= 'width' <= 0.6 but width = {0:.2f}".format(rel_width))
        
        def_kwargs = {'linewidth' : int(np.rint(self.scale * self.wire_len/self.pxsize * rel_width)),
                      'order' : 2,
                      'mode' : 'nearest'}
        
        if optimize:
            print('optimizing')
            t = time()
            if polar_coord:
                rho, phi = stop[0], -np.deg2rad(stop[1])
            else:
                rho = np.linalg.norm(stop-start)
                # geometric scalar product
                phi = np.arccos((stop[0]-start[0])/rho)
            
            # minimization
            sol = minimize(self.bivariance, phi, method='Powell', args=(rho, start, def_kwargs.copy()))
            phi = sol['x']
            print('optimizing done in {:.6f}ms \n'.format((time()-t)*1000))
            print('phi = {:.6f}, bivar = {:.6f}, rho = {:.6f} \n'.format(-np.rad2deg(phi), sol['fun'], rho))
            
            if polar_coord:
                stop = start + rho*np.array((np.cos(phi), np.sin(phi)))
                stop = np.asarray(np.rint(stop), dtype=int)
        
        else:
            if polar_coord:
                rho, phi = stop[0], -np.deg2rad(stop[1])
                stop = start + rho*np.array((np.cos(phi), np.sin(phi)))
                stop = np.asarray(np.rint(stop), dtype=int)
            else:
                pass
        self.coords = (start, stop, def_kwargs['linewidth'])
        
        # saving variables
        # matrix indeces differ from cartesian coordinates
        measure = profile_line(self.im, start[::-1], stop[::-1], **def_kwargs)
        self.measure = measure
        self.ind = np.arange(0, self.measure.size)
        return
        
        
        
    def calc_dips(self, bg_func = quadratic, prominence=10, height=None, width=None, rel_height=0.9):
        '''Calculate the dips in the profile.
        Find downwards orientated peaks (lower peaks, minimum peaks).
        Calculate the widths from the prominence data.
        Order peaks into pairs.
        Estimate background values.
        Calculate dips from pairs.
        
        Parameters
        ----------
        bg_func : callable, optional
            f(x, *args) -> y
            Scalar function that approximates the background values of the 
            measurement. 'args' will be calculated by regression.
        prominence : float, optional
            Peak prominence of the lower peaks to find. The absolute difference
            between the peak and its contour line.
            Reference in 'scipy.signal.peak_prominence()'.
        height : float, optional
            Peak height of the lower peaks to find (peaks lying above this value 
            will be excluded).
            Reference in 'scipy.signal.find_peaks()'.
        width : float, optional
            Peak width of the lower peaks to find.
            Reference in 'scipy.signal.peak_width()'.
        rel_height : float, optional
            Relative height to measure the width of the peaks at.
            Reference in 'scipy.signal.peak_widths()'.
        
        Returns
        -------
        None
            Write found dips to self.dips : ndarray, shape (m, )
            Write found minima to self.min : array-like, shape (2, n)
            Write found background values to self.bg : array-like, shape (2, o)'''
        
        
        if self.measure is None:
            raise OrderError("no profile was measured")
                
        if height is not None: 
            # self.measure is negated and so is the height
            height = -height
        
        
        # finding minimum peaks
        
        peaks, prop = find_peaks(-self.measure, prominence=prominence, height=height, width=width)
        prominence_data = (prop['prominences'], prop['left_bases'], prop['right_bases'])
        
        if peaks.size == 0:
            # raised when no peaks are found
            raise ResultError('No lower peaks were found')
        
        ### fitting background
        # cutting out peaks
        widths, _, _, _= peak_widths(-self.measure, peaks, rel_height=rel_height, prominence_data=prominence_data)
        
        mask_bg = np.ones(self.ind.shape, dtype=bool)
        intervals = ()
        for i in range(len(widths)):
            # limits should not exceed the array limits 0...len
            lim1 = int(np.rint(peaks[i]-widths[i]))
            if lim1 < 0:
                lim1 = 0
            elif lim1 >= self.measure.size:
                lim1 = self.measure.size-1
            
            lim2 = int(np.rint(peaks[i]+widths[i]))
            if lim2 < 0:
                lim2 = 0
            elif lim2 >= self.measure.size:
                lim2 = self.measure.size-1
            
            mask_bg[lim1 : lim2+1] = False
        
        # fitting
        popt_bg, _ = curve_fit(bg_func, self.ind[mask_bg], self.measure[mask_bg])
        bg_val = bg_func(self.ind, *popt_bg)
        
        
        # ordering minima into pairs.
        dist = peaks[1:] - peaks[:-1]  # pairwise distances: #1-#0, #2-#1, #3-#2, etc.
        dist_max = 1.05*dist[0]
        
        dips = []
        max_peaks = []
        for i in range(len(dist)):
            
            # end of array
            #if i == len(dist)-1:
            #    dips.append(0)
                
            # a pair
            if dist[i] <= dist_max: #weakest point of algorithm!!!
                lim1, lim2 = peaks[i], peaks[i+1]
                
                # the minima
                A = abs(bg_val[lim1] - self.measure[lim1])
                B = abs(bg_val[lim2] - self.measure[lim2])
                
                # the intermediate maximum
                C_pos = np.argmax(self.measure[lim1: lim2+1]) + lim1
                max_peaks.append(C_pos)
                C = abs(bg_val[C_pos] - self.measure[C_pos])
                
                dips.append(100 * (A + B - 2*C) / (A + B))
                
            # segmentations of Non-Pairs are not needed

        while len(dips) < len(self.wire_spacing):
            dips.append(0)
        
        # saving variables
        self.dips = np.array(dips)
        
        self.peaks = (peaks, self.measure[peaks])
        self.max_peaks = (max_peaks, self.measure[max_peaks])
        self.despike = (self.ind[mask_bg], self.measure[mask_bg])
        self.bg = (self.ind, bg_val)
        return
    
    
    def interpolate(self):
        '''Choose important dips, perform quadradic interpolation and
        choose the correct solution for the 20% dip.
        
        Returns
        -------
        None
            Write result to self.dip20 : scalar
            Write found dips of interest to self.dipsoi : array-like, shape (2, n)
            Write interpolation fit to self.inter : array-like, shape (2, m)'''
        
        if self.dips is None:
            raise OrderError("'segmentation()' has to be executed at least once")
        
        if self.dips.size == 0:
            raise ResultError('no dips were found')
        
        if all(self.dips < 20):
            raise ResultError("no dip > 20 was found, interpolation not possible")
        elif all(self.dips > 20):
            warn('no dip < 20 was found, a dip of 0 is used instead')

        # clean up:
        use_dips = copy.deepcopy(self.dips)
        use_wire_spacing = copy.deepcopy(self.wire_spacing)
        print("  {n} Dips found.".format(n=len(self.dips)))
        success = False
        while not success:
            success = True
            i = 1
            while i < len(use_dips):
                print("  Dip {i}: {d}".format(i=i, d=use_dips[i]))
                
                # The following, more shallow dip must not be deeper by more than 5% of previous dip.
                # Otherwise, there seems to be an order problem, i.e. the dips should become more shallow
                # with each iteration. 
                if (use_dips[i] - use_dips[i-1]) > 5:  
                    # This dip seems to be invalid. Drop it.
                    print("  Dropping {idx}.".format(idx=(i-1)))
                    use_dips = np.delete(use_dips, i-1)
                    use_wire_spacing = np.delete(use_wire_spacing, i-1)
                    i = i - 1
                    success = False

                i = i + 1


        # finding the 20% crossing element and choosing the important neighboring values
        index = np.arange(0, (len(use_dips)))
        for i in index:
            lower = i-2   # including
            upper = i+2   # including

            while lower < 0:
                lower += 1
            while upper >= len(use_dips):
                upper -= 1

            # Do not include nearest or next-nearest neighbor dips
            # with less than 1.5% modulation depth,
            # as this will give unpleasant fits:
            if (upper-i) >= 2:
                if use_dips[upper] < 1.5:  # %
                    upper -= 1

            if (upper-i) >= 1:
                if use_dips[upper] < 1.5:  # %
                    upper -= 1

            pos = index[lower:(upper+1)]

            if i < len(use_dips)-1:
                if use_dips[i+1] < 20:
                    self.criticalIndex = i
                    break
            else:
                self.criticalIndex = i
                break
        
        dists = use_wire_spacing[pos] #* self.scale
        dips = use_dips[pos]
        popt, _ = curve_fit(Interpolation.quadratic, dists, dips)
        self.a = popt[0]
        self.b = popt[1]
        self.c = popt[2]
        
        dips20 = Interpolation.inverted_quadratic(20, *popt)
        #for i in dips20:
        #    if i>= min(dists) and i<=max(dists):
        #        self.dip20 = i

        # Depending on the curvature of the interpolation function,
        # we choose either the left or the right zero-crossing of the quadratic function
        # as the iSRb:
        if self.a >= 0:
            self.dip20 = max(dips20)
        else:
            self.dip20 = min(dips20)
        
        if isinstance(self.dip20, type(None)):
            warn("could not estimate a single 20%-dip. The found values are {0:.4f} and {1:.4f}".format(*dips20))
        
        self.dipsoi = (dists, dips)
        lowerBound = min(dists)
        lowerBound = min(lowerBound, self.dip20)
        upperBound = max(dists)
        upperBound = max(upperBound, self.dip20)
        x = np.linspace(lowerBound, upperBound, 100)
        self.inter = (x, Interpolation.quadratic(x, *popt))
        return

Classes

class Interpolation (im, pixelsize, SOD=1000, SDD=1000, wire_length=15, wire_spacing=[0.8, 0.63, 0.5, 0.4, 0.32, 0.25, 0.2, 0.16, 0.13, 0.1, 0.08, 0.063, 0.05])

Manage Interpolation The interpolation process is separated into 4 parts: init() - open image profile() - measure profile in image calc_dips() - calculate dips interpolate() - calculate isrb from dips

Each part (except _init__) can be re-executed to estimate suitable variables. The Interpolation object saves parameters additional to the parameters needed for further calculations. They simplify finding fitting parameters for the calculation.

Create the Interpolation instance with the image representing array and other necessary parameters.

Parameters

im : array-like, shape (n, m)
The array representing the gray/intensity scale image (background is assumed to have high intensity, bright gray scales).
pixelsize : scalar
The pixelsize of the image in milimeters. Only square pixels are possible.
SOD : float, optional
Source Object Distance of the scan setup in milimeters.
SDD : float, optional
Source Detector Distance of the scan setup in milimeters.
wire_length : scalar
Length of the wires in mm
wire_spacing : array-like, shape (n, )
Spacing that the wire pairs hold, acending (matches the wire diameter)
Expand source code
class Interpolation(object):
    '''Manage Interpolation
    The interpolation process is separated into 4 parts:
    __init__()    - open image
    profile()     - measure profile in image
    calc_dips()   - calculate dips
    interpolate() - calculate isrb from dips
    
    Each part (except _init__) can be re-executed to estimate suitable 
    variables. The Interpolation object saves parameters additional to 
    the parameters needed for further calculations. They simplify
    finding fitting parameters for the calculation.
    '''
    
    def __init__(self, im, pixelsize, SOD=1000, SDD=1000, wire_length = 15,
        wire_spacing = [0.8, 0.63, 0.5, 0.4, 0.32, 0.25, 0.2, 0.16, 0.13, 0.1, 0.08, 0.063, 0.05]):
        '''Create the Interpolation instance with the image representing 
        array and other necessary parameters.
        
        Parameters
        ----------
        im : array-like, shape (n, m)
            The array representing the gray/intensity scale image 
            (background is assumed to have high intensity, bright 
            gray scales).
        pixelsize : scalar
            The pixelsize of the image in milimeters. Only square 
            pixels are possible.
        SOD : float, optional
            Source Object Distance of the scan setup in milimeters.
        SDD : float, optional
            Source Detector Distance of the scan setup in milimeters.
        wire_length : scalar
            Length of the wires in mm
        wire_spacing : array-like, shape (n, )
            Spacing that the wire pairs hold, acending (matches the 
            wire diameter)
            '''
        
        # calculating the scaling of the duplex wires
        if SDD == 0:
            raise ValueError('SDD cannot be 0')
        if SOD == 0:
            raise ValueError('SOD cannot be 0')
        self.scale = SDD/SOD
        if self.scale < 1:
            raise ValueError('SOD cannot be larger than SDD')
        
        # initializing required variables
        self.im = np.asarray(im, dtype = np.float64)
        self.pxsize = float(pixelsize)
        
        # the space inbetween that the wire pairs hold and the wire length
        self.wire_spacing = np.asarray(wire_spacing)
        self.wire_len = wire_length
        
        # variables that will later be set
        self.measure = None # ndarray
        self.dips = None    # ndarray
        self.dip20 = None   # scalar
        self.criticalIndex = None   # Index of last wire pair with dip > 20%.
        
        # variable not necessary to pass between functions but useful to investigate
        self.coords = None  # tuple of profile line properties
        self.peaks = None   # tuples of ndarray, shape (2, x)
        self.max_peaks = None
        self.despike = None
        self.bg = None
        self.dipsoi = None
        self.inter = None

        # Interpolation fit results:
        self.a = 0
        self.b = 0
        self.c = 0

        return
    
    
    
    def quadratic(x, a, b, c):
        '''quadratic function'''
        
        return a*x**2 + b*x + c
    
    def inverted_quadratic(y, a, b, c):
        '''solve quadratic function ax^2+bx+c=y
        
        Returns
        -------
        tuple
            Both possible solutions for x'''
        
        return (-b/(2*a) + np.sqrt((b/a)**2 /4 - (c-y)/a),
                -b/(2*a) - np.sqrt((b/a)**2 /4 - (c-y)/a))
    
    
    
    def _bivariance(self, phi, rho, start, def_kwargs):
        '''Calculate the variance of the vertically calculated variance 
        of the region of interest
        
        Parameters
        ----------
        phi : scalar
            Angle between the profile line and the horizontal plane
        rho : scalar
            Length of the profile line (pixel coordinates)
        start : array-like, shape (2, )
            Pixel coordinate of the profile line start
        def_kwargs : dict
            Options specifying the profile line
        
        Returns
        -------
        bivar : scalar
            variance of vertical variance
        '''
        
        # scipy minimization passes arrays
        phi = phi[0]
        
        def_kwargs['reduce_func'] = np.var
        stop = np.array(start) + rho * np.array((np.cos(phi), np.sin(phi)))
        # matrix indeces differ from cartesian coordinates
        var = profile_line(self.im, start[::-1], stop[::-1], **def_kwargs)
        bivar = np.var(var)
        return bivar
    
    
    
    def profile(self, start, stop, polar_coord = False, optimize = False, rel_width = 0.6):
        '''Measure intensity along a profile line (area) in the loaded image.
        
        Parameters
        ----------
        start : array-like, shape (2, )
            Pixel coordinate of the profile line start.
        stop : array-like, shape (2, )
            Coordinate of the profile line stop. Look at polar_coord for more info.
        polar_coord : bool, optional
            True: 'stop' is treated as polar coordinate (rho, phi), relative to 'start'.
                  Angle in degree, counter-clockwise.
            False: 'stop' is treated as cartesian coordinate (x, y)
        optimize : bool, optional
            If True, optimize the angle (phi) of 'stop' (regardless of coordinate type).
            Optimize by minimizig the variance of vertical variance of the region of 
            interest with the Powell's Method.
        rel_width : scalar, optional
            Relative portion of the wire length used for measuring. values ranging
            from 0.3 to 0.6 are recommended.
        
        Returns
        -------
        None
            Write the intensity profile along the scan line to
            self.measure : ndarray, shape (n, )
            on execution
        
        General Settings
        ----------------
         - linewidth = width * wire_length
         - bi-quadratic filtering for off-pixel coordinates
         - nearest filter for coordinates outside of the image
         - arithmetic mean for aggregation of pixels perpendicular to the line
        
        Notes
        -----
        The possibility of non-square pixels:
        The transformation between pixel-coordinates and cartesian 
        distance-coordinates is not a conform map, if the pixels are not squares. 
        Therefore the measured pixels perpendicular to the profile line in the pixel 
        coordinates would not be perpendicular in distance coordinates.
        
        The measurement is based on skimage.measure.profile_line(). For
        further informations, check out
        https://scikit-image.org/docs/stable/api/skimage.measure.html#skimage.measure.profile_line'''
        
        try:
            rel_width = float(rel_width)
        except (TypeError, ValueError):
            raise TypeError("'width' must be float type.")
        if rel_width < 0.3 or rel_width > 0.6:
            warn("Expected 0.3 <= 'width' <= 0.6 but width = {0:.2f}".format(rel_width))
        
        def_kwargs = {'linewidth' : int(np.rint(self.scale * self.wire_len/self.pxsize * rel_width)),
                      'order' : 2,
                      'mode' : 'nearest'}
        
        if optimize:
            print('optimizing')
            t = time()
            if polar_coord:
                rho, phi = stop[0], -np.deg2rad(stop[1])
            else:
                rho = np.linalg.norm(stop-start)
                # geometric scalar product
                phi = np.arccos((stop[0]-start[0])/rho)
            
            # minimization
            sol = minimize(self.bivariance, phi, method='Powell', args=(rho, start, def_kwargs.copy()))
            phi = sol['x']
            print('optimizing done in {:.6f}ms \n'.format((time()-t)*1000))
            print('phi = {:.6f}, bivar = {:.6f}, rho = {:.6f} \n'.format(-np.rad2deg(phi), sol['fun'], rho))
            
            if polar_coord:
                stop = start + rho*np.array((np.cos(phi), np.sin(phi)))
                stop = np.asarray(np.rint(stop), dtype=int)
        
        else:
            if polar_coord:
                rho, phi = stop[0], -np.deg2rad(stop[1])
                stop = start + rho*np.array((np.cos(phi), np.sin(phi)))
                stop = np.asarray(np.rint(stop), dtype=int)
            else:
                pass
        self.coords = (start, stop, def_kwargs['linewidth'])
        
        # saving variables
        # matrix indeces differ from cartesian coordinates
        measure = profile_line(self.im, start[::-1], stop[::-1], **def_kwargs)
        self.measure = measure
        self.ind = np.arange(0, self.measure.size)
        return
        
        
        
    def calc_dips(self, bg_func = quadratic, prominence=10, height=None, width=None, rel_height=0.9):
        '''Calculate the dips in the profile.
        Find downwards orientated peaks (lower peaks, minimum peaks).
        Calculate the widths from the prominence data.
        Order peaks into pairs.
        Estimate background values.
        Calculate dips from pairs.
        
        Parameters
        ----------
        bg_func : callable, optional
            f(x, *args) -> y
            Scalar function that approximates the background values of the 
            measurement. 'args' will be calculated by regression.
        prominence : float, optional
            Peak prominence of the lower peaks to find. The absolute difference
            between the peak and its contour line.
            Reference in 'scipy.signal.peak_prominence()'.
        height : float, optional
            Peak height of the lower peaks to find (peaks lying above this value 
            will be excluded).
            Reference in 'scipy.signal.find_peaks()'.
        width : float, optional
            Peak width of the lower peaks to find.
            Reference in 'scipy.signal.peak_width()'.
        rel_height : float, optional
            Relative height to measure the width of the peaks at.
            Reference in 'scipy.signal.peak_widths()'.
        
        Returns
        -------
        None
            Write found dips to self.dips : ndarray, shape (m, )
            Write found minima to self.min : array-like, shape (2, n)
            Write found background values to self.bg : array-like, shape (2, o)'''
        
        
        if self.measure is None:
            raise OrderError("no profile was measured")
                
        if height is not None: 
            # self.measure is negated and so is the height
            height = -height
        
        
        # finding minimum peaks
        
        peaks, prop = find_peaks(-self.measure, prominence=prominence, height=height, width=width)
        prominence_data = (prop['prominences'], prop['left_bases'], prop['right_bases'])
        
        if peaks.size == 0:
            # raised when no peaks are found
            raise ResultError('No lower peaks were found')
        
        ### fitting background
        # cutting out peaks
        widths, _, _, _= peak_widths(-self.measure, peaks, rel_height=rel_height, prominence_data=prominence_data)
        
        mask_bg = np.ones(self.ind.shape, dtype=bool)
        intervals = ()
        for i in range(len(widths)):
            # limits should not exceed the array limits 0...len
            lim1 = int(np.rint(peaks[i]-widths[i]))
            if lim1 < 0:
                lim1 = 0
            elif lim1 >= self.measure.size:
                lim1 = self.measure.size-1
            
            lim2 = int(np.rint(peaks[i]+widths[i]))
            if lim2 < 0:
                lim2 = 0
            elif lim2 >= self.measure.size:
                lim2 = self.measure.size-1
            
            mask_bg[lim1 : lim2+1] = False
        
        # fitting
        popt_bg, _ = curve_fit(bg_func, self.ind[mask_bg], self.measure[mask_bg])
        bg_val = bg_func(self.ind, *popt_bg)
        
        
        # ordering minima into pairs.
        dist = peaks[1:] - peaks[:-1]  # pairwise distances: #1-#0, #2-#1, #3-#2, etc.
        dist_max = 1.05*dist[0]
        
        dips = []
        max_peaks = []
        for i in range(len(dist)):
            
            # end of array
            #if i == len(dist)-1:
            #    dips.append(0)
                
            # a pair
            if dist[i] <= dist_max: #weakest point of algorithm!!!
                lim1, lim2 = peaks[i], peaks[i+1]
                
                # the minima
                A = abs(bg_val[lim1] - self.measure[lim1])
                B = abs(bg_val[lim2] - self.measure[lim2])
                
                # the intermediate maximum
                C_pos = np.argmax(self.measure[lim1: lim2+1]) + lim1
                max_peaks.append(C_pos)
                C = abs(bg_val[C_pos] - self.measure[C_pos])
                
                dips.append(100 * (A + B - 2*C) / (A + B))
                
            # segmentations of Non-Pairs are not needed

        while len(dips) < len(self.wire_spacing):
            dips.append(0)
        
        # saving variables
        self.dips = np.array(dips)
        
        self.peaks = (peaks, self.measure[peaks])
        self.max_peaks = (max_peaks, self.measure[max_peaks])
        self.despike = (self.ind[mask_bg], self.measure[mask_bg])
        self.bg = (self.ind, bg_val)
        return
    
    
    def interpolate(self):
        '''Choose important dips, perform quadradic interpolation and
        choose the correct solution for the 20% dip.
        
        Returns
        -------
        None
            Write result to self.dip20 : scalar
            Write found dips of interest to self.dipsoi : array-like, shape (2, n)
            Write interpolation fit to self.inter : array-like, shape (2, m)'''
        
        if self.dips is None:
            raise OrderError("'segmentation()' has to be executed at least once")
        
        if self.dips.size == 0:
            raise ResultError('no dips were found')
        
        if all(self.dips < 20):
            raise ResultError("no dip > 20 was found, interpolation not possible")
        elif all(self.dips > 20):
            warn('no dip < 20 was found, a dip of 0 is used instead')

        # clean up:
        use_dips = copy.deepcopy(self.dips)
        use_wire_spacing = copy.deepcopy(self.wire_spacing)
        print("  {n} Dips found.".format(n=len(self.dips)))
        success = False
        while not success:
            success = True
            i = 1
            while i < len(use_dips):
                print("  Dip {i}: {d}".format(i=i, d=use_dips[i]))
                
                # The following, more shallow dip must not be deeper by more than 5% of previous dip.
                # Otherwise, there seems to be an order problem, i.e. the dips should become more shallow
                # with each iteration. 
                if (use_dips[i] - use_dips[i-1]) > 5:  
                    # This dip seems to be invalid. Drop it.
                    print("  Dropping {idx}.".format(idx=(i-1)))
                    use_dips = np.delete(use_dips, i-1)
                    use_wire_spacing = np.delete(use_wire_spacing, i-1)
                    i = i - 1
                    success = False

                i = i + 1


        # finding the 20% crossing element and choosing the important neighboring values
        index = np.arange(0, (len(use_dips)))
        for i in index:
            lower = i-2   # including
            upper = i+2   # including

            while lower < 0:
                lower += 1
            while upper >= len(use_dips):
                upper -= 1

            # Do not include nearest or next-nearest neighbor dips
            # with less than 1.5% modulation depth,
            # as this will give unpleasant fits:
            if (upper-i) >= 2:
                if use_dips[upper] < 1.5:  # %
                    upper -= 1

            if (upper-i) >= 1:
                if use_dips[upper] < 1.5:  # %
                    upper -= 1

            pos = index[lower:(upper+1)]

            if i < len(use_dips)-1:
                if use_dips[i+1] < 20:
                    self.criticalIndex = i
                    break
            else:
                self.criticalIndex = i
                break
        
        dists = use_wire_spacing[pos] #* self.scale
        dips = use_dips[pos]
        popt, _ = curve_fit(Interpolation.quadratic, dists, dips)
        self.a = popt[0]
        self.b = popt[1]
        self.c = popt[2]
        
        dips20 = Interpolation.inverted_quadratic(20, *popt)
        #for i in dips20:
        #    if i>= min(dists) and i<=max(dists):
        #        self.dip20 = i

        # Depending on the curvature of the interpolation function,
        # we choose either the left or the right zero-crossing of the quadratic function
        # as the iSRb:
        if self.a >= 0:
            self.dip20 = max(dips20)
        else:
            self.dip20 = min(dips20)
        
        if isinstance(self.dip20, type(None)):
            warn("could not estimate a single 20%-dip. The found values are {0:.4f} and {1:.4f}".format(*dips20))
        
        self.dipsoi = (dists, dips)
        lowerBound = min(dists)
        lowerBound = min(lowerBound, self.dip20)
        upperBound = max(dists)
        upperBound = max(upperBound, self.dip20)
        x = np.linspace(lowerBound, upperBound, 100)
        self.inter = (x, Interpolation.quadratic(x, *popt))
        return

Methods

def calc_dips(self, bg_func=<function Interpolation.quadratic>, prominence=10, height=None, width=None, rel_height=0.9)

Calculate the dips in the profile. Find downwards orientated peaks (lower peaks, minimum peaks). Calculate the widths from the prominence data. Order peaks into pairs. Estimate background values. Calculate dips from pairs.

Parameters

bg_func : callable, optional
f(x, *args) -> y Scalar function that approximates the background values of the measurement. 'args' will be calculated by regression.
prominence : float, optional
Peak prominence of the lower peaks to find. The absolute difference between the peak and its contour line. Reference in 'scipy.signal.peak_prominence()'.
height : float, optional
Peak height of the lower peaks to find (peaks lying above this value will be excluded). Reference in 'scipy.signal.find_peaks()'.
width : float, optional
Peak width of the lower peaks to find. Reference in 'scipy.signal.peak_width()'.
rel_height : float, optional
Relative height to measure the width of the peaks at. Reference in 'scipy.signal.peak_widths()'.

Returns

None
Write found dips to self.dips : ndarray, shape (m, ) Write found minima to self.min : array-like, shape (2, n) Write found background values to self.bg : array-like, shape (2, o)
Expand source code
def calc_dips(self, bg_func = quadratic, prominence=10, height=None, width=None, rel_height=0.9):
    '''Calculate the dips in the profile.
    Find downwards orientated peaks (lower peaks, minimum peaks).
    Calculate the widths from the prominence data.
    Order peaks into pairs.
    Estimate background values.
    Calculate dips from pairs.
    
    Parameters
    ----------
    bg_func : callable, optional
        f(x, *args) -> y
        Scalar function that approximates the background values of the 
        measurement. 'args' will be calculated by regression.
    prominence : float, optional
        Peak prominence of the lower peaks to find. The absolute difference
        between the peak and its contour line.
        Reference in 'scipy.signal.peak_prominence()'.
    height : float, optional
        Peak height of the lower peaks to find (peaks lying above this value 
        will be excluded).
        Reference in 'scipy.signal.find_peaks()'.
    width : float, optional
        Peak width of the lower peaks to find.
        Reference in 'scipy.signal.peak_width()'.
    rel_height : float, optional
        Relative height to measure the width of the peaks at.
        Reference in 'scipy.signal.peak_widths()'.
    
    Returns
    -------
    None
        Write found dips to self.dips : ndarray, shape (m, )
        Write found minima to self.min : array-like, shape (2, n)
        Write found background values to self.bg : array-like, shape (2, o)'''
    
    
    if self.measure is None:
        raise OrderError("no profile was measured")
            
    if height is not None: 
        # self.measure is negated and so is the height
        height = -height
    
    
    # finding minimum peaks
    
    peaks, prop = find_peaks(-self.measure, prominence=prominence, height=height, width=width)
    prominence_data = (prop['prominences'], prop['left_bases'], prop['right_bases'])
    
    if peaks.size == 0:
        # raised when no peaks are found
        raise ResultError('No lower peaks were found')
    
    ### fitting background
    # cutting out peaks
    widths, _, _, _= peak_widths(-self.measure, peaks, rel_height=rel_height, prominence_data=prominence_data)
    
    mask_bg = np.ones(self.ind.shape, dtype=bool)
    intervals = ()
    for i in range(len(widths)):
        # limits should not exceed the array limits 0...len
        lim1 = int(np.rint(peaks[i]-widths[i]))
        if lim1 < 0:
            lim1 = 0
        elif lim1 >= self.measure.size:
            lim1 = self.measure.size-1
        
        lim2 = int(np.rint(peaks[i]+widths[i]))
        if lim2 < 0:
            lim2 = 0
        elif lim2 >= self.measure.size:
            lim2 = self.measure.size-1
        
        mask_bg[lim1 : lim2+1] = False
    
    # fitting
    popt_bg, _ = curve_fit(bg_func, self.ind[mask_bg], self.measure[mask_bg])
    bg_val = bg_func(self.ind, *popt_bg)
    
    
    # ordering minima into pairs.
    dist = peaks[1:] - peaks[:-1]  # pairwise distances: #1-#0, #2-#1, #3-#2, etc.
    dist_max = 1.05*dist[0]
    
    dips = []
    max_peaks = []
    for i in range(len(dist)):
        
        # end of array
        #if i == len(dist)-1:
        #    dips.append(0)
            
        # a pair
        if dist[i] <= dist_max: #weakest point of algorithm!!!
            lim1, lim2 = peaks[i], peaks[i+1]
            
            # the minima
            A = abs(bg_val[lim1] - self.measure[lim1])
            B = abs(bg_val[lim2] - self.measure[lim2])
            
            # the intermediate maximum
            C_pos = np.argmax(self.measure[lim1: lim2+1]) + lim1
            max_peaks.append(C_pos)
            C = abs(bg_val[C_pos] - self.measure[C_pos])
            
            dips.append(100 * (A + B - 2*C) / (A + B))
            
        # segmentations of Non-Pairs are not needed

    while len(dips) < len(self.wire_spacing):
        dips.append(0)
    
    # saving variables
    self.dips = np.array(dips)
    
    self.peaks = (peaks, self.measure[peaks])
    self.max_peaks = (max_peaks, self.measure[max_peaks])
    self.despike = (self.ind[mask_bg], self.measure[mask_bg])
    self.bg = (self.ind, bg_val)
    return
def interpolate(self)

Choose important dips, perform quadradic interpolation and choose the correct solution for the 20% dip.

Returns

None
Write result to self.dip20 : scalar Write found dips of interest to self.dipsoi : array-like, shape (2, n) Write interpolation fit to self.inter : array-like, shape (2, m)
Expand source code
def interpolate(self):
    '''Choose important dips, perform quadradic interpolation and
    choose the correct solution for the 20% dip.
    
    Returns
    -------
    None
        Write result to self.dip20 : scalar
        Write found dips of interest to self.dipsoi : array-like, shape (2, n)
        Write interpolation fit to self.inter : array-like, shape (2, m)'''
    
    if self.dips is None:
        raise OrderError("'segmentation()' has to be executed at least once")
    
    if self.dips.size == 0:
        raise ResultError('no dips were found')
    
    if all(self.dips < 20):
        raise ResultError("no dip > 20 was found, interpolation not possible")
    elif all(self.dips > 20):
        warn('no dip < 20 was found, a dip of 0 is used instead')

    # clean up:
    use_dips = copy.deepcopy(self.dips)
    use_wire_spacing = copy.deepcopy(self.wire_spacing)
    print("  {n} Dips found.".format(n=len(self.dips)))
    success = False
    while not success:
        success = True
        i = 1
        while i < len(use_dips):
            print("  Dip {i}: {d}".format(i=i, d=use_dips[i]))
            
            # The following, more shallow dip must not be deeper by more than 5% of previous dip.
            # Otherwise, there seems to be an order problem, i.e. the dips should become more shallow
            # with each iteration. 
            if (use_dips[i] - use_dips[i-1]) > 5:  
                # This dip seems to be invalid. Drop it.
                print("  Dropping {idx}.".format(idx=(i-1)))
                use_dips = np.delete(use_dips, i-1)
                use_wire_spacing = np.delete(use_wire_spacing, i-1)
                i = i - 1
                success = False

            i = i + 1


    # finding the 20% crossing element and choosing the important neighboring values
    index = np.arange(0, (len(use_dips)))
    for i in index:
        lower = i-2   # including
        upper = i+2   # including

        while lower < 0:
            lower += 1
        while upper >= len(use_dips):
            upper -= 1

        # Do not include nearest or next-nearest neighbor dips
        # with less than 1.5% modulation depth,
        # as this will give unpleasant fits:
        if (upper-i) >= 2:
            if use_dips[upper] < 1.5:  # %
                upper -= 1

        if (upper-i) >= 1:
            if use_dips[upper] < 1.5:  # %
                upper -= 1

        pos = index[lower:(upper+1)]

        if i < len(use_dips)-1:
            if use_dips[i+1] < 20:
                self.criticalIndex = i
                break
        else:
            self.criticalIndex = i
            break
    
    dists = use_wire_spacing[pos] #* self.scale
    dips = use_dips[pos]
    popt, _ = curve_fit(Interpolation.quadratic, dists, dips)
    self.a = popt[0]
    self.b = popt[1]
    self.c = popt[2]
    
    dips20 = Interpolation.inverted_quadratic(20, *popt)
    #for i in dips20:
    #    if i>= min(dists) and i<=max(dists):
    #        self.dip20 = i

    # Depending on the curvature of the interpolation function,
    # we choose either the left or the right zero-crossing of the quadratic function
    # as the iSRb:
    if self.a >= 0:
        self.dip20 = max(dips20)
    else:
        self.dip20 = min(dips20)
    
    if isinstance(self.dip20, type(None)):
        warn("could not estimate a single 20%-dip. The found values are {0:.4f} and {1:.4f}".format(*dips20))
    
    self.dipsoi = (dists, dips)
    lowerBound = min(dists)
    lowerBound = min(lowerBound, self.dip20)
    upperBound = max(dists)
    upperBound = max(upperBound, self.dip20)
    x = np.linspace(lowerBound, upperBound, 100)
    self.inter = (x, Interpolation.quadratic(x, *popt))
    return
def inverted_quadratic(y, a, b, c)

solve quadratic function ax^2+bx+c=y

Returns

tuple
Both possible solutions for x
Expand source code
def inverted_quadratic(y, a, b, c):
    '''solve quadratic function ax^2+bx+c=y
    
    Returns
    -------
    tuple
        Both possible solutions for x'''
    
    return (-b/(2*a) + np.sqrt((b/a)**2 /4 - (c-y)/a),
            -b/(2*a) - np.sqrt((b/a)**2 /4 - (c-y)/a))
def profile(self, start, stop, polar_coord=False, optimize=False, rel_width=0.6)

Measure intensity along a profile line (area) in the loaded image.

Parameters

start : array-like, shape (2, )
Pixel coordinate of the profile line start.
stop : array-like, shape (2, )
Coordinate of the profile line stop. Look at polar_coord for more info.
polar_coord : bool, optional
True: 'stop' is treated as polar coordinate (rho, phi), relative to 'start'. Angle in degree, counter-clockwise. False: 'stop' is treated as cartesian coordinate (x, y)
optimize : bool, optional
If True, optimize the angle (phi) of 'stop' (regardless of coordinate type). Optimize by minimizig the variance of vertical variance of the region of interest with the Powell's Method.
rel_width : scalar, optional
Relative portion of the wire length used for measuring. values ranging from 0.3 to 0.6 are recommended.

Returns

None
Write the intensity profile along the scan line to self.measure : ndarray, shape (n, ) on execution

General Settings

  • linewidth = width * wire_length
  • bi-quadratic filtering for off-pixel coordinates
  • nearest filter for coordinates outside of the image
  • arithmetic mean for aggregation of pixels perpendicular to the line

Notes

The possibility of non-square pixels: The transformation between pixel-coordinates and cartesian distance-coordinates is not a conform map, if the pixels are not squares. Therefore the measured pixels perpendicular to the profile line in the pixel coordinates would not be perpendicular in distance coordinates.

The measurement is based on skimage.measure.profile_line(). For further informations, check out https://scikit-image.org/docs/stable/api/skimage.measure.html#skimage.measure.profile_line

Expand source code
def profile(self, start, stop, polar_coord = False, optimize = False, rel_width = 0.6):
    '''Measure intensity along a profile line (area) in the loaded image.
    
    Parameters
    ----------
    start : array-like, shape (2, )
        Pixel coordinate of the profile line start.
    stop : array-like, shape (2, )
        Coordinate of the profile line stop. Look at polar_coord for more info.
    polar_coord : bool, optional
        True: 'stop' is treated as polar coordinate (rho, phi), relative to 'start'.
              Angle in degree, counter-clockwise.
        False: 'stop' is treated as cartesian coordinate (x, y)
    optimize : bool, optional
        If True, optimize the angle (phi) of 'stop' (regardless of coordinate type).
        Optimize by minimizig the variance of vertical variance of the region of 
        interest with the Powell's Method.
    rel_width : scalar, optional
        Relative portion of the wire length used for measuring. values ranging
        from 0.3 to 0.6 are recommended.
    
    Returns
    -------
    None
        Write the intensity profile along the scan line to
        self.measure : ndarray, shape (n, )
        on execution
    
    General Settings
    ----------------
     - linewidth = width * wire_length
     - bi-quadratic filtering for off-pixel coordinates
     - nearest filter for coordinates outside of the image
     - arithmetic mean for aggregation of pixels perpendicular to the line
    
    Notes
    -----
    The possibility of non-square pixels:
    The transformation between pixel-coordinates and cartesian 
    distance-coordinates is not a conform map, if the pixels are not squares. 
    Therefore the measured pixels perpendicular to the profile line in the pixel 
    coordinates would not be perpendicular in distance coordinates.
    
    The measurement is based on skimage.measure.profile_line(). For
    further informations, check out
    https://scikit-image.org/docs/stable/api/skimage.measure.html#skimage.measure.profile_line'''
    
    try:
        rel_width = float(rel_width)
    except (TypeError, ValueError):
        raise TypeError("'width' must be float type.")
    if rel_width < 0.3 or rel_width > 0.6:
        warn("Expected 0.3 <= 'width' <= 0.6 but width = {0:.2f}".format(rel_width))
    
    def_kwargs = {'linewidth' : int(np.rint(self.scale * self.wire_len/self.pxsize * rel_width)),
                  'order' : 2,
                  'mode' : 'nearest'}
    
    if optimize:
        print('optimizing')
        t = time()
        if polar_coord:
            rho, phi = stop[0], -np.deg2rad(stop[1])
        else:
            rho = np.linalg.norm(stop-start)
            # geometric scalar product
            phi = np.arccos((stop[0]-start[0])/rho)
        
        # minimization
        sol = minimize(self.bivariance, phi, method='Powell', args=(rho, start, def_kwargs.copy()))
        phi = sol['x']
        print('optimizing done in {:.6f}ms \n'.format((time()-t)*1000))
        print('phi = {:.6f}, bivar = {:.6f}, rho = {:.6f} \n'.format(-np.rad2deg(phi), sol['fun'], rho))
        
        if polar_coord:
            stop = start + rho*np.array((np.cos(phi), np.sin(phi)))
            stop = np.asarray(np.rint(stop), dtype=int)
    
    else:
        if polar_coord:
            rho, phi = stop[0], -np.deg2rad(stop[1])
            stop = start + rho*np.array((np.cos(phi), np.sin(phi)))
            stop = np.asarray(np.rint(stop), dtype=int)
        else:
            pass
    self.coords = (start, stop, def_kwargs['linewidth'])
    
    # saving variables
    # matrix indeces differ from cartesian coordinates
    measure = profile_line(self.im, start[::-1], stop[::-1], **def_kwargs)
    self.measure = measure
    self.ind = np.arange(0, self.measure.size)
    return
def quadratic(x, a, b, c)

quadratic function

Expand source code
def quadratic(x, a, b, c):
    '''quadratic function'''
    
    return a*x**2 + b*x + c
class OrderError (message)

Raise on inappropiate execution order. Correct order is: init(), profile(), calc_dips(), interpolate()

Expand source code
class OrderError(Exception):
    '''Raise on inappropiate execution order. Correct order is:
    __init__(), profile(), calc_dips(), interpolate()'''
    def __init__(self, message):
        super().__init__(message)
        return

Ancestors

  • builtins.Exception
  • builtins.BaseException
class ResultError (message)

Raise on bad calculation results, if unable to continue

Expand source code
class ResultError(Exception):
    '''Raise on bad calculation results, if unable to continue'''
    def __init__(self, message):
        super().__init__(message)
        return

Ancestors

  • builtins.Exception
  • builtins.BaseException