3

I am trying to detect the horizon in images taken from high altitude, so as to determine the orientation of the camera. I am also trying to have this run fast - ideally, I'd like to be able to process frames in real time (that is, a few frames per second) on a Raspberry Pi. The approach I've been taking so far is based on the fact that at high altitudes the sky is very dark, like so:

Earth from space

What I've tried is taking samples from all over the image and separating them into light and dark samples, and drawing a line between them. However, this places the horizon above its actual location, due to the fuzzyness of the amosphere:

And here's my code (in Javascript for ease of web demo):

function mag(arr) {
    return Math.sqrt(arr[0]*arr[0]+arr[1]*arr[1]+arr[2]*arr[2])
}
// return (a, b) that minimize
// sum_i r_i * (a*x_i+b - y_i)^2
function linear_regression( xy ) {
    var i,
        x, y,
        sumx=0, sumy=0, sumx2=0, sumy2=0, sumxy=0, sumr=0,
        a, b;
    for(i=0;i<xy.length;i++) {
        x = xy[i][0]; y = xy[i][2];
        r = 1
        sumr += r;
        sumx += r*x;
        sumx2 += r*(x*x);
        sumy += r*y;
        sumy2 += r*(y*y);
        sumxy += r*(x*y);
    }
    b = (sumy*sumx2 - sumx*sumxy)/(sumr*sumx2-sumx*sumx);
    a = (sumr*sumxy - sumx*sumy)/(sumr*sumx2-sumx*sumx);
    return [a, b];
}


var vals = []
for (var i=0; i<resolution; i++) {
            vals.push([])
            for (var j=0; j<resolution; j++) {
                    x = (canvas.width/(resolution+1))*(i+0.5)
                    y = (canvas.height/(resolution+1))*(j+0.5)
                    var pixelData = cr.getImageData(x, y, 1, 1).data;
                    vals[vals.length-1].push([x,y,pixelData])
                    cr.fillStyle="rgb("+pixelData[0]+","+pixelData[1]+","+pixelData[2]+")"
                    cr.strokeStyle="rgb(255,255,255)"
                    cr.beginPath()
                    cr.arc(x,y,10,0,2*Math.PI)
                   cr.fill()
                    cr.stroke()
            }
    }
    var line1 = []
    var line2 = []
    for (var i in vals) {
            i = parseInt(i)
            for (var j in vals[i]) {
                    j = parseInt(j)
                    if (mag(vals[i][j][3])<minmag) {
                            if ((i<(vals.length-2) ? mag(vals[i+1][j][4])>minmag : false)
                             || (i>0 ? mag(vals[i-1][j][5])>minmag : false)
                             || (j<(vals[i].length-2) ? mag(vals[i][j+1][6])>minmag : false)
                             || (j>0 ? mag(vals[i][j-1][7])>minmag : false)) {
                                    cr.strokeStyle="rgb(255,0,0)"
                                    cr.beginPath()
                                    cr.arc(vals[i][j][0],vals[i][j][8],10,0,2*Math.PI)
                                    cr.stroke()
                                    line1.push(vals[i][j])
                            }
                    }
                    else if (mag(vals[i][j][9])>minmag) {
                            if ((i<(vals.length-2) ? mag(vals[i+1][j][10])<minmag : false)
                             || (i>0 ? mag(vals[i-1][j][11])<minmag : false)
                             || (j<(vals[i].length-2) ? mag(vals[i][j+1][12])<minmag : false)
                             || (j>0 ? mag(vals[i][j-1][13])<minmag : false)) {
                                    cr.strokeStyle="rgb(0,0,255)"
                                    cr.beginPath()
                                    cr.arc(vals[i][j][0],vals[i][j][14],10,0,2*Math.PI)
                                    cr.stroke()
                                    line2.push(vals[i][j])
                            }
                    }
            }
        }
        eq1 = linear_regression(line1)
        cr.strokeStyle = "rgb(255,0,0)"
        cr.beginPath()
        cr.moveTo(0,eq1[1])
        cr.lineTo(canvas.width,canvas.width*eq1[0]+eq1[1])
        cr.stroke()
        eq2 = linear_regression(line2)
        cr.strokeStyle = "rgb(0,0,255)"
        cr.beginPath()
        cr.moveTo(0,eq2[1])
        cr.lineTo(canvas.width,canvas.width*eq2[0]+eq2[1])
        cr.stroke()
        eq3 = [(eq1[0]+eq2[0])/2,(eq1[1]+eq2[1])/2]
        cr.strokeStyle = "rgb(0,255,0)"
        cr.beginPath()
        cr.moveTo(0,eq3[1])
        cr.lineTo(canvas.width,canvas.width*eq3[0]+eq3[1])
        cr.stroke()

