30

I detected lines in an image and drew them in a separate image file in OpenCv C++ using HoughLinesP method. Following is a part of that resulting image. There are actually hundreds of small and thin lines which form a big single line.

enter image description here

But I want single few lines that represent all those number of lines. Closer lines should be merged together to form a single line. For example above set of lines should be represented by just 3 separate lines as below.

enter image description here

The expected output is as above. How to accomplish this task.



Up to now progress result from akarsakov's answer.


(separate classes of lines resulted are drawn in different colors). Note that this result is the original complete image I am working on, but not the sample section I had used in the question

enter image description here

Samitha Chathuranga
  • 1,689
  • 5
  • 30
  • 57
  • 1
    your lines have start and end points? why not just merge lines if their start and end points are close? – coproc Jun 12 '15 at 10:10
  • 1
    @coproc Yes that method will merge a lot of lines.. But many more will be left too. – Samitha Chathuranga Jun 12 '15 at 11:18
  • 3
    can you add your original image and the code used to generate the small lines? Much easier to give you a solution if we can test our approaches/ideas! – Micka Jun 18 '15 at 13:08

6 Answers6

27

If you don't know the number of lines in the image you can use the cv::partition function to split lines on equivalency group.

I suggest you the following procedure:

  1. Split your lines using cv::partition. You need to specify a good predicate function. It really depends on lines which you extract from image, but I think it should check following conditions:

    • Angle between lines should be quite small (less 3 degrees, for example). Use dot product to calculate angle's cosine.
    • Distance between centers of segments should be less than half of maximum length of two segments.

For example, it can be implemented as follows:

bool isEqual(const Vec4i& _l1, const Vec4i& _l2)
{
    Vec4i l1(_l1), l2(_l2);

    float length1 = sqrtf((l1[2] - l1[0])*(l1[2] - l1[0]) + (l1[3] - l1[1])*(l1[3] - l1[1]));
    float length2 = sqrtf((l2[2] - l2[0])*(l2[2] - l2[0]) + (l2[3] - l2[1])*(l2[3] - l2[1]));

    float product = (l1[2] - l1[0])*(l2[2] - l2[0]) + (l1[3] - l1[1])*(l2[3] - l2[1]);

    if (fabs(product / (length1 * length2)) < cos(CV_PI / 30))
        return false;

    float mx1 = (l1[0] + l1[2]) * 0.5f;
    float mx2 = (l2[0] + l2[2]) * 0.5f;

    float my1 = (l1[1] + l1[3]) * 0.5f;
    float my2 = (l2[1] + l2[3]) * 0.5f;
    float dist = sqrtf((mx1 - mx2)*(mx1 - mx2) + (my1 - my2)*(my1 - my2));

    if (dist > std::max(length1, length2) * 0.5f)
        return false;

    return true;
}

Guess you have your lines in vector<Vec4i> lines;. Next, you should call cv::partition as follows:

vector<Vec4i> lines;
std::vector<int> labels;
int numberOfLines = cv::partition(lines, labels, isEqual);

You need to call cv::partition once and it will clusterize all lines. Vector labels will store for each line label of cluster to which it belongs. See documentation for cv::partition

  1. After you get all groups of line you should merge them. I suggest calculating average angle of all lines in group and estimate "border" points. For example, if angle is zero (i.e. all lines are almost horizontal) it would be the left-most and right-most points. It remains only to draw a line between this points.

I noticed that all lines in your examples are horizontal or vertical. In such case you can calculate point which is average of all segment's centers and "border" points, and then just draw horizontal or vertical line limited by "border" points through center point.

Please note that cv::partition takes O(N^2) time, so if you process a huge number of lines it may take a lot of time.

I hope it will help. I used such approach for similar task.

alecive
  • 177
  • 1
  • 14
