Skip to content

filtering module

hydrafloods.filtering

close_binary(img, window=3, neighborhood=None)

Closing morphological filter. Closing is the erosion of the dialiation of values greater than 1.

Parameters:

Name Type Description Default
img ee.Image

binary image to apply closing filter on

required
window int | ee.Number

distance in pixels to consider for closing process

3
neighborhood int | ee.Number

size of the neighborhood to perform fast distance trasnform. Smaller values speed up operations however must be greater than window. If no neighborhood is provided, it will be double the window size. default = None

None

Returns:

Type Description
ee.Image

the closed binary image

Source code in hydrafloods/filtering.py
@decorators.keep_names
@decorators.keep_attrs
def close_binary(img, window=3, neighborhood=None):
    """Closing morphological filter. Closing is the erosion of the dialiation of
    values greater than 1.

    args:
        img (ee.Image): binary image to apply closing filter on
        window (int | ee.Number, optional): distance in pixels to consider for closing process
        neighborhood (int | ee.Number, optional): size of the neighborhood to perform fast
            distance trasnform. Smaller values speed up operations however must be greater
            than window. If no neighborhood is provided, it will be double the window size.
            default = None

    returns:
        ee.Image: the closed binary image

    """
    if not isinstance(window, ee.Number):
        window = ee.Number(window)

    if neighborhood is None:
        neighborhood = window.multiply(2)

    dialation = img.fastDistanceTransform(neighborhood).sqrt().lt(window)
    closed = dialation.Not().fastDistanceTransform(neighborhood).sqrt().gt(window)

    return closed.updateMask(img.mask())

gamma_map(img, window=7, enl=4.9, keep_bands=['angle'])

Gamma Map speckle filtering algorithm. Algorithm adapted from https://groups.google.com/g/google-earth-engine-developers/c/a9W0Nlrhoq0/m/tnGMC45jAgAJ.

Parameters:

Name Type Description Default
img ee.Image

Earth engine image object. Expects that imagery is a SAR image

required
window int

moving window size to apply filter (i.e. a value of 7 == 7x7 window). default = 7

7
enl float

equivalent number of looks (enl) per pixel from a SAR scan. See https://sentinel.esa.int/web/sentinel/user-guides/sentinel-1-sar/resolutions/level-1-ground-range-detected. default = 4.9

4.9
keep_bands list[str]

list of band names to drop during filtering and include in the result default = ["angle"]

['angle']

Returns:

Type Description
ee.Image

filtered SAR image using the Gamma Map algorithm

