本章包括以下内容:
- 用Canny 算子检测图像轮廓;
- 用霍夫变换检测直线;
- 点集的直线拟合;
- 提取连续区域;
- 计算区域的形状描述子。
7.2 用Canny 算子检测图像轮廓
简单的二值边缘分布图有两个主要缺点:第一,检测到的边缘过厚,这加大了识别物体边界的难度;第二,通常不可能找到既低到足以检测到图像中所有重要边缘,又高到足以避免产生太多无关紧要边缘的阈值。这是一个难以权衡的问题,Canny 算法试 图解决这个问题。
Canny 算法可通过OpenCV 的cv::Canny 函数实现。使用这个算法时,需要指定两个阈值。调用函数的方法如下所示:
// 应用Canny 算法
cv::Mat contours;
cv::Canny(image, // 灰度图像
contours, // 输出轮廓
125, // 低阈值
350); // 高阈值
先来看这幅图像。
在这幅图像上应用Canny 算法,得到如下结果。
注意,因为正常的结果是用非零像素表示轮廓的,所以这里在显示轮廓时做了反转处理。上面显示的图像只是像素值为255 的轮廓。
Canny 算子通常基于第6 章介绍的Sobel 算子,虽然也可使用其他的梯度算子。它的核心理念是用两个不同的阈值来判断哪个点属于轮廓,一个是低阈值,一个是高阈值。
选择低阈值时,要保证它能包含所有属于重要图像轮廓的边缘像素。例如,将前面例子中指定的低阈值应用到Sobel 算子返回的图像上,可得到如下边缘分布图。
可以看到,道路的边缘非常清晰。但因为这里使用了一个宽松的阈值,所以很多并不需要的边缘也被检测出来了。而第二个阈值的作用就是界定重要轮廓的边缘,排除掉异常的边缘。例如,在Sobel 边缘分布图上应用上例中的高阈值后,将得到如下结果。
现在得到的图像中有些边缘是断裂的,但是这些可见的边缘肯定属于本场景中的重要轮廓。Canny 算法将结合这两种边缘分布图,生成最优的轮廓分布图。具体做法是在低阈值边缘分布图上只保留具有连续路径的边缘点,同时把那些边缘点连接到属于高阈值边缘分布图的边缘上。这样一来,高阈值分布图上的所有边缘点都被保留下来,而低阈值分布图上边缘点的孤立链全部被移除。这是一种很好的折中方案,只要指定适当的阈值,就能获得高质量的轮廓。这种基于两个阈值获得二值分布图的策略被称为滞后阈值化,可用于任何需要用阈值化获得二值分布图的场景。但是它的计算复杂度比较高。
另外,Canny 算法用了一个额外的策略来优化边缘分布图的质量。在进行滞后阈值化之前,如果梯度幅值不是梯度方向上的最大值,那么对应的边缘点都会被移除(前面讲过,梯度的方向总是与边缘垂直的)。因此,这个方向上梯度的局部最大值对应着轮廓最大强度的位置。这是一个细化轮廓的运算,它创建的轮廓宽度只有一个像素。这也解释了为什么Canny 轮廓分布图的边缘比较薄。
7.3 用霍夫变换检测直线
霍夫变换(Hough transform)是一种常用于检测直线特征的经典算法。该算法起初用于检测图像中的直线,后来经过扩展,也能检测其他简单的图像结构。
针对用于检测直线的霍夫变换,OpenCV 提供了两种实现方法。
基础版是cv::HoughLines。它输入的是一个二值分布图,其中包含一批像素点(用非零像素表示),一些对齐的点构成了直线。它通常是一个已经生成的边缘分布图,例如Canny 算子生成的分布图。输出的是一个cv::Vec2f 类型元素组成的向量,每个元素是一对浮点数,表示检测到的直线的参数,即(ρ, θ)。
下面是使用这个函数的例子,首先用Canny 算子获得图像轮廓,然后用霍夫变换检测直线:
// 应用Canny 算法
cv::Mat contours;
cv::Canny(image, contours, 125, 350);
// 用霍夫变换检测直线
std::vector<cv::Vec2f> lines;
cv::HoughLines(contours, lines, 1,
PI / 180, // 步长
60); // 最小投票数
第3 个和第4 个参数表示搜索直线时用的步长。在本例中,半径步长为1,表示函数将搜索所有可能的半径;角度步长为π/180,表示函数将搜索所有可能的角度。
为了让检测结果可视化,我们在原始图像上绘制这些直线。有一点需要强调,这个算法检测的是图像中的直线而不是线段,它不会给出直线的端点。因此,我们绘制的直线将穿透整幅图像。通过遍历直线向量画出所有直线,代码如下所示:
cv::Mat result(contours.rows, contours.cols, CV_8U, cv::Scalar(255));
image.copyTo(result);
std::vector<cv::Vec2f>::const_iterator it = lines.begin();
while (it != lines.end()) {
float rho = (*it)[0]; // 第一个元素是距离rho
float theta = (*it)[1]; // 第二个元素是角度theta
if (theta < PI / 4. || theta > 3. * PI / 4.) { // 垂直线(大致)
// 直线与第一行的交叉点
cv::Point pt1(rho / cos(theta), 0);
// 直线与最后一行的交叉点
cv::Point pt2((rho - result.rows * sin(theta)) /
cos(theta), result.rows);
// 画白色的线
cv::line(image, pt1, pt2, cv::Scalar(255), 1);
}
else { // 水平线(大致)
// 直线与第一列的交叉点
cv::Point pt1(0, rho / sin(theta));
// 直线与最后一列的交叉点
cv::Point pt2(result.cols,
(rho - result.cols * cos(theta)) / sin(theta));
// 画白色的线
cv::line(image, pt1, pt2, cv::Scalar(255), 1);
}
++it;
}
得到的结果如下所示。
可以看出,霍夫变换只是寻找图像中边缘像素的对齐区域。因为有些像素只是碰巧排成了直线,所以霍夫变换可能产生错误的检测结果。也可能因为多条参数相近的直线穿过了同一个像素对齐区域,而导致检测出重复的结果。
为解决上述问题并检测到线段(即包含端点的直线),人们提出了霍夫变换的改进版。这就是概率霍夫变换,在OpenCV 中通过cv::HoughLinesP 函数实现。我们用它创建LineFinder类,封装函数的参数:
class LineFinder {
private:
// 原始图像
cv::Mat img;
// 包含被检测直线的端点的向量
std::vector<cv::Vec4i> lines;
// 累加器分辨率参数
double deltaRho;
double deltaTheta;
// 确认直线之前必须收到的最小投票数
int minVote;
// 直线的最小长度
double minLength;
// 直线上允许的最大空隙
double maxGap;
public:
// 默认累加器分辨率是1 像素,1 度
// 没有空隙,没有最小长度
LineFinder() : deltaRho(1), deltaTheta(PI / 180),
minVote(10), minLength(0.), maxGap(0.) {}
看一下对应的设置方法:
// 设置累加器的分辨率
void setAccResolution(double dRho, double dTheta) {
deltaRho = dRho;
deltaTheta = dTheta;
}
// 设置最小投票数
void setMinVote(int minv) {
minVote = minv;
}
// 设置直线长度和空隙
void setLineLengthAndGap(double length, double gap) {
minLength = length;
maxGap = gap;
}
用上述方法,检测霍夫线段的代码如下所示:
// 应用概率霍夫变换
std::vector<cv::Vec4i> findLines(cv::Mat& binary) {
lines.clear();
cv::HoughLinesP(binary, lines,
deltaRho, deltaTheta, minVote,
minLength, maxGap);
return lines;
}
这个方法返回cv::Vec4i 类型的向量,包含每条被检测线段的开始端点和结束端点的坐标。我们可以用下面的方法在图像上绘制检测到的线段:
// 在图像上绘制检测到的直线
void drawDetectedLines(cv::Mat& image, cv::Scalar color=cv::Scalar(255, 255, 255)) {
// 画直线
std::vector<cv::Vec4i>::const_iterator it2 = lines.begin();
while (it2 != lines.end()) {
cv::Point pt1((*it2)[0], (*it2)[1]);
cv::Point pt2((*it2)[2], (*it2)[3]);
cv::line(image, pt1, pt2, color);
++it2;
}
}
输入图像不变,可以用下面的次序检测直线:
// 创建LineFinder 类的实例
LineFinder finder;
// 设置概率霍夫变换的参数
finder.setLineLengthAndGap(100, 20);
finder.setMinVote(60);
// 检测直线并画线
std::vector<cv::Vec4i> lines = finder.findLines(contours);
finder.drawDetectedLines(image);
上面的代码得到如下结果。
检测圆
霍夫变换也能用来检测其他几何物体。事实上,任何可以用一个参数方程来表示的物体,都很适合用霍夫变换来检测。还有一种泛化霍夫变换,可以检测任何形状的物体。
cv::HoughCircles 函数将Canny 检测与霍夫变换结合,它的调用方法是:
cv::GaussianBlur(image, image, cv::Size(5, 5), 1.5);
std::vector<cv::Vec3f> circles;
cv::HoughCircles(image, circles, cv::HOUGH_GRADIENT,
2, // 累加器分辨率(图像尺寸/2)
50, // 两个圆之间的最小距离
200, // Canny 算子的高阈值
100, // 最少投票数
25,
100); // 最小和最大半径
有一点需要反复提醒:在调用cv::HoughCircles 函数之前,要对图像进行平滑化,以减少图像中可能导致误判的噪声。检测的结果存放在cv::Vec3f 实例的向量中。前面两个数值是圆心坐标,第三个数值是半径。
得到存放圆的向量后,就可以在图像上画出这些圆。方法是迭代遍历该向量,并调用cv::circle 函数,传入获得的参数:
std::vector<cv::Vec3f>::const_iterator itc = circles.begin();
while (itc != circles.end()) {
cv::circle(image,
cv::Point((*itc)[0], (*itc)[1]), // 圆心
(*itc)[2], // 半径
cv::Scalar(255), // 颜色
2); // 厚度
++itc;
}
使用上述方法和参数在测试图像上执行,得到如下结果。
霍夫变换的目的是在二值图像中找出全部直线,并且这些直线必须穿过足够多的像素点。它的处理方法是,检查输入的二值分布图中每个独立的像素点,识别出穿过该像素点的所有可能直线。如果同一条直线穿过很多像素点,就说明这条直线明显到足以被认定。
7.4 点集的直线拟合
在某些应用程序中,光是检测出图像中的直线还不够,还需要精确地估计直线的位置和方向。本节将介绍如何拟合出最适合指定点集的直线。
首先需要识别出图像中靠近直线的点。使用一条上节检测到的直线。把cv::HoughLinesP检测到的直线存放在std::vector<cv::Vec4i>类型的变量lines 中。为了提取出靠近这条直线(我们叫它第一条直线)的点集,可以继续以下步骤:在黑色图像上画一条白色直线,并且穿过用于检测直线的Canny 轮廓图。这可以用这些语句实现:
int n = 0; // 选用直线0
// 黑色图像
cv::Mat oneline(contours.size(), CV_8U, cv::Scalar(0));
// 白色直线
cv::line(oneline, cv::Point(lines[n][0], lines[n][1]),
cv::Point(lines[n][2], lines[n][3]), cv::Scalar(255), 3); // 直线宽度
// 轮廓与白色直线进行“与”运算
cv::bitwise_and(contours, oneline, oneline);
结果是一个包含了与指定直线相关的点的图像。为了引入公差,我们画了具有一定宽度(这里是3)的直线,因此位于指定邻域内的点都能被接受。
得到的图像如下所示(为了提升显示效果,对其做了反转)。
然后可以把这些集合内点的坐标插入到cv::Point 对象的std::vector 类型中(也可以使用浮点数坐标,即cv::Point2f),代码如下所示:
std::vector<cv::Point> points;
// 迭代遍历像素,得到所有点的位置
for (int y = 0; y < oneline.rows; y++) {
// 行y
uchar* rowPtr = oneline.ptr<uchar>(y);
for (int x = 0; x < oneline.cols; x++) {
// 列x
// 如果在轮廓上
if (rowPtr[x]) {
points.push_back(cv::Point(x, y));
}
}
}
得到点集后,利用这些点集拟合出直线。利用OpenCV 的函数cv::fitLine 可以很轻松地得到最优的拟合直线:
cv::Vec4f line;
cv::fitLine(points, line,
cv::DIST_L2, // 距离类型
0, // L2 距离不用这个参数
0.01, 0.01); // 精度
上述代码把直线方程式作为参数,形式是一个单位方向向量(cvVec4f 的前两个数值)和直线上一个点的坐标(cvVec4f 的后两个数值)。最后两个参数是所需的直线精度。
直线方程式通常用于某些属性的计算(例如需要精确参数的校准)。为了演示它的用法,也为了验证计算的直线是否正确,我们在图像上模拟一条直线。这里只是随意画了一条长度为100像素、宽度为2 像素的黑色线段(为了便于观察):
int x0 = line[2]; // 直线上的一个点
int y0 = line[3];
int x1 = x0 + 100 * line[0]; // 加上长度为100 的向量
int y1 = y0 + 100 * line[1]; //(用单位向量生成)
// 绘制这条线
cv::line(image, cv::Point(x0, y0), cv::Point(x1, y1),
0.2); // 颜色和宽度
下图显示了与道路边界非常一致的直线。
7.5 提取连续区域
图像通常包含各种物体,图像分析的目的之一就是识别和提取这些物体。在物体检测和识别程序中,第一步通常就是生成二值图像,找到感兴趣物体所处的位置。下一个步骤是从由1 和 0 组成的像素集合中提取出物体。
来看第5 章的水牛二值图像。
执行一次简单的阈值化操作,然后应用形态学滤波器,就能获得这幅图像。本节将介绍如何从这样的图像中提取物体。具体来说,就是提取连续区域,即二值图像中由一批连通的像素构成的形状。
OpenCV 提供了一个简单的函数,可以提取出图像中连续区域的轮廓,这个函数就是cv::findContours:
// 用于存储轮廓的向量
std::vector<std::vector<cv::Point>> contours;
cv::findContours(image,
contours, // 存储轮廓的向量
cv::RETR_EXTERNAL, // 检索外部轮廓
cv::CHAIN_APPROX_NONE); // 每个轮廓的全部像素
显然,函数输入的就是上述二值图像。输出的是一个存储轮廓的向量,每个轮廓用一个cv::Point 类型的向量表示。因此输出参数是一个由std::vector 实例构成的std::vector实例。
此外,函数还指明了两个选项,第一个选项表示只检索外部轮廓,即物体内部的空穴会被忽略;第二个选项指明了轮廓的格式。使用当前的选项,向量将列出轮廓的全部点。如使用cv::CHAIN_APPROX_SIMPLE,则只会列出包含水平、垂直或对角线轮廓的端点。用其他选项可得到逼近轮廓的更复杂的链,对轮廓的表示将更紧凑。
在前面的图像中可检测到9 个连续区域,用contours.szie()查看轮廓的数量。
有一个非常实用的函数可在图像(这里用白色图像)上画出那些区域的轮廓:
// 在白色图像上画黑色轮廓
cv::Mat result(image.size(), CV_8U, cv::Scalar(255));
cv::drawContours(result, contours,
-1, // 画全部轮廓
0, // 用黑色画
2); // 宽度为2
如果这个函数的第三个参数是负数,就画出全部轮廓,否则就可以指定要画的轮廓的序号,得到的结果如下所示。
提取轮廓的算法很简单,它系统地扫描图像,直到找到连续区域。从区域的起点开始,沿着它的轮廓对边界像素做标记。处理完这个轮廓后,就从上个位置继续扫描,直到发现新的区域。
你也可以对识别出的连续区域进行独立的分析。例如,如果事先已经知道感兴趣物体的大小,就可以将部分区域删除。我们采用区域边界的最小值和最大值,具体做法是迭代遍历存放轮廓的向量,并且删除无效的轮廓:
// 删除太短或太长的轮廓
int cmin = 50; // 最小轮廓长度
int cmax = 500; // 最大轮廓长度
std::vector<std::vector<cv::Point>>::
iterator itc = contours.begin();
// 针对所有轮廓
while (itc != contours.end()) {
// 验证轮廓大小
if (itc->size() < cmin || itc->size() > cmax)
itc = contours.erase(itc);
else
++itc;
}
因为std::vector 中的删除操作的时间复杂度为O(N),所以这个循环的效率还可以更高。不过这种小型向量的总体开销也不会不大。
这次我们在原始图像上画出剩下的轮廓,结果如下所示。
cv::findContours 函数也能检测二值图像中的所有闭合轮廓,包括区域内部空穴构成的轮廓。实现方法是在调用函数时指定另一个标志:
cv::findContours(image,
contours, // 存放轮廓的向量
cv::RETR_LIST, // 检索全部轮廓
cv::CHAIN_APPROX_NONE); // 全部像素
调用后得到如下轮廓。
注意,背景森林中增加了额外的轮廓。你也可以把这些轮廓分层次组织起来。主区域是父轮廓,它内部的空穴是子轮廓;如果空穴内部还有区域,那它们就是上述子轮廓的子轮廓,以此类推。使用cv::RETR_TREE 标志可得到这个层次结构,代码为:
std::vector<cv::Vec4i> hierarchy;
cv::findContours(image, contours, // 存放轮廓的向量
hierarchy, // 层次结构
cv::RETR_TREE, // 树状结构的轮廓
cv::CHAIN_APPROX_NONE); // 每个轮廓的全部像素
本例中每个轮廓都有一个对应的层次元素,存放次序与轮廓相同。层次元素由四个整数构成,前两个整数是下一个和上一个同级轮廓的序号,后两个整数是第一个子轮廓和父轮廓的序号。如果序号为负,就表示轮廓列表的末端。cv::RETR_CCOMP 标志的作用与之类似,但只允许两个层次。
7.6 计算区域的形状描述子
本节将介绍几种OpenCV 的形状描述子,用于描述连续区域的形状。
OpenCV 中用于形状描述的函数有很多,我们把其中的几个应用到上节提取的区域。在下面的代码段中,我们将计算轮廓的形状描述子(从contours[0]到contours[3]),并在轮廓图像(宽度为1)上画出结果(宽度为2)。
第一个是边界框,用于右下角的区域:
// 测试边界框
cv::Rect r0= cv::boundingRect(contours[0]);
// 画矩形
cv::rectangle(result,r0, 0, 2)
最小覆盖圆的情况也类似,将它用于右上角的区域:
// 测试覆盖圆
float radius;
cv::Point2f center;
cv::minEnclosingCircle(contours[1],center,radius);
// 画圆形
cv::circle(result,center, static_cast<int>(radius), cv::Scalar(0),2);
计算区域轮廓的多边形逼近的代码如下(位于左侧区域):
// 测试多边形逼近
std::vector<cv::Point> poly;
cv::approxPolyDP(contours[2],poly,5,true);
// 画多边形
cv::polylines(result, poly, true, 0, 2);
注意,多边形绘制函数cv::polylines 与其他画图函数很相似。第三个布尔型参数表示该轮廓是否闭合(如果闭合,最后一个点将与第一个点相连)。
凸包是另一种形式的多边形逼近(位于左侧第二个区域)
// 测试凸包
std::vector<cv::Point> hull;
cv::convexHull(contours[3],hull);
// 画多边形
cv::polylines(result, hull, true, 0, 2);
最后,计算轮廓矩是另一种功能强大的描述子(在所有区域内部画出重心):
// 测试轮廓矩
// 迭代遍历所有轮廓
itc = contours.begin();
while (itc != contours.end()) {
// 计算所有轮廓矩
cv::Moments mom = cv::moments(cv::Mat(*itc++));
// 画重心
cv::circle(result,
// 将重心位置转换成整数
cv::Point(mom.m10 / mom.m00, mom.m01 / mom.m00),
2, cv::Scalar(0), 2); // 画黑点
}
结果如下所示。
在表示和定位图像中区域的方法中,边界框可能是最简洁的。它的定义是:能完整包含该形状的最小垂直矩形。比较边界框的高度和宽度,可以获得物体在垂直或水平方向的特征(例如可以通过计算高度与宽度的比例,分辨出一幅图像是汽车还是行人)。
最小覆盖圆通常在只需要区域尺寸和位置的近似值时使用。
如果要更紧凑地表示区域的形状,可采用多边形逼近。在创建时要制定精确度参数,表示形状与对应的简化多边形之间能接受的最大距离。它是cv::approxPolyDP 函数的第四个参数。返回的结果是cv::Point 类型的向量,表示多边形的顶点个数。在画这个多边形时,要迭代遍历整个向量,并在顶点之间画直线,把它们逐个连接起来。
形状的凸包(或凸包络)是包含该形状的最小凸多边形。可以把它看作一条绕在区域周围的橡皮筋。可以看出,在形状轮廓中凹进去的位置,凸包轮廓会与原始轮廓发生偏离。
通常可用凸包缺陷来表示这些位置。OpenCV 中有一个专门用于识别凸包缺陷的函数cv::convexityDefects,它的调用方法如下所示:
std::vector<cv::Vec4i> defects;
cv::convexityDefects(contour, hull, defects);
参数contour 和hull 分别表示原始轮廓和凸包轮廓(两者都用std::vector<cv::Point>的实例表示)。函数输出的是一个向量,它的每个元素由四个整数组成:前两个整数是顶点在轮廓中的索引,用来界定该缺陷;第三个整数表示凹陷内部最远的点;最后的整数表示最远点与凸包之间的距离。
轮廓矩是形状结构分析中常用的数学模型。OpenCV 定义了一个数据结构,封装了形状中计算得到的所有轮廓矩。它是函数cv::moments 的返回值。这些轮廓矩共同表示物体形状的紧凑程度,常用于特征识别。我们只是用该结构获得每个区域的重心,这里用前面三个空间轮廓矩计算得到。
四边形检测
第5 章讲到的MSER 特征是一种高效的工具,可以从图像中提取形状。利用前面用MSER得到的结果,我们来构建一个在图像中监测四边形区域的算法。在当前图像中,该算法可用于检测建筑物的窗户。要获取MSER 的二值图像非常简单:
cv::Mat components;
components = cv::imread("mser.bmp", 0);
components = components == 255;
cv::morphologyEx(components, components, cv::MORPH_OPEN,
cv::Mat(), cv::Point(-1, -1), 3);
得到的图像如下所示。
下一步是获取轮廓:
std::vector<std::vector<cv::Point> > contours;
// 翻转图像(背景必须是黑色的)
cv::Mat componentsInv = 255 - components;
// 得到连续区域的轮廓
cv::findContours(componentsInv,
contours, // 轮廓的向量
cv::RETR_EXTERNAL, // 检索外部轮廓
cv::CHAIN_APPROX_NONE);
最后得到全部轮廓,并用多边形粗略地逼近它们:
// 白色图像
cv::Mat quadri(components.size(), CV_8U, 255);
// 针对全部轮廓
std::vector<std::vector<cv::Point>>::iterator it = contours.begin();
while (it != contours.end()) {
std::vector<cv::Point> poly;
// 用多边形逼近轮廓
cv::approxPolyDP(*it, poly, 5, true);
// 是否为四边形?
if (poly.size() == 4) {
// 画出来
cv::polylines(quadri, poly, true, 0, 2);
}
++it;
}
检测结果如下所示。