Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

stough 202-

Preview: Generic (e.g., non-linear) filtering
  1. Defining and applying a generic filter

  2. A non-linear filter: local standard deviation

  3. 🔨 Your Turn: Other Neighborhood Functions

  4. 🔨 Your Turn: Salt-and-Pepper Noise

In this brief demo we use scipy.ndimage.generic_filter to compute non-linear filters on an image, such as min/max/median and others. This works a lot more slowly than the correlate we used for the linear filters.

Every filter we’ve used so far has been linear: each output pixel is a fixed weighted sum of its neighborhood, which is why a single kernel of weights describes the whole operation. But there are useful neighborhood operations that can’t be written as a weighted sum: the minimum or maximum of the neighborhood, its median, its range, and so on. These are non-linear filters. They still slide a window over the image and compute one output per pixel, but the computation can be any function of the pixels in the window.

Define the generic filtering function we want

This function will be applied to every footprint or neighborhood in the image. generic_filter hands the function a flat 1D array of the neighborhood’s values (121 of them for an 11×1111 \times 11 window), and the function returns a single number: the output pixel.

Apply the function to each channel separately.

We could alternatively define the footprint or size to the generic_filter call to include the separate channels (e.g., size = (3,3,3)), but it’s easier to think about this way.

We’ll also normalize the image to float [0,1][0,1] for visualization purposes.

((687, 1024, 3), dtype('float64'), np.float64(0.0), np.float64(1.0))
<Figure size 900x300 with 2 Axes>
<Figure size 800x300 with 2 Axes>

The mean filter gives the same result as correlating with the 11×1111 \times 11 box kernel in the low-pass demo, but it takes much longer to compute. correlate runs its loop over pixels in compiled C code, while generic_filter has to call our Python function once for every pixel of every channel. That’s millions of Python function calls for this image. (If you need a fast version of a common non-linear filter, scipy.ndimage has dedicated ones, such as median_filter, minimum_filter and maximum_filter.)

 

A Non-linear Filter: Local Standard Deviation

The mean is in fact a linear operator, so we’re not really looking into what these local non-linear operators can do for us yet. Let’s try one that isn’t: the standard deviation of the neighborhood,

σi,j=1n∑(k,l)∈N(i,j)(pk,l−pˉi,j)2\sigma_{i,j} = \sqrt{\frac{1}{n}\sum_{(k,l) \in N(i,j)} \left(p_{k,l} - \bar{p}_{i,j}\right)^2}

where N(i,j)N(i,j) is the nn-pixel window around pixel (i,j)(i,j) and pˉi,j\bar{p}_{i,j} is its mean. This measures how busy each neighborhood is: it is 0 wherever the window is flat, whatever the brightness, and large wherever the window straddles an edge or texture. No kernel of weights can compute it, because of the squaring and the square root. One way to see this: for any linear filter, filtering the sum of two images gives the sum of their filtered results, but the busyness of I+JI + J is not the busyness of II plus the busyness of JJ (think of J=−IJ = -I).

We’ll compute it on a grayscale version of the image, with a 7×77 \times 7 window, and time it.

generic_filter: 16.04 seconds
((687, 1024), dtype('float64'), np.float64(0.002168262054284781), np.float64(0.29885254555571894))
<Figure size 800x300 with 2 Axes>

The local standard deviation is dark on the smooth walls and on the faces of the columns, and bright along the outlines of the arches and columns, the ceiling panels, and the umbrellas, flowers, and foliage. (Most of its values are small: half of the pixels are under 0.04, and the brightest edges reach about 0.3, on an image in [0,1][0,1].) A map like this is useful whenever we want to treat busy and flat regions differently: deciding where a compression scheme should spend its bits (compare the block transforms to come), where noise will be visible, or where one texture ends and another begins.

Building it from linear filters

Even though the standard deviation isn’t linear, we can assemble it from linear pieces. The variance is the mean of the squares minus the square of the mean,

σ2=p2‾−pˉ 2,\sigma^2 = \overline{p^2} - \bar{p}^{\,2},

and both of those local means are plain box filters. uniform_filter is the box filter, running in compiled code. Everything else is a per-pixel (point) operation.

two box filters: 0.029 seconds
largest difference from generic_filter: 4.873219883183566e-13

The two results agree to within floating point round-off, and the version built from box filters is a couple of hundred times faster. This is a common pattern: generic_filter is the most flexible way to try out an idea, since any Python function will do, and once the idea works we look for a way to express it with fast linear filters and point operations. Not every non-linear filter can be rewritten this way, though, as you’re about to find out.

 

🔨 Your Turn: Other Neighborhood Functions

Task: Try one of the other neighborhood functions defined at the top, like min or median, or even better, write one of your own. Apply them to the image, zoom into the same region in each result, and explain why they look different from each other and from the mean.

Guidance:

  • min_func, max_func and med_func are already defined above. Your own function receives the window as a flat 1D array and must return a single number.

  • generic_filter is slow, so work on a crop of the image (a few hundred pixels on a side) while you experiment, or on the grayscale Ig rather than all three channels.

  • vis_pair shares the axes of its two images, so zooming into one zooms the other. That makes it easy to compare the same region.

  • To explain the differences, think about what each function does with a small bright spot, a small dark spot, and an edge between a bright and a dark region inside the window.

 

🔨 Your Turn: Salt-and-Pepper Noise

A faulty sensor or a bad transmission can leave isolated pixels stuck at full black or full white. This is called salt-and-pepper noise. Smoothing is the usual cure for noise, so let’s see which of our neighborhood functions does the best job here.

Task: Corrupt the image with salt-and-pepper noise, then try to remove it with the mean filter and with each of your non-linear filters. Which one cleans it up best, and what does each do to the rest of the image? Explain why the winner wins.

Guidance:

  • To make the noise, draw a random number per pixel with np.random.random(Ig.shape), and set the pixels whose number falls below some small fraction (say 5%) to 0, and those above 1 minus that fraction to 1. Work on a copy so the clean image is still available.

  • Judge the results by eye and with a number: the mean absolute difference from the clean image is a simple choice. Try window sizes of 3 and 5.

  • Think about how each filter’s output changes when a single value in its window is replaced by an extreme one.

  • Can the winning filter be built from linear filters and point operations, the way the standard deviation could?