Source code in hydrafloods/filtering.py
@decorators.keep_names
@decorators.keep_attrs
def gamma_map(img, window=7, enl=4.9, keep_bands=["angle"]):
    """Gamma Map speckle filtering algorithm.
    Algorithm adapted from https://groups.google.com/g/google-earth-engine-developers/c/a9W0Nlrhoq0/m/tnGMC45jAgAJ.

    args:
        img (ee.Image): Earth engine image object. Expects that imagery is a SAR image
        window (int, optional): moving window size to apply filter (i.e. a value of 7 == 7x7 window). default = 7
        enl (float, optional): equivalent number of looks (enl) per pixel from a SAR scan.
            See https://sentinel.esa.int/web/sentinel/user-guides/sentinel-1-sar/resolutions/level-1-ground-range-detected.
            default = 4.9
        keep_bands (list[str], optional): list of band names to drop during filtering and include in the result
            default = ["angle"]

    returns:
        ee.Image: filtered SAR image using the Gamma Map algorithm
    """

    band_names = img.bandNames()
    if keep_bands is not None:
        keep_img = img.select(keep_bands)
        proc_bands = band_names.removeAll(keep_bands)
    else:
        proc_bands = band_names

    img = img.select(proc_bands)

    # Square kernel, window should be odd (typically 3, 5 or 7)
    weights = ee.List.repeat(ee.List.repeat(1, window), window)
    midPt = (window // 2) + 1 if (window % 2) != 0 else window // 2

    # ~~(window/2) does integer division in JavaScript
    kernel = ee.Kernel.fixed(window, window, weights, midPt, midPt, False)

    # Convert image from dB to natural values
    nat_img = geeutils.db_to_power(img)

    # Get mean and variance
    mean = nat_img.reduceNeighborhood(ee.Reducer.mean(), kernel)
    variance = nat_img.reduceNeighborhood(ee.Reducer.variance(), kernel)

    # "Pure speckle" threshold
    ci = variance.sqrt().divide(mean)  # square root of inverse of enl

    # If ci <= cu, the kernel lies in a "pure speckle" area -> return simple mean
    cu = 1.0 / math.sqrt(enl)

    # If cu < ci < cmax the kernel lies in the low textured speckle area -> return the filtered value
    cmax = math.sqrt(2.0) * cu

    alpha = ee.Image(1.0 + cu * cu).divide(ci.multiply(ci).subtract(cu * cu))
    b = alpha.subtract(enl + 1.0)
    d = (
        mean.multiply(mean)
        .multiply(b)
        .multiply(b)
        .add(alpha.multiply(mean).multiply(nat_img).multiply(4.0 * enl))
    )
    f = b.multiply(mean).add(d.sqrt()).divide(alpha.multiply(2.0))

    caster = ee.Dictionary.fromLists(
        proc_bands, ee.List.repeat("float", proc_bands.length())
    )
    img1 = (
        geeutils.power_to_db(mean.updateMask(ci.lte(cu)))
        .rename(proc_bands)
        .cast(caster)
    )
    img2 = (
        geeutils.power_to_db(f.updateMask(ci.gt(cu)).updateMask(ci.lt(cmax)))
        .rename(proc_bands)
        .cast(caster)
    )
    img3 = img.updateMask(ci.gte(cmax)).rename(proc_bands).cast(caster)

    # If ci > cmax do not filter at all (i.e. we don't do anything, other then masking)
    output = (
        ee.ImageCollection([img1, img2, img3])
        .reduce(ee.Reducer.firstNonNull())
        .rename(proc_bands)
        .clip(img.geometry())
    )

    if keep_bands is not None:
        output = output.addBands(keep_img)

    # Compose a 3 band image with the mean filtered "pure speckle", the "low textured" filtered and the unfiltered portions
    return output

lee_sigma(img, window=9, sigma=0.9, looks=4, tk=7, keep_bands=['angle'])

Lee Sigma speckle filtering algorithm. Implemented from interpreting https://doi.org/10.1109/TGRS.2008.2002881

Parameters:

Name Type Description Default
img ee.Image

Earth engine image object. Expects that imagery is a SAR image

required
window int

moving window size to apply filter (i.e. a value of 9 == 9x9 window). default = 9

9
sigma float

sigma lookup value from table 1 in paper. default = 0.9

0.9
looks int

look intensity value from table 1 in paper. default = 4

4
tk int

threshold value to determine values in window as point targets. default = 7

7
keep_bands list[str]

list of band names to drop during filtering and include in the result default = ["angle"]

['angle']

Returns:

Type Description
ee.Image

filtered SAR image using the Lee Sigma algorithm

Source code in hydrafloods/filtering.py
@decorators.keep_names
@decorators.keep_attrs
def lee_sigma(img, window=9, sigma=0.9, looks=4, tk=7, keep_bands=["angle"]):
    """Lee Sigma speckle filtering algorithm.
    Implemented from interpreting https://doi.org/10.1109/TGRS.2008.2002881

    args:
        img (ee.Image): Earth engine image object. Expects that imagery is a SAR image
        window (int, optional): moving window size to apply filter (i.e. a value of 9 == 9x9 window). default = 9
        sigma (float, optional): sigma lookup value from table 1 in paper. default = 0.9
        looks (int, optional): look intensity value from table 1 in paper. default = 4
        tk (int, optional): threshold value to determine values in window as point targets. default = 7
        keep_bands (list[str], optional): list of band names to drop during filtering and include in the result
            default = ["angle"]

    returns:
        ee.Image: filtered SAR image using the Lee Sigma algorithm
    """
    band_names = img.bandNames()
    if keep_bands is not None:
        keep_img = img.select(keep_bands)
        proc_bands = band_names.removeAll(keep_bands)
    else:
        proc_bands = band_names

    img = img.select(proc_bands)

    midPt = (window // 2) + 1 if (window % 2) != 0 else window // 2
    kernelWeights = ee.List.repeat(ee.List.repeat(1, window), window)
    kernel = ee.Kernel.fixed(window, window, kernelWeights, midPt, midPt)

    targetWeights = ee.List.repeat(ee.List.repeat(1, 3), 3)
    targetkernel = ee.Kernel.fixed(3, 3, targetWeights, 1, 1)

    # Lookup table for range and eta values for intensity
    sigmaLookup = ee.Dictionary(
        {
            1: ee.Dictionary(
                {
                    0.5: ee.Dictionary({"A1": 0.436, "A2": 1.92, "η": 0.4057}),
                    0.6: ee.Dictionary({"A1": 0.343, "A2": 2.21, "η": 0.4954}),
                    0.7: ee.Dictionary({"A1": 0.254, "A2": 2.582, "η": 0.5911}),
                    0.8: ee.Dictionary({"A1": 0.168, "A2": 3.094, "η": 0.6966}),
                    0.9: ee.Dictionary({"A1": 0.084, "A2": 3.941, "η": 0.8191}),
                    0.95: ee.Dictionary({"A1": 0.043, "A2": 4.840, "η": 0.8599}),
                }
            ),
            2: ee.Dictionary(
                {
                    0.5: ee.Dictionary({"A1": 0.582, "A2": 1.584, "η": 0.2763}),
                    0.6: ee.Dictionary({"A1": 0.501, "A2": 1.755, "η": 0.3388}),
                    0.7: ee.Dictionary({"A1": 0.418, "A2": 1.972, "η": 0.4062}),
                    0.8: ee.Dictionary({"A1": 0.327, "A2": 2.260, "η": 0.4819}),
                    0.9: ee.Dictionary({"A1": 0.221, "A2": 2.744, "η": 0.5699}),
                    0.95: ee.Dictionary({"A1": 0.152, "A2": 3.206, "η": 0.6254}),
                }
            ),
            3: ee.Dictionary(
                {
                    0.5: ee.Dictionary({"A1": 0.652, "A2": 1.458, "η": 0.2222}),
                    0.6: ee.Dictionary({"A1": 0.580, "A2": 1.586, "η": 0.2736}),
                    0.7: ee.Dictionary({"A1": 0.505, "A2": 1.751, "η": 0.3280}),
                    0.8: ee.Dictionary({"A1": 0.419, "A2": 1.865, "η": 0.3892}),
                    0.9: ee.Dictionary({"A1": 0.313, "A2": 2.320, "η": 0.4624}),
                    0.95: ee.Dictionary({"A1": 0.238, "A2": 2.656, "η": 0.5084}),
                }
            ),
            4: ee.Dictionary(
                {
                    0.5: ee.Dictionary({"A1": 0.694, "A2": 1.385, "η": 0.1921}),
                    0.6: ee.Dictionary({"A1": 0.630, "A2": 1.495, "η": 0.2348}),
                    0.7: ee.Dictionary({"A1": 0.560, "A2": 1.627, "η": 0.2825}),
                    0.8: ee.Dictionary({"A1": 0.480, "A2": 1.804, "η": 0.3354}),
                    0.9: ee.Dictionary({"A1": 0.378, "A2": 2.094, "η": 0.3991}),
                    0.95: ee.Dictionary({"A1": 0.302, "A2": 2.360, "η": 0.4391}),
                }
            ),
        }
    )

    # extract data from lookup
    looksDict = ee.Dictionary(sigmaLookup.get(ee.String(str(looks))))
    sigmaImage = ee.Dictionary(looksDict.get(ee.String(str(sigma)))).toImage()
    a1 = sigmaImage.select("A1")
    a2 = sigmaImage.select("A2")
    aRange = a2.subtract(a1)
    eta = sigmaImage.select("η").pow(2)

    img = geeutils.db_to_power(img)

    # MMSE estimator
    mmseMask = img.gte(a1).Or(img.lte(a2))
    mmseIn = img.updateMask(mmseMask)
    oneImg = ee.Image(1)
    z = mmseIn.reduceNeighborhood(ee.Reducer.mean(), kernel, None, True)
    varz = mmseIn.reduceNeighborhood(ee.Reducer.variance(), kernel)
    varx = (varz.subtract(z.abs().pow(2).multiply(eta))).divide(oneImg.add(eta))
    b = varx.divide(varz)
    mmse = oneImg.subtract(b).multiply(z.abs()).add(b.multiply(mmseIn))

    # workflow
    z99 = ee.Dictionary(
        img.reduceRegion(
            reducer=ee.Reducer.percentile([99], None, 255, 0.001, 1e6),
            geometry=img.geometry(),
            scale=10,
            bestEffort=True,
        )
    ).toImage()

    overThresh = img.gte(z99)

    K = overThresh.reduceNeighborhood(ee.Reducer.sum(), targetkernel, None, True)

    retainPixel = K.gte(tk)
    xHat = geeutils.power_to_db(img.updateMask(retainPixel).unmask(mmse))

    output = ee.Image(xHat).rename(proc_bands)

    if keep_bands is not None:
        output = output.addBands(keep_img)

    return output

modified_median_zscore(img, fill_img=None)

Outlier detection and filling on complete DEM using the modified z-score and a median filter Method from Iglewicz, B. and Hoaglin, D.C., 1993. How to detect and handle outliers (Vol. 16). Asq Press.

Parameters:

Name Type Description Default
img ee.Image

Earth engine image object to apply filter on

required
fill_img ee.Image

Earth engine image object to fill values resulting from filter. If None provided, the fill operation will be on the input image. Default = None

None

Returns:

Type Description
ee.Image

filtered image

Source code in hydrafloods/filtering.py
@decorators.keep_names
@decorators.keep_attrs
def modified_median_zscore(img, fill_img=None):
    """Outlier detection and filling on complete DEM using the modified z-score and a median filter
    Method from  Iglewicz, B. and Hoaglin, D.C., 1993. How to detect and handle outliers (Vol. 16). Asq Press.

    args:
        img (ee.Image): Earth engine image object to apply filter on
        fill_img (ee.Image): Earth engine image object to fill values resulting from filter.
            If None provided, the fill operation will be on the input image. Default = None

    returns:
        ee.Image: filtered image
    """

    kernel = ee.Kernel.fixed(3, 3, [[1, 1, 1], [1, 1, 1], [1, 1, 1]])
    kernel_weighted = ee.Kernel.fixed(3, 3, [[1, 1, 1], [1, 0, 1], [1, 1, 1]])

    median = img.focal_median(kernel=kernel)
    median_weighted = img.focal_median(kernel=kernel_weighted)

    diff = img.subtract(median)

    mzscore = diff.multiply(0.6745).divide(diff.abs().focal_median(kernel=kernel))

    if fill_img:
        filled = fill_img.where(mzscore.gt(3.5), median_weighted)
    else:
        filled = img.where(mzscore.gt(3.5), median_weighted)

    return filled

open_binary(img, window=3, neighborhood=None)

Opening morphological filter. Opening is the dilation of the erosion of values greater than 1.

Parameters:

Name Type Description Default
img ee.Image

binary image to apply opening filter on

required
window int | ee.Number

distance in pixels to consider for opening process

3
neighborhood int | ee.Number

size of the neighborhood to perform fast distance trasnform. Smaller values speed up operations however must be greater than window. If no nerighborhood is provided, it will be double the window size. default = None

None

Returns:

Type Description
ee.Image

the opened binary image

Source code in hydrafloods/filtering.py
@decorators.keep_names
@decorators.keep_attrs
def open_binary(img, window=3, neighborhood=None):
    """Opening morphological filter. Opening is the dilation of the erosion of
    values greater than 1.

    args:
        img (ee.Image): binary image to apply opening filter on
        window (int | ee.Number, optional): distance in pixels to consider for opening process
        neighborhood (int | ee.Number, optional): size of the neighborhood to perform fast
            distance trasnform. Smaller values speed up operations however must be greater
            than window. If no nerighborhood is provided, it will be double the window size.
            default = None

    returns:
        ee.Image: the opened binary image

    """
    if not isinstance(window, ee.Number):
        window = ee.Number(window)

    if neighborhood is None:
        neighborhood = window.multiply(2)

    erosion = img.Not().fastDistanceTransform(neighborhood).sqrt().gt(window)
    opened = erosion.fastDistanceTransform(neighborhood).sqrt().lt(window)
    return opened.updateMask(img.mask())

p_median(img, window=5, keep_bands=['angle'])

P-Median filter for smoothing imagery. Calculates the average from the median along cross and diagnal pixels of a window

Parameters:

Name Type Description Default
img ee.Image

Earth engine image object to filter

required
window int

moving window size to apply filter (i.e. a value of 5 == 5x5 window). default = 5

5

Returns:

Type Description
ee.Image

filtered image

Source code in hydrafloods/filtering.py
@decorators.keep_names
@decorators.keep_attrs
def p_median(img, window=5, keep_bands=["angle"]):
    """P-Median filter for smoothing imagery.
    Calculates the average from the median along cross and diagnal pixels of a window

    args:
        img (ee.Image): Earth engine image object to filter
        window (int, optional): moving window size to apply filter (i.e. a value of 5 == 5x5 window). default = 5

    returns:
        ee.Image: filtered image
    """

    def _band_filter(bname):
        selector = ee.List([bname])
        band_img = img.select(selector)
        hv_median = band_img.reduceNeighborhood(ee.Reducer.median(), hv_kernel)
        diag_median = band_img.reduceNeighborhood(ee.Reducer.median(), diag_kernel)
        return ee.Image(ee.Image.cat([hv_median, diag_median]).reduce("mean")).rename(
            selector
        )

    if window % 2 == 0:
        window += 1

    center_idx = (window - 1) // 2

    hv = [
        [1 if i == center_idx or j == center_idx else 0 for j in range(window)]
        for i in range(window)
    ]
    diag = [
        [1 if i == j or i == ((window - 1) - j) else 0 for j in range(window)]
        for i in range(window)
    ]

    # method based on ???
    band_names = img.bandNames()
    hv_weights = ee.List(hv)
    diag_weights = ee.List(diag)

    hv_kernel = ee.Kernel.fixed(window, window, hv_weights)
    diag_kernel = ee.Kernel.fixed(window, window, diag_weights)

    reduced_bands = ee.ImageCollection.fromImages(
        band_names.map(_band_filter)
    ).toBands()

    return reduced_bands.rename(band_names)

perona_malik(img, n_iters=10, K=3, method=1)

Perona-Malik (anisotropic diffusion) convolution Developed by Gennadii Donchyts see https://groups.google.com/g/google-earth-engine-developers/c/umGlt5qIN1I/m/PD8lsJ7qBAAJ I(n+1, i, j) = I(n, i, j) + lambda * (cN * dN(I) + cS * dS(I) + cE * dE(I), cW * dW(I))

Parameters:

Name Type Description Default
img ee.Image

Earth engine image object. Expects that imagery is a SAR image

required
n_iters int

Number of interations to apply filter K (int,optional): moving window size to apply filter (i.e. a value of 7 == 7x7 window). default = 3

10
method int

choose method 1 (default) or 2

1

Returns:

Type Description
ee.Image

filtered SAR image using the perona malik algorithm

Source code in hydrafloods/filtering.py
@decorators.keep_names
@decorators.keep_attrs
def perona_malik(img, n_iters=10, K=3, method=1):
    """Perona-Malik (anisotropic diffusion) convolution
    Developed by Gennadii Donchyts see https://groups.google.com/g/google-earth-engine-developers/c/umGlt5qIN1I/m/PD8lsJ7qBAAJ
    I(n+1, i, j) = I(n, i, j) + lambda * (cN * dN(I) + cS * dS(I) + cE * dE(I), cW * dW(I))

    args:
        img (ee.Image): Earth engine image object. Expects that imagery is a SAR image
        n_iters (int, optional): Number of interations to apply filter
            K (int,optional): moving window size to apply filter (i.e. a value of 7 == 7x7 window). default = 3
        method (int, optional): choose method 1 (default) or 2
    returns:
        ee.Image: filtered SAR image using the perona malik algorithm
    """

    def _method_1(dI_W, dI_E, dI_N, dI_S):
        cW = dI_W.pow(2).multiply(k1).exp()
        cE = dI_E.pow(2).multiply(k1).exp()
        cN = dI_N.pow(2).multiply(k1).exp()
        cS = dI_S.pow(2).multiply(k1).exp()

        return cW, cE, cN, cS

    def _method_2(dI_W, dI_E, dI_N, dI_S):
        cW = one.divide(one.add(dI_W.pow(2).divide(k2)))
        cE = one.divide(one.add(dI_E.pow(2).divide(k2)))
        cN = one.divide(one.add(dI_N.pow(2).divide(k2)))
        cS = one.divide(one.add(dI_S.pow(2).divide(k2)))

        return cW, cE, cN, cS

    # covnert db to natural units  before applying filter
    power = geeutils.db_to_power(img)

    dxW = ee.Kernel.fixed(3, 3, [[0, 0, 0], [1, -1, 0], [0, 0, 0]])
    dxE = ee.Kernel.fixed(3, 3, [[0, 0, 0], [0, -1, 1], [0, 0, 0]])
    dyN = ee.Kernel.fixed(3, 3, [[0, 1, 0], [0, -1, 0], [0, 0, 0]])
    dyS = ee.Kernel.fixed(3, 3, [[0, 0, 0], [0, -1, 0], [0, 1, 0]])

    one = ee.Image.constant(1.0)
    l = ee.Image.constant(0.2)
    k = ee.Image.constant(K)
    k1 = ee.Image.constant(-1.0 / K)
    k2 = k.pow(2)

    if method == 1:
        _method = _method_1
    elif method == 2:
        _method = _method_2
    else:
        raise NotImplementedError(
            "Could not determine algorithm to apply filter...options for `method` are 1 or 2"
        )

    for i in range(n_iters):
        dI_W = power.convolve(dxW)
        dI_E = power.convolve(dxE)
        dI_N = power.convolve(dyN)
        dI_S = power.convolve(dyS)

        cW, cE, cN, cS = _method(dI_W, dI_E, dI_N, dI_S)

        power = power.add(
            l.multiply(
                cN.multiply(dI_N)
                .add(cS.multiply(dI_S))
                .add(cE.multiply(dI_E))
                .add(cW.multiply(dI_W))
            )
        )

    # covnert natural to db units after filter is done
    img = geeutils.power_to_db(power)

    return img

refined_lee(image, keep_bands=['angle'])

Refined Lee speckle filtering algorithm. Algorithm adapted from https://groups.google.com/g/google-earth-engine-developers/c/ExepnAmP-hQ/m/7e5DnjXXAQAJ

Parameters:

Name Type Description Default
image ee.Image

Earth engine image object. Expects that imagery is a SAR image

required

Returns:

Type Description
ee.Image

filtered SAR image using the Refined Lee algorithm

Source code in hydrafloods/filtering.py
@decorators.keep_names
@decorators.keep_attrs
def refined_lee(image, keep_bands=["angle"]):
    """Refined Lee speckle filtering algorithm.
    Algorithm adapted from https://groups.google.com/g/google-earth-engine-developers/c/ExepnAmP-hQ/m/7e5DnjXXAQAJ

    args:
        image (ee.Image): Earth engine image object. Expects that imagery is a SAR image

    returns:
        ee.Image: filtered SAR image using the Refined Lee algorithm
    """
    # TODO: include keep bands...maybe one-shot filtering if using keep_bands???
    def apply_filter(b):
        """Closure function to apply the refined lee algorithm on individual bands"""
        b = ee.String(b)
        img = power.select(b)

        # img must be in natural units, i.e. not in dB!
        # Set up 3x3 kernels
        weights3 = ee.List.repeat(ee.List.repeat(1, 3), 3)
        kernel3 = ee.Kernel.fixed(3, 3, weights3, 1, 1, False)

        mean3 = img.reduceNeighborhood(ee.Reducer.mean(), kernel3)
        variance3 = img.reduceNeighborhood(ee.Reducer.variance(), kernel3)

        # Use a sample of the 3x3 windows inside a 7x7 windows to determine gradients and directions
        sample_weights = ee.List(
            [
                [0, 0, 0, 0, 0, 0, 0],
                [0, 1, 0, 1, 0, 1, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 1, 0, 1, 0, 1, 0],
                [0, 0, 0, 0, 0, 0, 0],
                [0, 1, 0, 1, 0, 1, 0],
                [0, 0, 0, 0, 0, 0, 0],
            ]
        )

        sample_kernel = ee.Kernel.fixed(7, 7, sample_weights, 3, 3, False)

        # Calculate mean and variance for the sampled windows and store as 9 bands
        sample_mean = mean3.neighborhoodToBands(sample_kernel)
        sample_var = variance3.neighborhoodToBands(sample_kernel)

        # Determine the 4 gradients for the sampled windows
        gradients = sample_mean.select(1).subtract(sample_mean.select(7)).abs()
        gradients = gradients.addBands(
            sample_mean.select(6).subtract(sample_mean.select(2)).abs()
        )
        gradients = gradients.addBands(
            sample_mean.select(3).subtract(sample_mean.select(5)).abs()
        )
        gradients = gradients.addBands(
            sample_mean.select(0).subtract(sample_mean.select(8)).abs()
        )

        # And find the maximum gradient amongst gradient bands
        max_gradient = gradients.reduce(ee.Reducer.max())

        # Create a mask for band pixels that are the maximum gradient
        gradmask = gradients.eq(max_gradient)

        # duplicate gradmask bands: each gradient represents 2 directions
        gradmask = gradmask.addBands(gradmask)

        # Determine the 8 directions
        directions = (
            sample_mean.select(1)
            .subtract(sample_mean.select(4))
            .gt(sample_mean.select(4).subtract(sample_mean.select(7)))
            .multiply(1)
        )
        directions = directions.addBands(
            sample_mean.select(6)
            .subtract(sample_mean.select(4))
            .gt(sample_mean.select(4).subtract(sample_mean.select(2)))
            .multiply(2)
        )
        directions = directions.addBands(
            sample_mean.select(3)
            .subtract(sample_mean.select(4))
            .gt(sample_mean.select(4).subtract(sample_mean.select(5)))
            .multiply(3)
        )
        directions = directions.addBands(
            sample_mean.select(0)
            .subtract(sample_mean.select(4))
            .gt(sample_mean.select(4).subtract(sample_mean.select(8)))
            .multiply(4)
        )
        # The next 4 are the not() of the previous 4
        directions = directions.addBands(directions.select(0).Not().multiply(5))
        directions = directions.addBands(directions.select(1).Not().multiply(6))
        directions = directions.addBands(directions.select(2).Not().multiply(7))
        directions = directions.addBands(directions.select(3).Not().multiply(8))

        # Mask all values that are not 1-8
        directions = directions.updateMask(gradmask)

        # "collapse" the stack into a singe band image (due to masking, each pixel has just one value (1-8) in it's directional band, and is otherwise masked)
        directions = directions.reduce(ee.Reducer.sum())

        sample_stats = sample_var.divide(sample_mean.multiply(sample_mean))

        # Calculate localNoiseVariance
        sigmaV = (
            sample_stats.toArray()
            .arraySort()
            .arraySlice(0, 0, 5)
            .arrayReduce(ee.Reducer.mean(), [0])
        )

        # Set up the 7*7 kernels for directional statistics
        rect_weights = ee.List.repeat(ee.List.repeat(0, 7), 3).cat(
            ee.List.repeat(ee.List.repeat(1, 7), 4)
        )

        diag_weights = ee.List(
            [
                [1, 0, 0, 0, 0, 0, 0],
                [1, 1, 0, 0, 0, 0, 0],
                [1, 1, 1, 0, 0, 0, 0],
                [1, 1, 1, 1, 0, 0, 0],
                [1, 1, 1, 1, 1, 0, 0],
                [1, 1, 1, 1, 1, 1, 0],
                [1, 1, 1, 1, 1, 1, 1],
            ]
        )

        rect_kernel = ee.Kernel.fixed(7, 7, rect_weights, 3, 3, False)
        diag_kernel = ee.Kernel.fixed(7, 7, diag_weights, 3, 3, False)

        # Create stacks for mean and variance using the original kernels. Mask with relevant direction.
        dir_mean = img.reduceNeighborhood(ee.Reducer.mean(), rect_kernel).updateMask(
            directions.eq(1)
        )
        dir_var = img.reduceNeighborhood(ee.Reducer.variance(), rect_kernel).updateMask(
            directions.eq(1)
        )

        dir_mean = dir_mean.addBands(
            img.reduceNeighborhood(ee.Reducer.mean(), diag_kernel).updateMask(
                directions.eq(2)
            )
        )
        dir_var = dir_var.addBands(
            img.reduceNeighborhood(ee.Reducer.variance(), diag_kernel).updateMask(
                directions.eq(2)
            )
        )

        # and add the bands for rotated kernels
        for i in range(1, 4):
            dir_mean = dir_mean.addBands(
                img.reduceNeighborhood(
                    ee.Reducer.mean(), rect_kernel.rotate(i)
                ).updateMask(directions.eq(2 * i + 1))
            )
            dir_var = dir_var.addBands(
                img.reduceNeighborhood(
                    ee.Reducer.variance(), rect_kernel.rotate(i)
                ).updateMask(directions.eq(2 * i + 1))
            )
            dir_mean = dir_mean.addBands(
                img.reduceNeighborhood(
                    ee.Reducer.mean(), diag_kernel.rotate(i)
                ).updateMask(directions.eq(2 * i + 2))
            )
            dir_var = dir_var.addBands(
                img.reduceNeighborhood(
                    ee.Reducer.variance(), diag_kernel.rotate(i)
                ).updateMask(directions.eq(2 * i + 2))
            )

        # "collapse" the stack into a single band image (due to masking, each pixel has just one value in it's directional band, and is otherwise masked)
        dir_mean = dir_mean.reduce(ee.Reducer.sum())
        dir_var = dir_var.reduce(ee.Reducer.sum())

        # A finally generate the filtered value
        varX = dir_var.subtract(dir_mean.multiply(dir_mean).multiply(sigmaV)).divide(
            sigmaV.add(1.0)
        )

        b = varX.divide(dir_var)

        # return multi-band image band from array
        return (
            dir_mean.add(b.multiply(img.subtract(dir_mean)))
            .arrayProject([0])
            .arrayFlatten([["sum"]])
            .float()
        )

    band_names = image.bandNames()
    if keep_bands is not None:
        keep_img = image.select(keep_bands)
        proc_bands = band_names.removeAll(keep_bands)
    else:
        proc_bands = band_names

    image = image.select(proc_bands)

    power = geeutils.db_to_power(image)

    result = ee.ImageCollection(proc_bands.map(apply_filter)).toBands()

    output = geeutils.power_to_db(ee.Image(result)).rename(proc_bands)

    if keep_bands is not None:
        output = output.addBands(keep_img)

    return output