akarsakov
  • 2,134
  • 4
  • 21
  • 26
  • can yo please post the images of results you got in your task with input image, so that I can see it is what I expect. – Samitha Chathuranga Jun 18 '15 at 05:41
  • And if there are total 1000 lines in my original image does this isEqual() function runs 1000^2 times.(1000 000) Is that so? – Samitha Chathuranga Jun 18 '15 at 05:43
  • Yes, if there are 1000 lines `isEqual` will run million times. So you need quite no computational expensive function. Unfortunately I can't provide images of results since code is under NDA. – akarsakov Jun 18 '15 at 08:59
  • I called isEqual() function for all possible pairs of lines. But where should I call cv::partition() function? Your answer suggests me that I should call it just after I call isEqual() as u have used isEqual argument in calling that partition() function. So it is called for each possible pair of lines in the image. But this seems meaningless. I edited my answer with your suggestion and can u please check it? – Samitha Chathuranga Jun 19 '15 at 14:59
  • You just need to call `cv::partition` function once, not for all pairs of lines. `cv::partition` will call `isEqual()` within itself for all pair. I updated answer. – akarsakov Jun 19 '15 at 15:43
  • I did what u said. But I am afraid the numberOfLines returned by partition function is exactly same as the number of original lines. (number of original lines was taken by lines.size() function). So it seems that no effective clustering has been done. – Samitha Chathuranga Jun 19 '15 at 16:27
  • 1
    Oh, I was wrong with dot product formula. I fixed code snippet. I hope it will work now. You can easily check correctness of `isEqual` function by using two closer lines as input. Also it would better to check that this function doesn't accept orthogonal lines. – akarsakov Jun 19 '15 at 22:36
  • I tested with the edited code. But I cannot say that it is successful. I got 30 labels (30 partitions). It is ok. But even obviously separate and remote lines were labeled as to belong to the same partition. I will add an screenshot of my result as an edit to the question. (note that this result is the original complete image I am working on, but not the sample section I had used in the question.) I draw the lines of separate 6 partitions (out of 30 partitions) in 6 different colors to make it clear. – Samitha Chathuranga Jun 24 '15 at 14:40
  • And I would like to know the logic behind the partitioning operation. Is it based on the proximity, angle, length,..etc??? – Samitha Chathuranga Jun 24 '15 at 14:45
  • Good solution. I'd pull the sqrts off and penalize longer lines for angle more than shorter, but this is great. – Joel Teply Mar 03 '19 at 23:19
6

First off I want to note that your original image is at a slight angle, so your expected output seems just a bit off to me. I'm assuming you are okay with lines that are not 100% vertical in your output because they are slightly off on your input.

Mat image;
Mat binary = image > 125;  // Convert to binary image

// Combine similar lines
int size = 3;
Mat element = getStructuringElement( MORPH_ELLIPSE, Size( 2*size + 1, 2*size+1 ), Point( size, size ) );
morphologyEx( mask, mask, MORPH_CLOSE, element );

So far this yields this image:

These lines are not at 90 degree angles because the original image is not.

You can also choose to close the gap between the lines with:

Mat out = Mat::zeros(mask.size(), mask.type());

vector<Vec4i> lines;
HoughLinesP(mask, lines, 1, CV_PI/2, 50, 50, 75);
for( size_t i = 0; i < lines.size(); i++ )
{
    Vec4i l = lines[i];
    line( out, Point(l[0], l[1]), Point(l[2], l[3]), Scalar(255), 5, CV_AA);
}

If these lines are too fat, I've had success thinning them with:

size = 15;
Mat eroded;
cv::Mat erodeElement = getStructuringElement( MORPH_ELLIPSE, cv::Size( size, size ) );
erode( mask, eroded, erodeElement );

Rick Smith
  • 9,031
  • 15
  • 81
  • 85
  • 1
    I think this doesn't output 3 lines (so doesn't reduced the number of lines) which I expect to have. This seems to give many lines as previously, because houghLinesP usually function so. – Samitha Chathuranga Jun 18 '15 at 05:31
  • @SamithaChathuranga Pixel for pixel, my image is very close to your expected image. I think maybe you've asked for something you don't want. Are you expecting a `vector lines` with a size of 3 rather than a binary image? – Rick Smith Jun 18 '15 at 15:14
  • I mentioned in my question that "For example above set of lines should be represented by just 3 separate lines as below.". So what I asked is actually what I want. And those 3 lines means that I want them as any type of vector of lines with size of 3 (3 lines). – Samitha Chathuranga Jun 19 '15 at 11:25
  • Hmm...I read "The expected output is as above" and saw the picture and assumed you wanted to match the picture. "Lines" is ambiguous because it can be of various formats and thicknesses. Ie. my picture also has 3 lines with crude anti-aliasing. I guess I misunderstood you, sorry about that. – Rick Smith Jun 19 '15 at 16:23
  • 2
    However, you should still probably use my answer as a pre-processing step. If you run HoughLines on my answer you will get a greatly reduced number of lines which will help in an N^2 answer. Also, my answer has the effect similar to averaging the lines which takes out some of the noise. If it were me, I'd combine this answer with akarsakov's. – Rick Smith Jun 19 '15 at 16:29
  • Ok, Thanks. Appreciate your supporting effort. Anyway the purpose of having such small number of representative lines is that I want to prolong (increase the length) each of those lines for my intended purpose. – Samitha Chathuranga Jun 19 '15 at 16:33
