教程:深入理解轮廓、采样点与 pointPolygonTest
及其在测量线距中的应用
1. 轮廓与采样点
1.1 轮廓是什么?
在图像处理中,轮廓(contour) 是指图像中物体的边界或边缘。例如:
- 一个圆形物体的轮廓是圆周。
- 一个方形物体的轮廓是四条边的集合。
在 OpenCV 中,轮廓是通过 findContours
函数检测出来的,结果是一个由多个点组成的列表,这些点被称为采样点。但需要明确以下两点:
- 真实轮廓:是连续的边界线,比如一个完整的圆周或方形的四条边。
- 采样点:是这条连续边界上的离散点,用于近似表示轮廓。
图示(用文字模拟):
真实轮廓(连续的边界)/\/ \/ \
/______\采样点(离散的点). .. .
. .
- 真实轮廓:一条完整的、连续的线。
- 采样点:在这条线上抽取的一些点,可能稀疏也可能密集,取决于检测算法的参数(如
CV_CHAIN_APPROX_SIMPLE
或CV_CHAIN_APPROX_NONE
)。
1.2 采样点的意义与局限性
采样点是轮廓的离散表示,OpenCV 使用这些点之间的直线段来近似描述整个轮廓。也就是说,轮廓的边界不仅仅是这些采样点本身,还包括它们之间的连线。
举个例子:
假设有一个正方形轮廓,采样点是四个顶点:
-
(0,0)
-
(10,0)
-
(10,10)
-
(0,10)
-
真实轮廓:包括四条边上的所有点,例如
(5,0)
(在底边上)、(10,5)
(在右边上)。 -
采样点:只有四个顶点,
(5,0)
并不是采样点,但它是轮廓边界的一部分。
图示(文字模拟):
正方形轮廓:
(0,0) ---- (10,0)| |
(0,10) --- (10,10)边界上的点(非采样点):
(5,0) 在底边上,(10,5) 在右边上
局限性:
采样点是离散的,无法完全覆盖轮廓的每一个位置。如果只关注采样点本身,可能会忽略边界上其他关键点的位置。例如,(5,0)
不在采样点中,但它是轮廓的一部分,在计算距离时可能非常重要。
2. pointPolygonTest
函数详解
2.1 函数的作用
pointPolygonTest
是 OpenCV 中一个非常有用的函数,它的主要功能是:
计算一个点到轮廓边界的最短距离。
函数签名(简化版):
distance = cv2.pointPolygonTest(contour, pt, measureDist=True)
contour
:轮廓的采样点列表(比如正方形的四个顶点)。pt
:要测试的点坐标,比如(15,5)
。measureDist=True
:表示返回实际距离(而不是仅仅判断点在轮廓内、外还是边界上)。
返回值:
- 正值:点在轮廓外部,值是到边界的最短距离。
- 负值:点在轮廓内部,值是到边界的最短距离的负数。
- 0:点在轮廓边界上。
在测量线距的代码中,通常会使用 abs
取绝对值,确保距离总是正数。
2.2 计算的是什么距离?
你可能会问:这个距离是到采样点的距离吗?
答案是否定的。
pointPolygonTest
计算的是点到轮廓整个边界的最短距离,而不是仅仅到某个采样点的距离。轮廓的边界包括采样点以及它们之间的直线段。
举个例子:
假设轮廓是一个正方形,采样点是:
(0,0)
(10,0)
(10,10)
(0,10)
测试点 pt = (15,5)
。
-
如果只计算到采样点的距离:
- 到
(10,0)
的距离 = √((15-10)² + (5-0)²) ≈ 7.07 - 到
(10,10)
的距离 = √((15-10)² + (5-10)²) ≈ 7.07 - 到
(0,0)
的距离 = √((15-0)² + (5-0)²) ≈ 15.81 - 到
(0,10)
的距离 = √((15-0)² + (5-10)²) ≈ 15.81 - 最小距离 ≈ 7.07。
- 到
-
实际
pointPolygonTest
的计算:- 轮廓的右边是
(10,0)
到(10,10)
的直线段。 pt = (15,5)
到这条边的最近点是(10,5)
(垂直投影点)。- 距离 = |15 - 10| = 5。
- 轮廓的右边是
结果:pointPolygonTest
返回 5,而不是 7.07。
图示(文字模拟):
正方形轮廓:
(0,0) ---- (10,0)| |
(0,10) --- (10,10)点 pt:(15,5) <- 到右边界的最近点是 (10,5),距离 = 5
结论:
pointPolygonTest
考虑的是轮廓的连续边界(采样点之间的直线段),而不是仅仅计算到采样点的距离。这也是它在测量线距时更准确的原因,因为它能捕捉到边界上非采样点的贡献。
3. 为什么需要双向计算?
在测量两个轮廓之间的距离时,代码通常会进行双向计算:
- 从轮廓 A 的采样点到轮廓 B 的距离。
- 从轮廓 B 的采样点到轮廓 A 的距离。
然后取两者的最小值作为最终结果。为什么不能只算一个方向呢?
3.1 单向计算的不足
单向计算(比如只从轮廓 A 到轮廓 B)可能会漏掉某些关键的最近点。因为两个轮廓的形状可能复杂,采样点的分布也不均匀,单向计算无法保证捕捉到全局最小距离。
举个例子:
- 轮廓 A:一个五角星形状,有尖锐的角。
- 轮廓 B:一个圆形。
图示(文字模拟):
五角星(轮廓 A) 圆(轮廓 B)/\ O/ \ / \/ \ / \
-
只从轮廓 B(圆)到轮廓 A(五角星)计算:
- 圆的采样点可能是均匀分布的,比如 12 个点(像时钟上的刻度)。
- 这些点到五角星的距离可能都不包括五角星尖角到圆的最短距离(因为尖角可能不在圆的采样点附近)。
-
反过来,从轮廓 A 到轮廓 B 计算:
- 五角星的采样点包括尖角(比如
(5,10)
)。 pointPolygonTest
从尖角(5,10)
到圆的边界,可能会发现更小的距离(比如尖角几乎碰到圆)。
- 五角星的采样点包括尖角(比如
结果:
- 单向计算(从圆到五角星)可能得到较大的距离(比如 10)。
- 双向计算能捕捉到更小的距离(比如 2)。
3.2 双向计算的优势
- 全面性:从两个方向计算,利用了两组采样点,增加了捕捉最近点的机会。
- 弥补采样点的局限性:采样点只是轮廓的近似,双向计算通过
pointPolygonTest
考虑了两条边界的完整形状。
结论:
双向计算确保了无论轮廓形状如何复杂,都能找到两个轮廓之间的全局最小距离。这是算法鲁棒性的关键。
4. 综合例子
假设我们有两个轮廓:
- 轮廓 A:正方形,采样点
(0,0)
,(10,0)
,(10,10)
,(0,10)
。 - 轮廓 B:三角形,采样点
(15,0)
,(20,5)
,(15,10)
。
目标:计算两轮廓的最短距离。
-
从轮廓 A 到轮廓 B:
- 点
(10,0)
到三角形边界的最短距离(用pointPolygonTest
计算)。- 三角形底边
(15,0)
到(20,5)
,(10,0)
的最近点可能是(15,0)
,距离 = 5。
- 三角形底边
- 点
(10,10)
到三角形边界的最短距离。- 三角形顶边
(15,10)
到(20,5)
,(10,10)
的最近点可能是(15,10)
,距离 = 5。
- 三角形顶边
- 其他点如
(0,0)
和(0,10)
距离更远。 - 最小值 ≈ 5。
- 点
-
从轮廓 B 到轮廓 A:
- 点
(15,0)
到正方形边界的最短距离。- 正方形右边
(10,0)
到(10,10)
,最近点(10,0)
,距离 = 5。
- 正方形右边
- 点
(20,5)
到正方形边界的最短距离。- 正方形右边
(10,0)
到(10,10)
,最近点(10,5)
,距离 = 10。
- 正方形右边
- 点
(15,10)
到正方形边界的最短距离。- 正方形顶边
(0,10)
到(10,10)
,最近点(10,10)
,距离 = 5。
- 正方形顶边
- 最小值 ≈ 5。
- 点
-
最终结果:
- 比较两方向的最小值(5 和 5),全局最小距离 = 5。
图示(文字模拟):
正方形(轮廓 A) 三角形(轮廓 B)
(0,0) ---- (10,0) (15,0)| | \
(0,10) --- (10,10) (20,5)/(15,10)
5. 代码实现与详细讲解:getLineSpace
函数
5.1 代码功能
getLineSpace
函数的目的是测量图像中两个最大轮廓之间的最小距离。输入参数包括:
src
:原始图像。mask
:掩膜,用于过滤感兴趣区域。result
:输出参数,存储计算出的最小距离。rects
:输出参数,存储两个轮廓的旋转矩形。
5.2 完整代码
int getLineSpace(Mat src, Mat mask, double& result, vector<RotatedRect>& rects) {if (src.empty() || mask.empty()) return 0;Mat dst;if (src.channels() != 1) {cvtColor(src, dst, CV_BGR2GRAY);bitwise_and(dst, mask, dst);} else {bitwise_and(src, mask, dst);}if (IsEmptyMat(dst)) return 0;vector<vector<Point>> contours;findContours(dst, contours, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_NONE);if (contours.size() < 2) return 0;// 找出两个最大轮廓double maxArea1 = 0, maxArea2 = 0;int id1 = -1, id2 = -1;for (size_t i = 0; i < contours.size(); i++) {double area = contourArea(contours[i]);if (area > maxArea1) {maxArea2 = maxArea1;id2 = id1;maxArea1 = area;id1 = i;} else if (area > maxArea2) {maxArea2 = area;id2 = i;}}vector<vector<Point>> twoContours = { contours[id1], contours[id2] };rects = { minAreaRect(contours[id1]), minAreaRect(contours[id2]) };if (rects.size() != 2) return 0;// 双向计算最小距离double minDist = 2500;for (const auto& pt : twoContours[1]) {double realDist = abs(pointPolygonTest(twoContours[0], pt, true));if (minDist > realDist) minDist = realDist;}for (const auto& pt : twoContours[0]) {double realDist = abs(pointPolygonTest(twoContours[1], pt, true));if (minDist > realDist) minDist = realDist;}result = minDist;return 1;
}
5.3 详细讲解
5.3.1 图像预处理:为轮廓检测做准备
if (src.empty() || mask.empty()) return 0;
Mat dst;
if (src.channels() != 1) {cvtColor(src, dst, CV_BGR2GRAY);bitwise_and(dst, mask, dst);
} else {bitwise_and(src, mask, dst);
}
if (IsEmptyMat(dst)) return 0;
- 输入检查:
- 检查输入图像
src
和掩膜mask
是否为空。如果为空,返回 0,表示处理失败。这是基本的鲁棒性设计,避免后续操作崩溃。
- 检查输入图像
- 图像转换与掩膜应用:
- 如果
src
是多通道图像(例如彩色图像,channels() != 1
),通过cvtColor
将其转换为灰度图,存储在dst
中。然后,使用bitwise_and
将灰度图与mask
按位与,保留掩膜中非零区域的像素。 - 如果
src
已为单通道图像(例如灰度图),直接与mask
按位与,结果存储在dst
中。 - 目的:无论输入是什么格式,最终得到一个单通道的
dst
,其中只包含掩膜允许的区域。例如,在文档扫描中,掩膜可以屏蔽背景,只保留文本行。
- 如果
- 结果检查:
- 使用
IsEmptyMat(dst)
检查dst
是否为空(例如掩膜将所有像素都屏蔽,导致dst
全为零)。如果是,返回 0,表示没有可处理的区域。
- 使用
5.3.2 轮廓检测:找到图像中的对象边界
vector<vector<Point>> contours;
findContours(dst, contours, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_NONE);
if (contours.size() < 2) return 0;
- 轮廓检测:
- 使用 OpenCV 的
findContours
函数在dst
上检测轮廓:CV_RETR_EXTERNAL
:只检测最外层的轮廓,忽略嵌套的内部轮廓。CV_CHAIN_APPROX_NONE
:保留轮廓上的所有点,而不是简化轮廓(如CV_CHAIN_APPROX_SIMPLE
只保留拐点)。- 结果存储在
contours
中,每个轮廓是一个vector<Point>
,表示边界上的采样点序列。
- 使用 OpenCV 的
- 轮廓数量检查:
- 如果检测到的轮廓数量少于 2 个,返回 0。因为目标是计算两个轮廓之间的距离,至少需要两个轮廓才能继续。
5.3.3 选择两个最大轮廓:聚焦主要目标
double maxArea1 = 0, maxArea2 = 0;
int id1 = -1, id2 = -1;
for (size_t i = 0; i < contours.size(); i++) {double area = contourArea(contours[i]);if (area > maxArea1) {maxArea2 = maxArea1;id2 = id1;maxArea1 = area;id1 = i;} else if (area > maxArea2) {maxArea2 = area;id2 = i;}
}
vector<vector<Point>> twoContours = { contours[id1], contours[id2] };
rects = { minAreaRect(contours[id1]), minAreaRect(contours[id2]) };
if (rects.size() != 2) return 0;
- 找出最大轮廓:
- 初始化变量:
maxArea1
和maxArea2
:记录最大和次大面积。id1
和id2
:记录对应轮廓的索引。
- 遍历所有轮廓,使用
contourArea
计算每个轮廓的面积。 - 如果当前面积大于
maxArea1
:- 将原来的
maxArea1
降级为maxArea2
,id1
降级为id2
。 - 更新
maxArea1
和id1
为当前值。
- 将原来的
- 如果当前面积大于
maxArea2
但小于maxArea1
,只更新maxArea2
和id2
。
- 初始化变量:
- 存储最大轮廓:
- 将面积最大的两个轮廓(
contours[id1]
和contours[id2]
)存储在twoContours
中。
- 将面积最大的两个轮廓(
- 生成旋转矩形:
- 对两个轮廓分别调用
minAreaRect
,生成最小面积的旋转矩形,存储在rects
中。rects
是一个输出参数,可能用于后续可视化或分析。
- 对两个轮廓分别调用
- 检查:
- 如果
rects
的大小不是 2,返回 0,确保后续操作基于两个有效轮廓。
- 如果
- 为什么要选最大轮廓?:
- 在实际应用中(例如文档分析),图像可能包含许多小噪声轮廓。选择面积最大的两个轮廓可以聚焦于主要目标(例如两条主要的文本行),过滤掉无关的小区域。
5.3.4 双向计算最小距离:确保全局最优
double minDist = 2500;
for (const auto& pt : twoContours[1]) {double realDist = abs(pointPolygonTest(twoContours[0], pt, true));if (minDist > realDist) minDist = realDist;
}
for (const auto& pt : twoContours[0]) {double realDist = abs(pointPolygonTest(twoContours[1], pt, true));if (minDist > realDist) minDist = realDist;
}
result = minDist;
return 1;
- 初始化:
- 将
minDist
初始化为 2500(一个较大的值),作为最小距离的初始基准。这个值应大于图像中可能的最大距离。
- 将
- 从轮廓 B 到轮廓 A:
- 遍历
twoContours[1]
(第二个轮廓)的每个采样点pt
。 - 使用
pointPolygonTest
计算pt
到twoContours[0]
(第一个轮廓)的最近距离,取绝对值(abs
),并更新minDist
如果新距离更小。
- 遍历
- 从轮廓 A 到轮廓 B:
- 遍历
twoContours[0]
的每个采样点pt
。 - 计算到
twoContours[1]
的最近距离,同样更新minDist
。
- 遍历
- 输出结果:
- 将最终的
minDist
赋值给result
,返回 1,表示成功。
- 将最终的
- 深入理解关键点:
pointPolygonTest
的细节:- 它计算点到轮廓边界(采样点之间的直线段)的最近距离,而不是仅到采样点。
- 参数
true
表示返回实际距离值。 - 例如,点
(15,5)
到正方形边界(10,0)
到(10,10)
的距离是 5(垂线距离)。
- 双向计算的必要性:
- 单向计算可能漏掉某些关键的最短距离(见第 3 节的五角星与圆的例子)。
- 双向计算利用两组采样点,确保全局最小距离。
6. 总结与建议
6.1 总结
- 轮廓:由采样点表示的连续边界,真实边界包括采样点之间的直线段。
pointPolygonTest
:计算点到轮廓边界(而非仅采样点)的最近距离,考虑了整个连续边界。- 双向计算:从两个轮廓的采样点出发,确保找到全局最小距离,弥补单向计算的不足。
getLineSpace
函数:- 预处理图像,应用掩膜过滤无关区域。
- 检测所有外部轮廓。
- 选择面积最大的两个轮廓,并生成旋转矩形。
- 双向计算两个轮廓之间的最小距离。
6.2 建议
- 可视化验证:
- 在代码中添加调试输出,例如用
cv::drawContours
绘制轮廓,用cv::line
标注最小距离的位置,观察minDist
是否符合预期。
- 在代码中添加调试输出,例如用
- 测试复杂形状:
- 输入包含凹形或不规则轮廓的图像,验证双向计算的必要性和准确性。
- 优化性能:
- 如果轮廓点很多,遍历所有点可能较慢。可以考虑采样部分点(例如每隔几个点取一个)或使用近似算法(如先用边界框粗略估计距离)。
- 鲁棒性增强:
- 检查轮廓的有效性(例如面积是否过小),避免噪声干扰。