And the result (green line is the detected horizon, red and blue are estimated outside bounds):

enter image description here

How can I improve this? And is there a more efficient way to do it? The final program will probably be written in Python, or C if that's too slow.

Jan Doggen
  • 8,799
  • 13
  • 70
  • 144
Skyler
  • 909
  • 1
  • 10
  • 24
  • You can do a floodfill with a certain threshold. ( and code review is probably the correct site for this ) – this Mar 05 '14 at 02:28
  • Have you tried turning using HSV colors and just using the V? Seems like your horizon is pitch black and everything else isn't, so you should be able to get very consistent low value readings and it should work fast enough for what you are describing. – ruedamanuel Mar 05 '14 at 02:37
  • The horizon isn't necessarily going to be pitch black. It might be dark blue; and I'm still trying to find the edge of the ground, not the edge of the air. – Skyler Mar 05 '14 at 02:53
  • I don't suppose you know the altitude at which the picture was taken? – Nuclearman Mar 05 '14 at 02:54
  • The earth and the atmosphere is curved at the same angle, perhaps you could use that to offset the horizon. – Nuclearman Mar 05 '14 at 02:56
  • I don't, no. Also, I'm not sure what to do if the sun gets into the image, like so: http://gaynorsnewsblog.files.wordpress.com/2010/03/icarus-3-1.jpg – Skyler Mar 05 '14 at 03:06
  • btw on high altitudes the atmosphere is very thin so diffuse effects are very different, if you get sun into view then there are most likely some camera filters+cutoffs applied to prevent damage of the sensor (direct sunlight in sparse atmosphere is deadly for pixels) so the image can be distorted and even with filtering you probably need to do different approach for horizont detection. – Spektre Mar 05 '14 at 16:25

2 Answers2

3