5

Here is a refinement build upon @akarsakov answer. A basic issue with:

Distance between centers of segments should be less than half of maximum length of two segments.

is that parallel long lines that are visually far might end up in same equivalence class (as demonstrated in OP's edit).

Therefore the approach that I found working reasonable for me:

  1. Construct a window (bounding rectangle) around a line1.
  2. line2 angle is close enough to line1's and at least one point of line2 is inside line1's bounding rectangle

Often a long linear feature in the image that is quite weak will end up recognized (HoughP, LSD) by a set of line segments with considerable gaps between them. To alleviate this, our bounding rectangle is constructed around line extended in both directions, where extension is defined by a fraction of original line width.

bool extendedBoundingRectangleLineEquivalence(const Vec4i& _l1, const Vec4i& _l2, float extensionLengthFraction, float maxAngleDiff, float boundingRectangleThickness){

    Vec4i l1(_l1), l2(_l2);
    // extend lines by percentage of line width
    float len1 = sqrtf((l1[2] - l1[0])*(l1[2] - l1[0]) + (l1[3] - l1[1])*(l1[3] - l1[1]));
    float len2 = sqrtf((l2[2] - l2[0])*(l2[2] - l2[0]) + (l2[3] - l2[1])*(l2[3] - l2[1]));
    Vec4i el1 = extendedLine(l1, len1 * extensionLengthFraction);
    Vec4i el2 = extendedLine(l2, len2 * extensionLengthFraction);

    // reject the lines that have wide difference in angles
    float a1 = atan(linearParameters(el1)[0]);
    float a2 = atan(linearParameters(el2)[0]);
    if(fabs(a1 - a2) > maxAngleDiff * M_PI / 180.0){
        return false;
    }

    // calculate window around extended line
    // at least one point needs to inside extended bounding rectangle of other line,
    std::vector<Point2i> lineBoundingContour = boundingRectangleContour(el1, boundingRectangleThickness/2);
    return
        pointPolygonTest(lineBoundingContour, cv::Point(el2[0], el2[1]), false) == 1 ||
        pointPolygonTest(lineBoundingContour, cv::Point(el2[2], el2[3]), false) == 1;
}

where linearParameters, extendedLine, boundingRectangleContour are following:

Vec2d linearParameters(Vec4i line){
    Mat a = (Mat_<double>(2, 2) <<
                line[0], 1,
                line[2], 1);
    Mat y = (Mat_<double>(2, 1) <<
                line[1],
                line[3]);
    Vec2d mc; solve(a, y, mc);
    return mc;
}

Vec4i extendedLine(Vec4i line, double d){
    // oriented left-t-right
    Vec4d _line = line[2] - line[0] < 0 ? Vec4d(line[2], line[3], line[0], line[1]) : Vec4d(line[0], line[1], line[2], line[3]);
    double m = linearParameters(_line)[0];
    // solution of pythagorean theorem and m = yd/xd
    double xd = sqrt(d * d / (m * m + 1));
    double yd = xd * m;
    return Vec4d(_line[0] - xd, _line[1] - yd , _line[2] + xd, _line[3] + yd);
}

std::vector<Point2i> boundingRectangleContour(Vec4i line, float d){
    // finds coordinates of perpendicular lines with length d in both line points
    // https://math.stackexchange.com/a/2043065/183923

    Vec2f mc = linearParameters(line);
    float m = mc[0];
    float factor = sqrtf(
        (d * d) / (1 + (1 / (m * m)))
    );

    float x3, y3, x4, y4, x5, y5, x6, y6;
    // special case(vertical perpendicular line) when -1/m -> -infinity
    if(m == 0){
        x3 = line[0]; y3 = line[1] + d;
        x4 = line[0]; y4 = line[1] - d;
        x5 = line[2]; y5 = line[3] + d;
        x6 = line[2]; y6 = line[3] - d;
    } else {
        // slope of perpendicular lines
        float m_per = - 1/m;

        // y1 = m_per * x1 + c_per
        float c_per1 = line[1] - m_per * line[0];
        float c_per2 = line[3] - m_per * line[2];

        // coordinates of perpendicular lines
        x3 = line[0] + factor; y3 = m_per * x3 + c_per1;
        x4 = line[0] - factor; y4 = m_per * x4 + c_per1;
        x5 = line[2] + factor; y5 = m_per * x5 + c_per2;
        x6 = line[2] - factor; y6 = m_per * x6 + c_per2;
    }

    return std::vector<Point2i> {
        Point2i(x3, y3),
        Point2i(x4, y4),
        Point2i(x6, y6),
        Point2i(x5, y5)
    };
}

To partion, call:

std::vector<int> labels;
int equilavenceClassesCount = cv::partition(linesWithoutSmall, labels, [](const Vec4i l1, const Vec4i l2){
    return extendedBoundingRectangleLineEquivalence(
        l1, l2,
        // line extension length - as fraction of original line width
        0.2,
        // maximum allowed angle difference for lines to be considered in same equivalence class
        2.0,
        // thickness of bounding rectangle around each line
        10);
});

Now, in order to reduce each equivalence class to single line, we build a point cloud out of it and find a line fit:

// fit line to each equivalence class point cloud
std::vector<Vec4i> reducedLines = std::accumulate(pointClouds.begin(), pointClouds.end(), std::vector<Vec4i>{}, [](std::vector<Vec4i> target, const std::vector<Point2i>& _pointCloud){
    std::vector<Point2i> pointCloud = _pointCloud;

    //lineParams: [vx,vy, x0,y0]: (normalized vector, point on our contour)
    // (x,y) = (x0,y0) + t*(vx,vy), t -> (-inf; inf)
    Vec4f lineParams; fitLine(pointCloud, lineParams, CV_DIST_L2, 0, 0.01, 0.01);

    // derive the bounding xs of point cloud
    decltype(pointCloud)::iterator minXP, maxXP;
    std::tie(minXP, maxXP) = std::minmax_element(pointCloud.begin(), pointCloud.end(), [](const Point2i& p1, const Point2i& p2){ return p1.x < p2.x; });

    // derive y coords of fitted line
    float m = lineParams[1] / lineParams[0];
    int y1 = ((minXP->x - lineParams[2]) * m) + lineParams[3];
    int y2 = ((maxXP->x - lineParams[2]) * m) + lineParams[3];

    target.push_back(Vec4i(minXP->x, y1, maxXP->x, y2));
    return target;
});

Demonstration:

Original image

Detected partitioned line (with small lines filtered out): enter image description here

Reduced: enter image description here

Demonstration code:

int main(int argc, const char* argv[]){

    if(argc < 2){
        std::cout << "img filepath should be present in args" << std::endl;
    }

    Mat image = imread(argv[1]);
    Mat smallerImage; resize(image, smallerImage, cv::Size(), 0.5, 0.5, INTER_CUBIC);
    Mat target = smallerImage.clone();

    namedWindow("Detected Lines", WINDOW_NORMAL);
    namedWindow("Reduced Lines", WINDOW_NORMAL);
    Mat detectedLinesImg = Mat::zeros(target.rows, target.cols, CV_8UC3);
    Mat reducedLinesImg = detectedLinesImg.clone();

    // delect lines in any reasonable way
    Mat grayscale; cvtColor(target, grayscale, CV_BGRA2GRAY);
    Ptr<LineSegmentDetector> detector = createLineSegmentDetector(LSD_REFINE_NONE);
    std::vector<Vec4i> lines; detector->detect(grayscale, lines);

    // remove small lines
    std::vector<Vec4i> linesWithoutSmall;
    std::copy_if (lines.begin(), lines.end(), std::back_inserter(linesWithoutSmall), [](Vec4f line){
        float length = sqrtf((line[2] - line[0]) * (line[2] - line[0])
                             + (line[3] - line[1]) * (line[3] - line[1]));
        return length > 30;
    });

    std::cout << "Detected: " << linesWithoutSmall.size() << std::endl;

    // partition via our partitioning function
    std::vector<int> labels;
    int equilavenceClassesCount = cv::partition(linesWithoutSmall, labels, [](const Vec4i l1, const Vec4i l2){
        return extendedBoundingRectangleLineEquivalence(
            l1, l2,
            // line extension length - as fraction of original line width
            0.2,
            // maximum allowed angle difference for lines to be considered in same equivalence class
            2.0,
            // thickness of bounding rectangle around each line
            10);
    });

    std::cout << "Equivalence classes: " << equilavenceClassesCount << std::endl;

    // grab a random colour for each equivalence class
    RNG rng(215526);
    std::vector<Scalar> colors(equilavenceClassesCount);
    for (int i = 0; i < equilavenceClassesCount; i++){
        colors[i] = Scalar(rng.uniform(30,255), rng.uniform(30, 255), rng.uniform(30, 255));;
    }

    // draw original detected lines
    for (int i = 0; i < linesWithoutSmall.size(); i++){
        Vec4i& detectedLine = linesWithoutSmall[i];
        line(detectedLinesImg,
             cv::Point(detectedLine[0], detectedLine[1]),
             cv::Point(detectedLine[2], detectedLine[3]), colors[labels[i]], 2);
    }

    // build point clouds out of each equivalence classes
    std::vector<std::vector<Point2i>> pointClouds(equilavenceClassesCount);
    for (int i = 0; i < linesWithoutSmall.size(); i++){
        Vec4i& detectedLine = linesWithoutSmall[i];
        pointClouds[labels[i]].push_back(Point2i(detectedLine[0], detectedLine[1]));
        pointClouds[labels[i]].push_back(Point2i(detectedLine[2], detectedLine[3]));
    }

    // fit line to each equivalence class point cloud
    std::vector<Vec4i> reducedLines = std::accumulate(pointClouds.begin(), pointClouds.end(), std::vector<Vec4i>{}, [](std::vector<Vec4i> target, const std::vector<Point2i>& _pointCloud){
        std::vector<Point2i> pointCloud = _pointCloud;

        //lineParams: [vx,vy, x0,y0]: (normalized vector, point on our contour)
        // (x,y) = (x0,y0) + t*(vx,vy), t -> (-inf; inf)
        Vec4f lineParams; fitLine(pointCloud, lineParams, CV_DIST_L2, 0, 0.01, 0.01);

        // derive the bounding xs of point cloud
        decltype(pointCloud)::iterator minXP, maxXP;
        std::tie(minXP, maxXP) = std::minmax_element(pointCloud.begin(), pointCloud.end(), [](const Point2i& p1, const Point2i& p2){ return p1.x < p2.x; });

        // derive y coords of fitted line
        float m = lineParams[1] / lineParams[0];
        int y1 = ((minXP->x - lineParams[2]) * m) + lineParams[3];
        int y2 = ((maxXP->x - lineParams[2]) * m) + lineParams[3];

        target.push_back(Vec4i(minXP->x, y1, maxXP->x, y2));
        return target;
    });

    for(Vec4i reduced: reducedLines){
        line(reducedLinesImg, Point(reduced[0], reduced[1]), Point(reduced[2], reduced[3]), Scalar(255, 255, 255), 2);
    }

    imshow("Detected Lines", detectedLinesImg);
    imshow("Reduced Lines", reducedLinesImg);
    waitKey();

    return 0;
}
ambientlight
  • 7,212
  • 3
  • 49
  • 61
  • 1
    For those who wonder like me - `pointPolygonTest` is an OpenCV function: https://docs.opencv.org/3.4.2/dc/d48/tutorial_point_polygon_test.html – Grillteller Jul 09 '18 at 11:53
  • 1
    I've implemented this in C# and found a couple of issues in the reducing function. If _pointcloud have just two points, you can skip fitLine and just add it to the final list. Also there's an issue that creates an artifact in the final image where some lines became points. This happens with vertical lines, when all the points have the same X. I'm still in doubt on how to prevent this... anyway thanks a lot for the detailed answer, it really helped me ;) – Dan May 17 '21 at 20:25
3

I would recommend that you use HoughLines from OpenCV.

void HoughLines(InputArray image, OutputArray lines, double rho, double theta, int threshold, double srn=0, double stn=0 )

You can adjust with rho and theta the possible orientation and position of the lines you want to observe. In your case, theta = 90° would be fine (only vertical and horizontal lines).

After this, you can get unique line equations with Plücker coordinates. And from there you could apply a K-mean with 3 centers that should fit approximately your 3 lines in the second image.

PS : I will see if i can test the whole process with your image

AdMor
  • 91
  • 7
  • 1
    Yes I got above result using HoughLinesP method in opencv. And can u clarify the next things you had mentioned about plucker coordinates and using k-means. – Samitha Chathuranga Jun 12 '15 at 11:07
  • 1
    And please note that in my application I do not know the exact number of required lines (even though here it is 3). And above image is just a screenshot of the original image. So if you are going to test your suggested process, use this link to download the original file https://drive.google.com/file/d/0B5EFFnZ2dI5GdG1qdEh5THZ1eGM/view?usp=sharing – Samitha Chathuranga Jun 12 '15 at 11:15
3

You can merge multiple close line into single line by clustering lines using rho and theta and finally taking average of rho and theta.

    void contourLines(vector<cv::Vec2f> lines, const float rho_threshold, const float theta_threshold, vector< cv::Vec2f > &combinedLines)
{
    vector< vector<int> > combineIndex(lines.size());

    for (int i = 0; i < lines.size(); i++)
    {
        int index = i;
        for (int j = i; j < lines.size(); j++)
        {
            float distanceI = lines[i][0], distanceJ = lines[j][0];
            float slopeI = lines[i][1], slopeJ = lines[j][1];
            float disDiff = abs(distanceI - distanceJ);
            float slopeDiff = abs(slopeI - slopeJ);

            if (slopeDiff < theta_max && disDiff < rho_max)
            {
                bool isCombined = false;
                for (int w = 0; w < i; w++)
                {
                    for (int u = 0; u < combineIndex[w].size(); u++)
                    {
                        if (combineIndex[w][u] == j)
                        {
                            isCombined = true;
                            break;
                        }
                        if (combineIndex[w][u] == i)
                            index = w;
                    }
                    if (isCombined)
                        break;
                }
                if (!isCombined)
                    combineIndex[index].push_back(j);
            }
        }
    }

    for (int i = 0; i < combineIndex.size(); i++)
    {
        if (combineIndex[i].size() == 0)
            continue;
        cv::Vec2f line_temp(0, 0);
        for (int j = 0; j < combineIndex[i].size(); j++) {
            line_temp[0] += lines[combineIndex[i][j]][0];
            line_temp[1] += lines[combineIndex[i][j]][1];
        }
        line_temp[0] /= combineIndex[i].size();
        line_temp[1] /= combineIndex[i].size();
        combinedLines.push_back(line_temp);
    }
}

function call You can tune houghThreshold, rho_threshold and theta_threshold as per your application.

    HoughLines(edge, lines_t, 1, CV_PI / 180, houghThreshold, 0, 0);

    float rho_threshold= 15;
    float theta_threshold = 3*DEGREES_TO_RADIANS;
    vector< cv::Vec2f > lines;
    contourCluster(lines_t, rho_max, theta_max, lines);

lines before clustering

lines after clustering

C_Raj
  • 162
  • 7
1

@C_Raj made a good point, for lines like this, i.e., most likely extracted from table/form-like images, you should make full use of the fact that many of the line segments captured by Hough transform from the same lines have very similar \rho and \theta.

After clustering these line segments based on their \rho and \theta, you can apply 2D line fitting to obtain estimate of the true lines in an image.

There is a paper describing this idea and it's making further assumptions of the lines in a page.

HTH.

galactica
  • 1,753
  • 2
  • 26
  • 36