The core of the technique I'm sharing below uses cv::HoughLinesP()
to find line segments in a grayscale image.
The application starts by loading the input image as grayscale. Then it performs a basic pre-processing operation to enhance certain characteristics of the image, aiming to improve the detection performed by cv::HoughLinesP()
:
#include <cv.h>
#include <highgui.h>
#include <algorithm>
// Custom sort method adapted from: https://mcmap.net/q/893227/-how-to-use-std-sort-with-a-vector-of-structures-and-compare-function
// This is used later by std::sort()
struct sort_by_y_coord
{
bool operator ()(cv::Vec4i const& a, cv::Vec4i const& b) const
{
if (a[1] < b[1]) return true;
if (a[1] > b[1]) return false;
return false;
}
};
int main()
{
/* Load input image as grayscale */
cv::Mat src = cv::imread("13531682.jpg", 0);
/* Pre-process the image to enhance the characteristics we are interested at */
medianBlur(src, src, 5);
int erosion_size = 2;
cv::Mat element = cv::getStructuringElement(cv::MORPH_CROSS,
cv::Size(2 * erosion_size + 1, 2 * erosion_size + 1),
cv::Point(erosion_size, erosion_size) );
cv::erode(src, src, element);
cv::dilate(src, src, element);
/* Identify all the lines in the image */
cv::Size size = src.size();
std::vector<cv::Vec4i> total_lines;
cv::HoughLinesP(src, total_lines, 1, CV_PI/180, 100, size.width / 2.f, 20);
int n_lines = total_lines.size();
std::cout << "* Total lines: "<< n_lines << std::endl;
cv::Mat disp_lines(size, CV_8UC1, cv::Scalar(0, 0, 0));
// For debugging purposes, the block below writes all the lines into disp_lines
// for (unsigned i = 0; i < n_lines; ++i)
// {
// cv::line(disp_lines,
// cv::Point(total_lines[i][0], total_lines[i][2]),
// cv::Point(total_lines[i][3], total_lines[i][4]),
// cv::Scalar(255, 0 ,0));
// }
// cv::imwrite("total_lines.png", disp_lines);
At this point, all the line segments detected can be written to a file for visualization purposes:
At this point we need to sort our vector of lines because cv::HoughLinesP()
doesn't do that, and we need the vector sorted to be able to identify groups of lines, by measuring and comparing the distance between the lines:
/* Sort lines according to their Y coordinate.
The line closest to Y == 0 is at the first position of the vector.
*/
sort(total_lines.begin(), total_lines.end(), sort_by_y_coord());
/* Separate them according to their (visible) groups */
// Figure out the number of groups by distance between lines
std::vector<int> idx_of_groups; // stores the index position where a new group starts
idx_of_groups.push_back(0); // the first line indicates the start of the first group
// The loop jumps over the first line, since it was already added as a group
int y_dist = 35; // the next groups are identified by a minimum of 35 pixels of distance
for (unsigned i = 1; i < n_lines; i++)
{
if ((total_lines[i][5] - total_lines[i-1][6]) >= y_dist)
{
// current index marks the position of a new group
idx_of_groups.push_back(i);
std::cout << "* New group located at line #"<< i << std::endl;
}
}
int n_groups = idx_of_groups.size();
std::cout << "* Total groups identified: "<< n_groups << std::endl;
The last part of the code above simply stores the index positions of the vector of lines in a new vector<int>
so we know which lines starts a new group.
For instance, assume that the indexes stored in the new vector are: 0 4 8 12
. Remember: they define the start of each group. That means that the ending lines of the groups are: 0, 4-1, 4, 8-1, 8, 12-1, 12
.
Knowing that, we write the following code:
/* Mark the beginning and end of each group */
for (unsigned i = 0; i < n_groups; i++)
{
// To do this, we discard the X coordinates of the 2 points from the line,
// so we can draw a line from X=0 to X=size.width
// beginning
cv::line(disp_lines,
cv::Point(0, total_lines[ idx_of_groups[i] ][7]),
cv::Point(size.width, total_lines[ idx_of_groups[i] ][8]),
cv::Scalar(255, 0 ,0));
// end
if (i != n_groups-1)
{
cv::line(disp_lines,
cv::Point(0, total_lines[ idx_of_groups[i+1]-1 ][9]),
cv::Point(size.width, total_lines[ idx_of_groups[i+1]-1 ][10]),
cv::Scalar(255, 0 ,0));
}
}
// mark the end position of the last group (not done by the loop above)
cv::line(disp_lines,
cv::Point(0, total_lines[n_lines-1][11]),
cv::Point(size.width, total_lines[n_lines-1][12]),
cv::Scalar(255, 0 ,0));
/* Save the output image and display it on the screen */
cv::imwrite("groups.png", disp_lines);
cv::imshow("groove", disp_lines);
cv::waitKey(0);
cv::destroyWindow("groove");
return 0;
}
And the resulting image is:
It's not a perfect match, but it's close. With a little bit of tweaks here and there this approach can get much better. I would start by writing a smarter logic for sort_by_y_coord
, which should discard lines that have small distances between the X coordinates (i.e. small line segments), and also lines that are not perfectly aligned on the X axis (like the one from the second group in the output image). This suggestion makes much more sense after you take the time to evaluate the first image generated by the application.
Good luck.