Consider some basic channel mixing and thresholding, followed by vertical samples as @Spektre suggests. [Edited to change to 2*R-B instead of R+G-B following @Spektre's comment]

Here are some options on the channel mixing:

multiplechoice

  1. Original
  2. Flat mono mix R+G+B
  3. Red channel
  4. 2*R - B
  5. R + G - B

It looks like #4 is the clearest horizon (thanks @Spektre for making me check this more carefully), mixing the colours in a ratio [Red 2: Green 0: Blue -1], you get this monochrome image:

mixed channel mono

Setting blue negative means that the blue haze over the horizon is used to kill off the fuzziness there. This turns out to be more effective than just using red and/or green (try it with the Channel Mixer in the GIMP).

Then we can clarify further, if you like, by thresholding (although you could do this after sampling), here at 25% grey:

25pc thresholded

Using Spektre's approach of vertically sampling the image, just scan down until you see the value go over 25%. With 3 lines, you should gain 3 x,y pairs and thus reconstruct the curve knowing that it is a parabola.

For more robustness, take more than 3 samples and discard outliers.

Phil H
  • 19,928
  • 7
  • 68
  • 105
  • +1 nice ... proper filtering before recognition is always better then after. of course in different atmosphere it could lose its properties but for earth is this superb ... – Spektre Mar 05 '14 at 16:09
  • btw have you tried only Red channel ? Red color goes through our atmosphere with least problems due to scattering (that is why satellites use mostly IR cameras) – Spektre Mar 06 '14 at 07:44
  • @Spektre, yes, I played with the channel mixer in GIMP. If you look at the haze, it starts blue but ends up nearer white. Looking at the red channel, it has a very light haze there; scattering is not zero for red, just much less than blue. By subtracting blue, the red channel haze is removed leaving only the un-blue colours. – Phil H Mar 06 '14 at 14:05
  • @Spektre, after your comment I went back to make comparison images for different mixing options, and found that the 2*R-B (#4) was clearer than the R+G-B (#5) that I'd originally gone for. So I've redone the other images in light of that. Thanks for prompting, hopefully that makes the choice clearer regarding the haze. – Phil H Mar 06 '14 at 14:49
  • indeed very nice filter output for this purpose :) if I only knew the right physics background for my problems more often... would save me a lot of time ... – Spektre Mar 06 '14 at 20:21
  • To learn Physics, explore the world you see, all the way down, until you find turtles or it stops being interesting. To see a world in a grain of sand, And a heaven in a wild flower, Hold infinity in the palm of your hand, And eternity in an hour. (Blake) – Phil H Mar 07 '14 at 10:51
  • Thank you! Now I just need to figure out how to make this with OpenCV (which if I understand correctly will give me the best performance on a Pi). – Skyler Mar 29 '14 at 05:01
  • Just FYI... the physics involved is `Rayleigh scattering`, and basically the amount of scattering is inversely proportional to the 4th power of wavelength. So, short wavelengths (i.e. blue) are scattered more, and therefore appear more diffuse, than longer wavelengths (i.e. red). It is the reason why black and white photographers like to use yellow filters on hazy days. – Mark Setchell May 05 '15 at 08:26
2

I would do it like this:

  1. convert to BW

  2. scan Horizontal and Vertical lines from each side like this

    vertical lines scan from top

    Vertical line scan

    The black line shows the line position. For selected one the green arrows shows direction of scan (down) and direction of color intensity visualization (right). The white curve is color intensity graph (so you actually see what is happening)

    1. select some grid step this is 64 points between lines
    2. create temp array int p[]; to store line
    3. pre process each line

      • p[0] is intensity of first pixel of line
      • p[1,...] is derivation by x for H and by y for V lines (just substract neighboring line pixels)

      blur p[1,...] few times to avoid noise problems (from both sides to avoid position shift).

    4. scan + integrate it back

      integration is just summing c(i)=p[ 0 ] + p[ 1 ] + ... + p[ i ]. If c is below treshold you are outside atmosphere so start scanning and if from start of line is this area you are scanning from the right side. Remember where you reach treshold A-point and continue scanning until you reach peak C-point (first negative derivation value or real peak ... local max value).

    5. compute B-point

      for ease you can do B = 0.5*(A+C) but if you want be precise then atmosphere intensity grows exponentially so scan derivations from A to C and determine exponential function from it. If derivation start differs from it you reached B-point so remember all B-points (for each line).

  3. now you have set of B-points

    So delete all invalid B-points (you should have 2 per each line ... from start and from end) so area with bigger atmosphere is often the right one unless you have some dark seamless close object in view.

  4. approximate some curve through remaining B-points

[Notes]

You cannot shift B-point position based on altitude because the visual thickness of atmosphere also depends on observer and light source (Sun) positions. Also you should filter remaining B-points because some stars in view could do a mess. But I think the curve approximation should be enough.

[Edit1] did some stuff around for fun

so I did it in BDS2006 C++ VCL ... so you have to change image access to your environment

void find_horizont()
{
int i,j,x,y,da,c0,c1,tr0,tr1;

pic1=pic0;      // copy input image pic0 to pic1
pic1.rgb2i();   // RGB -> BW

struct _atm
    {
    int x,y;    // position of horizont point
    int l;      // found atmosphere thickness
    int id;     // 0,1 - V line; 2,3 - H line;
    };
_atm p,pnt[256];// horizont points
int pnts=0;     // num of horizont points
int n[4]={0,0,0,0}; // count of id-type points for the best option selection

da=32;          // grid step [pixels]
tr0=4;          // max difference of intensity inside vakuum homogenous area <0,767>
tr1=10;         // min atmosphere thickness [pixels]

// process V-lines
for (x=da>>1;x<pic1.xs;x+=da)
    {
    // blur it y little (left p[0] alone)
    for (i=0;i<5;i++)
        {
        for (y=   0;y<pic1.ys-1;y++) pic1.p[y][x].dd=(pic1.p[y][x].dd+pic1.p[y+1][x].dd)>>1;    // this shift left
        for (y=pic1.ys-1;y>   0;y--) pic1.p[y][x].dd=(pic1.p[y][x].dd+pic1.p[y-1][x].dd)>>1;    // this shift right
        }
    // scann from top to bottom
    // - for end of homogenous area
    for (c0=pic1.p[0][x].dd,y=0;y<pic1.ys;y++)
        {
        c1=pic1.p[y][x].dd;
        i=c1-c0; if (i<0) i=-i;
        if (i>=tr0) break;  // non homogenous bump
        }
    p.l=y;
    // - for end of exponential increasing intensity part
    for (i=c1-c0,y++;y<pic1.ys;y++)
        {
        c0=c1; c1=pic1.p[y][x].dd;
        j = i; i =c1-c0;
        if (i*j<=0) break;  // peak
        if (i+tr0<j) break;     // non exponential ... increase is slowing down
        }
    // add horizont point if thick enough atmosphere found
    p.id=0; p.x=x; p.y=y; p.l-=y; if (p.l<0) p.l=-p.l; if (p.l>tr1) { pnt[pnts]=p; pnts++; n[p.id]++; }
    // scann from bottom to top
    // - for end of homogenous area
    for (c0=pic1.p[pic1.ys-1][x].dd,y=pic1.ys-1;y>=0;y--)
        {
        c1=pic1.p[y][x].dd;
        i=c1-c0; if (i<0) i=-i;
        if (i>=tr0) break;  // non homogenous bump
        }
    p.l=y;
    // - for end of exponential increasing intensity part
    for (i=c1-c0,y--;y>=0;y--)
        {
        c0=c1; c1=pic1.p[y][x].dd;
        j = i; i =c1-c0;
        if (i*j<=0) break;  // peak
        if (i+tr0<j) break;     // non exponential ... increase is slowing down
        }
    // add horizont point
    // add horizont point if thick enough atmosphere found
    p.id=1; p.x=x; p.y=y; p.l-=y; if (p.l<0) p.l=-p.l; if (p.l>tr1) { pnt[pnts]=p; pnts++; n[p.id]++; }
    }

// process H-lines
for (y=da>>1;y<pic1.ys;y+=da)
    {
    // blur it x little (left p[0] alone)
    for (i=0;i<5;i++)
        {
        for (x=   0;x<pic1.xs-1;x++) pic1.p[y][x].dd=(pic1.p[y][x].dd+pic1.p[y][x+1].dd)>>1;    // this shift left
        for (x=pic1.xs-1;x>   0;x--) pic1.p[y][x].dd=(pic1.p[y][x].dd+pic1.p[y][x-1].dd)>>1;    // this shift right
        }
    // scann from top to bottom
    // - for end of homogenous area
    for (c0=pic1.p[y][0].dd,x=0;x<pic1.xs;x++)
        {
        c1=pic1.p[y][x].dd;
        i=c1-c0; if (i<0) i=-i;
        if (i>=tr0) break;  // non homogenous bump
        }
    p.l=x;
    // - for end of eyponential increasing intensitx part
    for (i=c1-c0,x++;x<pic1.xs;x++)
        {
        c0=c1; c1=pic1.p[y][x].dd;
        j = i; i =c1-c0;
        if (i*j<=0) break;  // peak
        if (i+tr0<j) break;     // non eyponential ... increase is slowing down
        }
    // add horizont point if thick enough atmosphere found
    p.id=2; p.y=y; p.x=x; p.l-=x; if (p.l<0) p.l=-p.l; if (p.l>tr1) { pnt[pnts]=p; pnts++; n[p.id]++; }
    // scann from bottom to top
    // - for end of homogenous area
    for (c0=pic1.p[y][pic1.xs-1].dd,x=pic1.xs-1;x>=0;x--)
        {
        c1=pic1.p[y][x].dd;
        i=c1-c0; if (i<0) i=-i;
        if (i>=tr0) break;  // non homogenous bump
        }
    p.l=x;
    // - for end of eyponential increasing intensitx part
    for (i=c1-c0,x--;x>=0;x--)
        {
        c0=c1; c1=pic1.p[y][x].dd;
        j = i; i =c1-c0;
        if (i*j<=0) break;  // peak
        if (i+tr0<j) break;     // non eyponential ... increase is slowing down
        }
    // add horizont point if thick enough atmosphere found
    p.id=3; p.y=y; p.x=x; p.l-=x; if (p.l<0) p.l=-p.l; if (p.l>tr1) { pnt[pnts]=p; pnts++; n[p.id]++; }
    }

pic1=pic0;  // get the original image

// chose id with max horizont points
j=0;
if (n[j]<n[1]) j=1;
if (n[j]<n[2]) j=2;
if (n[j]<n[3]) j=3;
// draw horizont line from pnt.id==j points only
pic1.bmp->Canvas->Pen->Color=0x000000FF;    // Red
for (i=0;i<pnts;i++) if (pnt[i].id==j) { pic1.bmp->Canvas->MoveTo(pnt[i].x,pnt[i].y); break; }
for (   ;i<pnts;i++) if (pnt[i].id==j)   pic1.bmp->Canvas->LineTo(pnt[i].x,pnt[i].y);
}

input image is pic0, output image is pic1 they are my classes so some members are:

  • xs,ys size of image in pixels
  • p[y][x].dd is pixel at (x,y) position as 32 bit integer type
  • bmp is GDI bitmap
  • rgb2i() converts all RGB pixels to intensity integer values <0-765> (r+g+b)

As you can see all horizon points are in pnt[pnts] array where:

  • x,y is position of horizon point
  • l is atmosphere thickness (exponential part)
  • id is { 0,1,2,3 } which identify scan direction

Here is output image (works well even for rotated images)

out normal

This will not work for sun glow images unless you add some heavy duty filtering

Spektre
  • 49,595
  • 11
  • 110
  • 380