4

I am detecting edges of round objects and am obtaining "bumpy" irregular edges. Is there away to smooth the edges so that I have a more uniform shape?

For example, in the code below I generate a "bumpy" circle (left). Is there a smoothing or moving average kind of function I could use to obtain or approximate the "smooth" circle (right). Preferably with some sort of parameter I can control as my actual images arn't perfectly circular.

import numpy as np
import matplotlib.pyplot as plt

fig, (bumpy, smooth) = plt.subplots(ncols=2, figsize=(14, 7))

an = np.linspace(0, 2 * np.pi, 100) 

bumpy.plot(3 * np.cos(an) + np.random.normal(0,.03,100), 3 * np.sin(an) + np.random.normal(0,.03,100))

smooth.plot(3 * np.cos(an), 3 * np.sin(an))

bumpy and smooth circle

G_T
  • 1,555
  • 1
  • 18
  • 34
  • What is your expected input and output? The images? – DYZ Jul 10 '18 at 07:40
  • either smooth points by FIR filter (weighted average from itself and neighbors it maight need aditional correction of center position and scale to avoid shifting position and changing size on heavy filtering) or [fit expected shape](https://stackoverflow.com/a/36054594/2521214) through your dataset (circle with minimal distance to your points etc. – Spektre Jul 10 '18 at 08:03
  • @DyZ Input is a list or array e.g. `zip(3 * np.cos(an) + np.random.normal(0,.03,100), 3 * np.sin(an) + np.random.normal(0,.03,100))` output is similar e.g. `zip(3 * np.cos(an), 3 * np.sin(an))` – G_T Jul 10 '18 at 08:08
  • @Spektre I'll look into a FIR filter. – G_T Jul 10 '18 at 08:11
  • 1
    @G_T added small C++ example (sorry not a python coder) as an answer – Spektre Jul 10 '18 at 09:06
  • Cool question and you have got some really first-rate answers here - all worthy of upvotes! – Mark Setchell Jul 12 '18 at 12:29

3 Answers3

8

If the shapes in question approximate ellipses, and you’d like to force them to be actual ellipses, you can easily fit an ellipse by computing the moments of inertia of the set of points, and figure the parameters of the ellipse from those. This Q&A shows how to accomplish that using MATLAB, the code is easy to translate to Python:

import numpy as np
import matplotlib.pyplot as plt

# Some test data (from Question, but with offset added):
an = np.linspace(0, 2 * np.pi, 100) 
x = 3 * np.cos(an) + np.random.normal(0,.03,100) + 3.8
y = 3 * np.sin(an) + np.random.normal(0,.03,100) + 5.4

plt.plot(x, y)

# Approximate the ellipse:
L, V = np.linalg.eig(np.cov(x, y))
r = np.sqrt(2*L)                       # radius
cos_phi = V[0, 0]
sin_phi = V[1, 0]                      # two components of minor axis direction
m = np.array([np.mean(x), np.mean(y)]) # centroid

# Draw the ellipse:
x_approx = r[0] * np.cos(an) * cos_phi - r[1] * np.sin(an) * sin_phi + m[0];
y_approx = r[0] * np.cos(an) * sin_phi + r[1] * np.sin(an) * cos_phi + m[1];

plt.plot(x_approx, y_approx, 'r')
plt.show()

fitted ellipse

The centroid calculation above works because the points are uniformly distributed around the ellipse. If this is not the case, a slightly more complex centroid computation is needed.

Cris Luengo
  • 55,762
  • 10
  • 62
  • 120
  • i feel like this should be a comment, unless you also add some sample python code for the OP – Haleemur Ali Jul 10 '18 at 16:28
  • 1
    @HaleemurAli As far as I know, there is no requirement for answers to either be complete or contain code - sometimes an insight from an experienced person is all that is required. Sometimes one person knows half the answer and someone else can contribute the other half and I think SO would be a poorer place if it didn't permit partial/incomplete or code-free answers. – Mark Setchell Jul 10 '18 at 17:35
  • 1
    @HaleemurAli: I don't agree at all, but I do agree that a more explicit answer with code is more useful. It's just not easy to write code on a phone... I've added the code now. – Cris Luengo Jul 10 '18 at 21:21
  • 1
    @CrisLuengo, thanks for updating the answer. I was wrong in the wording of my comment. What I should have said was "this answer would be much better if there were actual python code accompanying it". If a viewer who came to this post is familiar with python but not with matlab, then the link to the other discussion would be less useful to them. – Haleemur Ali Jul 10 '18 at 21:35
  • Great thinking - nice approach. – Mark Setchell Jul 12 '18 at 12:28
7

You can do this in frequency domain. Take the (x,y) coordinates of the points of your curve and construct the signal as signal = x + yj, then take the Fourier transform of this signal. Filter out the high frequency components, then take the inverse Fourier transform and you'll get a smooth curve. You can control the smoothness by adjusting the cutoff frequency.

Here's an example:

import numpy as np
from matplotlib import pyplot as plt

r = 3
theta = np.linspace(0, 2 * np.pi, 100) 
noise_level = 2
# construct the signal
x = r *  np.cos(theta) + noise_level * np.random.normal(0,.03,100)
y = r *  np.sin(theta) + noise_level * np.random.normal(0,.03,100)
signal = x + 1j*y
# FFT and frequencies
fft = np.fft.fft(signal)
freq = np.fft.fftfreq(signal.shape[-1])
# filter
cutoff = 0.1
fft[np.abs(freq) > cutoff] = 0
# IFFT
signal_filt = np.fft.ifft(fft)

plt.figure()
plt.subplot(121)
plt.axis('equal')
plt.plot(x, y, label='Noisy')
plt.subplot(122)
plt.axis('equal')
plt.plot(signal_filt.real, signal_filt.imag, label='Smooth')

figure

dhanushka
  • 10,492
  • 2
  • 37
  • 47
  • I think the real trick here is not going to the Fourier domain, so much more as reworking your cartesian coordinates in the complex plane. This is really nice idea. Unfortunately, it looks to me that is is not so easy to apply to higher dimensionality problems (which is not the scope of the question anyway). – norok2 Jul 10 '18 at 12:43
  • @norok2 Actually I drew this idea from [Fourier Descriptors](http://demonstrations.wolfram.com/FourierDescriptors/). To my knowledge (it doesn't go beyond 2D though), we should be able to apply this to smooth high dimensional surfaces. I don't think that's possible for high dimensional curves. – dhanushka Jul 10 '18 at 15:17
  • Great answer. Can you give any further insights onto the `cutoff` parameter please so I can learn something? What are its units and how could you work out a sensible range of values other than by experimenting empirically? – Mark Setchell Jul 12 '18 at 12:25
  • @MarkSetchell `cutoff` is a frequency. Its units can vary depending on your `signal`. Basically, frequency is an indicator of how fast your signal varies. If you inspect the frequency spectrum of your signal, high frequencies correspond to the fast varying components of the signal. So, if you don't want frequencies beyond a certain value, you can remove them in frequency domain representation of the signal, then take the inverse transform. So, this is like a crude low-pass filter. – dhanushka Jul 12 '18 at 15:42
  • @MarkSetchell, and anyone else wanting to learn: Bin 0 in `fft` represents the centroid of the circle. Bins 1 and -1 (the second one and the last one) together represent the radii (this is one period of a sine wave in both real and imaginary components, with appropriate scaling and shifting). Thus, with the first three values you can encode any ellipse. Bins 2 and -2 together represent the next higher mode of variation (two periods of a sine wave), with this you can encode a more complex smooth shape. The more components you add, the more complexity you add to the shape. – Cris Luengo Jul 13 '18 at 04:07
  • @CrisLuengo and dhanushka Thank you both for taking the time to "bring me along". – Mark Setchell Jul 13 '18 at 06:20
3

I recommend to use FIR filter. The idea behind is to use weighted average of points arround filtered point... You just do something like

p(i) = 0.5*p(i) + 0.25*p(i-1) + 0.25*p(i+1) 

where p(i) is i-th point. Its a good idea to remember original p(i) as it is used for next iteration (to avoid shifting). You can do each axis separately. You can use any weights but their sum should be 1.0. You can use any number of neighbors not just 2 (but in such case you need to remember more points). Symmetric weights will reduce shifting. You can apply FIR multiple times ...

Here small 2D C++ example:

//---------------------------------------------------------------------------
const int n=50;     // points
float pnt[n][2];    // points x,y ...
//---------------------------------------------------------------------------
void pnt_init()
    {
    int i;
    float a,da=2.0*M_PI/float(n),r;
    Randomize();
    for (a=0.0,i=0;i<n;i++,a+=da)
        {
        r=0.75+(0.2*Random());
        pnt[i][0]=r*cos(a);
        pnt[i][1]=r*sin(a);
        }
    }
//---------------------------------------------------------------------------
void pnt_smooth()
    {
    int i,j;
    float p0[2],*p1,*p2,tmp[2],aa0[2],aa1[2],bb0[2],bb1[2];
    // bb = original BBOX
    for (j=0;j<2;j++) { bb0[j]=pnt[0][j]; bb1[j]=pnt[0][j]; }
    for (i=0;i<n;i++)
     for (p1=pnt[i],j=0;j<2;j++)
        {
        if (bb0[j]>p1[j]) bb0[j]=p1[j];
        if (bb1[j]<p1[j]) bb1[j]=p1[j];
        }
    // FIR filter
    for (j=0;j<2;j++) p0[j]=pnt[n-1][j];                    // remember p[i-1]
    p1=pnt[0]; p2=pnt[1];                                   // pointers to p[i],p[i+1]
    for (i=0;i<n;i++,p1=p2,p2=pnt[(i+1)%n])
        {
        for (j=0;j<2;j++)
            {
            tmp[j]=p1[j];                                   // store original p[i]
            p1[j]=(0.1*p0[j]) + (0.8*p1[j]) + (0.1*p2[j]);  // p[i] = FIR(p0,p1,p2)
            p0[j]=tmp[j];                                   // remeber original p1 as p[i-1] for next iteration
            }
        }
    // aa = new BBOX
    for (j=0;j<2;j++) { aa0[j]=pnt[0][j]; aa1[j]=pnt[0][j]; }
    for (i=0;i<n;i++)
     for (p1=pnt[i],j=0;j<2;j++)
        {
        if (aa0[j]>p1[j]) aa0[j]=p1[j];
        if (aa1[j]<p1[j]) aa1[j]=p1[j];
        }
    // compute scale transform aa -> bb
    for (j=0;j<2;j++) tmp[j]=(bb1[j]-bb0[j])/(aa1[j]-aa0[j]);   // scale
    // convert aa -> bb
    for (i=0;i<n;i++)
     for (p1=pnt[i],j=0;j<2;j++)
      p1[j]=bb0[0]+((p1[j]-aa0[j])*tmp[j]);
    }
//---------------------------------------------------------------------------

I added also checking BBOX before and after smoothing so the shape does not change size and position. In some cases centroid is better than BBOX for the position correction.

Here preview of multiple application of FIR filter:

preview

Spektre
  • 49,595
  • 11
  • 110
  • 380
  • The bounding box scaling is a clever solution to the shrinking effect of smoothing a curve. I had not thought of that before. The only issue there is that you depend on max and min of noisy data... How about preserving the area? – Cris Luengo Jul 10 '18 at 14:23
  • 1
    @CrisLuengo you can use any criteria ... as I mentioned centroid is better and using area should be better too you just need to replace the bbox computation by area of polygon. – Spektre Jul 10 '18 at 15:11
  • 1
    @MarkSetchell thx I grab it with my GIF capture screen app I made (pure mine C++ code no 3th party libs other than VCL) to make gfx for my answers easier (as imgur allows only GIF animations). I set the fps to 1 and manually apply FIR filter by mouse wheel to animate :)... (all animations in my answers are done with the grabber) – Spektre Jul 12 '18 at 14:26