深度图ROI扫描与打标区域定位算法:均值与极差阈值的工程智慧
一、引言
激光打标需要打在工件的平坦区域 上,否则激光聚焦不准,打出的标记会模糊甚至损坏工件。那么,如何从一张深度图中自动找到"足够平坦"的区域?这正是本项目核心算法------FindBox------要解决的问题。
本文从第一性原理出发,讲解"如何用均值与极差阈值在深度图中定位平坦区域",并结合FindBox.cpp源码,深入剖析ROI扫描、均值计算、极差计算、阈值判断的完整算法流程。
二、第一性原理:什么是"平坦区域"
2.1 平坦的数学定义
在深度图中,一个区域是否"平坦",本质上是看该区域内所有像素的深度值是否接近一致。如果深度值都差不多,说明该区域表面平整;如果深度值差异很大,说明表面凹凸不平。
数学上,衡量一组数据"是否接近"有两个常用指标:
- 均值(Mean):数据的平均水平。
- 极差(Range):最大值与最小值之差,衡量数据的离散程度。
2.2 为什么用极差而不是方差
衡量离散程度,统计学上常用方差(Variance)或标准差(Standard Deviation) 。但本项目选择了极差(Range),原因在于:
- 计算简单:只需遍历一次找最大最小值,无需累加平方。
- 直观:极差直接反映"最高点与最低点的高度差",物理意义明确。
- 对打标场景足够:打标只关心区域是否足够平,极差小于阈值即可。
2.3 两个阈值的分工
本项目使用了两个阈值:
- 均值阈值(averThreshold):过滤无效区域。如果某区域平均深度过低(如-5000以下),说明该区域测量失败或超出量程,直接跳过。
- 极差阈值(rgThreshold):判断平坦度。如果某区域极差小于阈值,说明足够平坦,可作为打标区域。
这两个阈值一个管"有效性",一个管"平坦度",分工明确。
三、算法整体流程
FindBox类的核心算法是findBoundingBoxes方法,整体流程如下:
1. 获取深度图尺寸
2. 用固定大小的ROI窗口遍历整张图
3. 对每个ROI:
a. 计算平均深度
b. 若平均深度 < 均值阈值,跳过
c. 计算极差
d. 若极差 <= 极差阈值,记录为候选区域
4. 返回所有候选区域
3.1 数据结构
FindBox.h中定义了BoundingBox结构体,用于存储候选区域信息:
cpp
struct BoundingBox {
Point center; // 区域中心点
double avg_deviation; // 平均极差
int distance_to_bottom_right_x; // 到右下角的横向距离
int distance_to_bottom_right_y; // 到右下角的纵向距离
};
这个结构体不仅记录了中心点,还记录了到右下角的距离,这些信息在后续的坐标换算中用于驱动运动平台。
四、代码实例讲解
4.1 初始化与参数
FindBox构造函数设置了默认参数:
cpp
FindBox::FindBox()
{
this->rgThreshold = 200; // 极差阈值
this->averThreshold = -5000; // 均值阈值
this->box_size.width = 100; // 扫描框宽度
this->box_size.height = 100; // 扫描框高度
}
Init方法允许外部配置这些参数:
cpp
void FindBox::Init(int rg_Threshold, int aver_Threshold, Size box_Size)
{
rgThreshold = rg_Threshold;
averThreshold = aver_Threshold;
box_size = box_Size;
}
这些参数从C#侧通过DLL传入,最终来源于config.ini配置。
4.2 深度图数据装载
arrToImage方法把外部传入的深度数据数组转换为OpenCV的Mat:
cpp
void FindBox::arrToImage(int height, int width, short* data)
{
depthImage = Mat::zeros(height, width, CV_16SC1);
depthImage = Mat(height, width, CV_16SC1, data);
}
这里把short*指针直接包装成Mat,不复制数据,效率高。CV_16SC1表示16位有符号整数单通道。
4.3 均值计算
mean方法计算ROI区域的平均深度:
cpp
double FindBox::mean(const Rect& roi) {
double sum = 0.0;
int count = 0;
for (int y = roi.y; y < roi.y + roi.height; ++y) {
for (int x = roi.x; x < roi.x + roi.width; ++x) {
sum += depthImage.at<short>(y, x);
count++;
}
}
return sum / count;
}
关键点:
- 双重循环:遍历ROI内的所有像素。
depthImage.at<short>(y, x):OpenCV的at方法按坐标访问像素,<short>指定数据类型。- 累加求和:把所有深度值累加,最后除以像素数得到均值。
4.4 极差计算
range方法计算ROI区域的极差:
cpp
double FindBox::range(const Rect& roi) {
short min_depth = SHRT_MAX; // 初始化为最大short值
short max_depth = SHRT_MIN; // 初始化为最小short值
for (int y = roi.y; y < roi.y + roi.height; ++y) {
for (int x = roi.x; x < roi.x + roi.width; ++x) {
short depth = depthImage.at<short>(y, x);
if (depth < min_depth) min_depth = depth; // 更新最小值
if (depth > max_depth) max_depth = depth; // 更新最大值
}
}
return max_depth - min_depth; // 极差 = 最大值 - 最小值
}
关键点:
- 初始化技巧 :
min_depth初始化为SHRT_MAX(32767),max_depth初始化为SHRT_MIN(-32768),这样任何真实值都能正确更新。 - 单次遍历:一次遍历同时找到最大最小值,效率高。
- 极差计算 :
max_depth - min_depth即为极差。
4.5 核心扫描算法
findBoundingBoxes是核心方法:
cpp
vector<BoundingBox> FindBox::findBoundingBoxes()
{
int image_height = depthImage.rows;
int image_width = depthImage.cols;
vector<BoundingBox> bounding_boxes;
for (int y = 1; y <= image_height - box_size.height; y += box_size.height) {
for (int x = 1; x <= image_width - box_size.width; x += box_size.width) {
Rect roi(x - 1, y - 1, box_size.width, box_size.height); // 创建ROI
double avg_depth = mean(roi); // 计算平均深度
if (avg_depth < averThreshold) {
continue; // 平均深度过低,跳过
}
double r = range(roi); // 计算极差
if (r <= rgThreshold) { // 极差小于阈值,足够平坦
bb.center.x = x + box_size.width / 2 - 1; // 计算中心点X
bb.center.y = y + box_size.height / 2 - 1; // 计算中心点Y
bb.avg_deviation = r; // 存储极差
bb.distance_to_bottom_right_x = bb.center.x; // 到右下角X距离
bb.distance_to_bottom_right_y = bb.center.y; // 到右下角Y距离
depthImage.at<short>(bb.center.y, bb.center.x) = 32678; // 标记中心点
bounding_boxes.push_back(bb); // 加入候选列表
}
}
}
return bounding_boxes;
}
4.6 扫描策略详解
这段代码的扫描策略值得深入分析:
-
步进扫描 :
y += box_size.height和x += box_size.width,ROI窗口以自身大小为步长移动,不重叠。这样整张图被划分成一个个不重叠的格子。 -
边界处理 :
y <= image_height - box_size.height,确保ROI不超出图像边界。 -
ROI偏移 :
Rect roi(x - 1, y - 1, ...),从1开始遍历,ROI左上角偏移1像素,避免边界问题。 -
中心点计算 :
x + box_size.width / 2 - 1,ROI左上角加上半宽得到中心点。 -
中心点标记 :
depthImage.at<short>(bb.center.y, bb.center.x) = 32678,把中心点像素值设为32678(接近short最大值),用于可视化标记。
4.7 算法复杂度分析
假设图像尺寸为W×H,ROI尺寸为w×h,则:
- ROI数量 = (W/w) × (H/h)
- 每个ROI需要遍历 w×h 个像素
- 总复杂度 = (W/w) × (H/h) × w×h = W×H
也就是说,算法总复杂度是O(W×H),即与图像总像素数成正比。对于1306×2450的图像,约320万像素,单次扫描在C++中非常快。
五、算法在DLL中的封装
5.1 DLL导出接口
dll.cpp把FindBox封装成DLL导出函数,供C#调用:
cpp
static FindBox* g_FindBox;
int Initialize()
{
g_FindBox = new FindBox();
return 0;
}
int Release()
{
g_FindBox->UnInit();
delete(g_FindBox);
return 0;
}
int SetConfig(int rg_Threshold, int aver_Threshold, Size box_Size)
{
g_FindBox->Init(rg_Threshold, aver_Threshold, box_Size);
return 0;
}
bool StartWork(int height, int width, short* data, BoundingBox* b)
{
g_FindBox->arrToImage(height, width, data);
vector<BoundingBox> bounding_boxes = g_FindBox->findBoundingBoxes();
if (!bounding_boxes.empty())
{
b->avg_deviation = bounding_boxes[0].avg_deviation;
b->center = bounding_boxes[0].center;
b->distance_to_bottom_right_x = bounding_boxes[0].distance_to_bottom_right_x;
b->distance_to_bottom_right_y = bounding_boxes[0].distance_to_bottom_right_y;
return true;
}
return false;
}
5.2 全局单例模式
这里使用了一个全局静态指针 g_FindBox来持有FindBox实例:
Initialize():创建实例。Release():销毁实例。SetConfig():配置参数。StartWork():执行定位。
这种"初始化-配置-工作-释放"的生命周期管理,是DLL封装的经典模式。
5.3 返回第一个候选区域
StartWork只返回第一个候选区域(bounding_boxes[0])。这是因为在实际打标场景中,通常只需要定位一个打标位置。如果需要多个位置,可以扩展返回整个列表。
六、算法在C#侧的调用
在MainView.cs的driveBtn_Click方法中,C#侧调用DLL:
csharp
DllMethod.Initialize();
Size BoxSize = new Size((CConfig.RoiWidth*1000/CConfig.Accuracy), (CConfig.RoiHeight*1000/CConfig.Accuracy));
DllMethod.SetConfig(CConfig.RgThreshold, CConfig.AverThreshold, BoxSize);
// 数组转内存
IntPtr dataPtr = Marshal.AllocHGlobal(imagedataInt16.Count * sizeof(Int16));
Marshal.Copy(imagedataInt16.ToArray(), 0, dataPtr, imagedataInt16.Count);
// 结构体初始化
IntPtr bb = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(Box)));
Box b = (Box)Marshal.PtrToStructure(bb, typeof(Box));
b.center.X = 0; b.center.Y = 0;
b.avg_deviation = 0; b.x_dis = 0; b.y_dis = 0;
Marshal.StructureToPtr(b, bb, false);
bool ret = DllMethod.StartWork(imageHeight, imageWidth, dataPtr, bb);
Box box = (Box)Marshal.PtrToStructure(bb, typeof(Box));
Marshal.FreeHGlobal(dataPtr);
这里有几个关键点:
-
ROI尺寸换算 :
RoiWidth*1000/Accuracy把毫米转换为像素。例如RoiWidth=16mm,Accuracy=75μm,则16×1000/75≈213像素。 -
内存分配 :用
Marshal.AllocHGlobal分配非托管内存,传递数据指针。 -
结构体传递 :用
Marshal.StructureToPtr把结构体写入非托管内存,DLL直接修改这块内存。
七、算法优化与改进方向
7.1 当前算法的局限
- 固定步长:ROI不重叠扫描,可能漏掉跨越格子边界的平坦区域。
- 只返回第一个:无法选择最优区域(如最平坦、最居中)。
- 无积分图优化:均值计算重复遍历,可用积分图(Integral Image)优化到O(1)。
7.2 改进建议
- 积分图:预计算积分图,任意ROI的均值可在O(1)时间得到,大幅提升性能。
- 重叠扫描:允许ROI部分重叠,提高定位精度。
- 多候选排序:收集所有候选区域,按平坦度或位置排序,选择最优。
八、总结
本文从第一性原理出发,讲解了深度图ROI扫描与打标区域定位算法:
- 平坦区域 = 深度值接近一致的区域,用极差衡量。
- 两个阈值:均值阈值过滤无效区域,极差阈值判断平坦度。
- 扫描策略:固定大小ROI不重叠遍历整张图。
- DLL封装:通过全局单例和导出函数供C#调用。
这个算法虽然简单,但非常实用,体现了"用最少的计算解决实际问题"的工程智慧。下一篇文章将深入讲解C#调用C++ DLL的P/Invoke互操作技术。