做工业检测的人对“平整度”三个字应该都不陌生。我之前接了一个项目,要给一批金属面板做平面度初筛,预算有限、又不能上激光轮廓仪,最后是拿一台普通工业相机加OpenCVSharp的角点检测把这活儿干的。当时最头疼的是网上资料大多在讲相机标定、OCR或者人脸检测,很少有专门讲“用角点检测去算平整度”的完整参考,代码也都是Python的,想迁到C#还得自己改半天。这篇文章把我调试这套方案时的完整思路、参考代码和踩过的坑整理出来,给正准备做类似事情的朋友一个能直接抄作业的底子。
先说清楚这套东西适合谁:正在搞视觉定位、平面度检测、产线初筛,或者想在C#里快速集成计算机视觉功能的人。不需要有多深的图像处理底子,只要会用C#、会接相机,跟着思路走基本能跑通。文章里的代码以OpenCvSharp 4.x为主,版本差异我会单独说明。
1. 从需求到方案:为什么用角点检测测平整度
很多人一听到“测平整度”,第一反应是上激光位移传感器、千分表或者离线的三坐标测量仪。这些方案精度确实高,但有个共性毛病:贵、慢、对环境要求高,而且很多只适合抽检。产线如果要做全检,或者只是做来料快速筛选,光学方案往往更划算。用普通工业相机加角点检测,本质上是把“平面是否弯曲”这个几何问题,转化成“图像中特征点位置是否偏离理想网格”的数学问题。
1.1 角点检测能反映平面形变的原理
一块平面如果绝对平整,你在正上方垂直拍一张贴有规则网格的图,网格交点(也就是角点)应该落在理想的均匀格点上。一旦平面有了翘曲,哪怕只是局部的微小弯曲,网格交点相对理想位置就会产生偏移。角点检测要做的,就是把每个交点的像素坐标精确抠出来,再拿这些坐标和理论坐标做对比,偏差越大,说明该位置的平面变形越大。
这里要拆开想一下:相机是个透视投影系统,平面的弯曲会同时带来两个可见效果。第一,角点之间的间距不再均匀;第二,原本应该是直线的网格线会出现弯曲。所以只要角点定位足够准,就能根据整套网格交点坐标的分布情况,反推出平面的平整度。这也是这个方案最核心的价值——用二维图像信息,去间接度量三维平面的形变。
当然,这种方案测的不是绝对Z向高度差,而是“平面度残差”,也就是角点相对理想网格的投影偏差。对于产线初筛和相对比较是完全够用的,后面我会讲清楚怎么把它换算成毫米量级的结果。
1.2 这个方案能解决什么,不能解决什么
能解决的是:刚性面板的翘曲筛查、PCB板的弯曲度初判、屏幕贴合平面的质量稳定性监控,以及任何“表面可以附着或打印规则图案”的平面测量场景。只要是平面变形会带动特征点位置偏移的情况,这套思路都成立。
不能解决的是:完全没有纹理和图案的镜面、透明材质、大曲率的柔性材料,以及需要绝对毫米级Z向高度差数据的场景。镜面没法提取稳定角点,柔性材料本身会卷曲抖动,透视关系混乱,硬做只会得到一堆误差很大的数据。遇到这种情况,就该考虑双目视觉、结构光或者激光方案了。这个话放在前面,免得有朋友照着代码跑完发现测不了,回头骂我。
2. 技术选型:OpenCVSharp在视觉检测中的定位
选OpenCVSharp,不是因为它比别的方案强多少,而是因为它在C#生态里是相对最顺手的计算机视觉库。做工业上位机软件、产线检测工具,很多团队的技术栈就是C#加WinForms或WPF,这时候引入OpenCVSharp最自然,不用像调用Python那边还要起一个独立服务或者搬数据。
2.1 为什么选OpenCVSharp而不是Python
Python版本的OpenCV功能当然更全、社区资源更多,但在实测项目里有个现实问题:产线检测程序往往要和其他设备通信、对接PLC和数据库,这些在C#里做是舒适区。如果你用Python,要么写混合进程,要么额外起接口服务,平白多了不少故障点。
OpenCVSharp的优势在于API设计和OpenCV C++原版几乎一一对应,同时所有Mat资源都放在IDisposable体系里,用using或者手动Dispose管理内存,写上位机程序时不会有Python那种全局解释器锁的别扭感。对工业检测这种需要长时间持续跑的场景,内存稳定性和异常可控性都很重要。
我并不是说Python不行。如果是做纯离线算法验证,Python确实快。但从“快速集成到现有测控系统”这个角度来说,OpenCVSharp至少能省掉一个跨语言通信的环节。
2.2 OpenCVSharp常用检测算子梳理
做角点检测,OpenCV里最常用的无非三套:Harris角点检测、Shi-Tomasi角点检测(对应GoodFeaturesToTrack接口)和棋盘格专用检测FindChessboardCorners。它们的区别和适用场景我直接列了个表:
| 检测方案 | 典型接口 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| Harris | Cv2.CornerHarris | 通用角点、纹理丰富区域 | 全图扫描角点响应 | 没有排序和数量控制,需要自己筛选 |
| Shi-Tomasi | Cv2.GoodFeaturesToTrack | 需要指定数量的最强角点 | 返回强度排名靠前的点,使用简单 | 对无纹理、纯色区域无力 |
| 棋盘格检测 | Cv2.FindChessboardCorners | 规则格子图案定位 | 自动排序、坐标规律性强,自带亚像素优化接口 | 对残缺图案敏感,必须要完整格点 |
我这次测量的目标平面是金属面板,没办法直接在上面画棋盘格,所以实际做法是做了两块辅助工装:一块是贴在平面上的标准棋盘格标定板,另一块是棋盘格图案的转印贴纸。测平整度的时候把带棋盘格的贴纸贴到待测面上,拍完再撕下来,这样检测就能稳定走棋盘格路线。
如果你面对的是已经有规则孔位、螺丝孔或纹路的工件,也可以直接用Shi-Tomasi那套,不一定要棋盘格。但前提是特征点足够多、分布足够均匀,否则拟合出来的理想网格会非常歪。
3. 核心参考代码:从拍摄到平整度指标
写代码之前先把整体流程理一遍:获取图像、灰度化、角点检测、亚像素细化、构造理论坐标、算单应矩阵、把检测角点投影到理论平面上、算每个点的偏差、输出RMS和最大偏差。下面把每个环节的代码和为什么这么写的思路一并说清楚。
3.1 项目结构与前置准备
先建一个最简单的控制台项目,引用OpenCvSharp4和OpenCvSharp4.runtime.win这两个NuGet包。基本结构就两个文件:主程序做流程调度,算子类做检测计算。我不建议把代码全塞在Main里,因为工业相机一般有回调线程,算法部分和UI部分至少要分层。
using OpenCvSharp; public class FlatnessDetector { private readonly Size patternSize; private readonly float squareMM; private Point2f[] objectPoints; public FlatnessDetector(Size patternSize, float squareMM) { this.patternSize = patternSize; this.squareMM = squareMM; BuildObjectPoints(); } private void BuildObjectPoints() { objectPoints = new Point2f[patternSize.Height * patternSize.Width]; int idx = 0; for (int r = 0; r < patternSize.Height; r++) { for (int c = 0; c < patternSize.Width; c++) { objectPoints[idx++] = new Point2f(c * squareMM, r * squareMM); } } } }这里的patternSize注意是内角点数,不是棋盘格的总格子数。比如一张9x7的黑白棋盘格,内角点是8x6,对应代码里的new Size(8, 6)。squareMM是单个格子的物理边长,单位毫米,后面算偏差时会直接用到。
3.2 棋盘格角点检测与亚像素定位
核心检测逻辑如下:
public FlatnessResult DetectFromImage(Mat image) { Mat gray = image.CvtColor(ColorConversionCodes.BGR2GRAY); bool found = Cv2.FindChessboardCorners( gray, patternSize, out Point2f[] corners, ChessboardFlags.AdaptiveThresh | ChessboardFlags.NormalizeImage); if (!found) { throw new InvalidOperationException("未找到完整棋盘格,请调整拍摄角度或补光"); } Cv2.CornerSubPix( gray, corners, new Size(5, 5), new Size(-1, -1), new TermCriteria(TermCriteriaTypes.Eps | TermCriteriaTypes.Count, 30, 0.001)); }FindChessboardCorners返回的是按行排列的内角点坐标,顺序是从左上角开始,先从左往右,再换下一行。这个顺序和我在BuildObjectPoints里构造理论点的顺序完全一致,后面做单应变换时才不会张冠李戴。
CornerSubPix是亚像素细化的关键。棋盘格检测初筛得到的角点虽然基本位置正确,但精度在整数像素级别。CornerSubPix会在每个角点周围的小窗口里用梯度信息重新拟合角点位置,把坐标精度推到亚像素。这一步对平整度测量影响很大,像素级偏差通常就有零点几毫米了,不做细化根本没法用。
3.3 单应校正与残差计算
拿到图像角点和理论角点之后,下一步就是构造从图像平面到棋盘物理平面的单应矩阵。这一步的物理含义,是把相机拍到的透视图矫正成正视图,然后再对比偏差。
Point2f[] warped = Cv2.PerspectiveTransform(corners, H); List<double> errors = new List<double>(); double sumSquared = 0; double maxError = 0; for (int i = 0; i < warped.Length; i++) { double dx = warped[i].X - objectPoints[i].X; double dy = warped[i].Y - objectPoints[i].Y; double dist = Math.Sqrt(dx * dx + dy * dy); errors.Add(dist); sumSquared += dist * dist; maxError = Math.Max(maxError, dist); }这里用的是Cv2.FindHomography(srcPoints, dstPoints, HomographyMethods.Ransac, 3.0, out Mat mask),参数里面的3.0是RANSAC的投影误差阈值,单位像素。如果拍摄环境比较差,阈值适当放宽到5或者8;如果环境稳定,收缩到2以内可以过滤掉一部分误检点。
单应矩阵算出来以后,用PerspectiveTransform把检测到的角点从图像坐标投影到“理想平面坐标”。如果这个平面是完美的,投影结果和objectPoints应当重合;如果局部翘曲,就会出现大小不一的偏差向量。每个偏差向量的长度就是该点的平面度残差,单位直接是毫米(因为objectPoints的坐标本来就是按毫米构造的)。
3.4 平整度指标计算
整理一下输出指标:
double rms = Math.Sqrt(sumSquared / errors.Count); double mean = errors.Average();最后返回:
public class FlatnessResult { public bool Success { get; set; } public double RmsErrorMM { get; set; } public double MaxErrorMM { get; set; } public double MeanErrorMM { get; set; } public Point2f[] Corners { get; set; } }三个指标怎么用:RMS偏差能反映整体平整度波动,适合用来监控产线稳定性;最大偏差能快速定位哪个位置翘得最厉害,适合做不良品分离;平均偏差更多是参考值,因为整体平移和偏转也会被算进去。实际操作中我更看重RMS和最大值这两个数。产品标准如果要求平面度在0.1mm以内,换算到棋盘格尺寸后,我会把RMS阈值定在0.05mm左右,留一点余量给检测噪声和环境误差。
4. 实操关键点与参数调优
代码本身并不复杂,真正让效果拉开差距的是现场实操。同样的代码,有人测出来稳定到±0.02mm,有人测出来数据忽大忽小,问题几乎都出在拍摄条件和参数选择上。
4.1 相机安装和拍摄环境要求
相机安装时最核心的一点:镜头光轴尽量垂直于被测平面。这里说的“尽量”不是嘴上说说,我实测过,光轴偏5度,RMS误差就能差出0.03到0.05mm左右。因为单应矩阵本质上假设平面是理想平面,透视关系是全图一致的,一旦光轴倾斜,平面边缘的投影偏差会被放大,算出来的平整度数值会当成“翘曲”被误报。
实际现场很难做到完全垂直,所以一个重要技巧是:每次换产型或移动相机后,都要拿一块已知高平整度的标准平板(比如大理石板或光学平板)贴棋盘格跑一遍基线。基线测出来如果RMS超过0.05mm,先优化安装,而不是直接调算法阈值。基线数据还能用来做后续测量的系统误差扣除,等于给整个方案做了一个在线校准。
灯光这一块,优先选漫射光,避免强点光源在棋盘格表面产生局部反光。反光会破坏黑白格的对比度,直接影响FindChessboardCorners的识别。我以前吃过闷亏,用了一盏便宜的环形强光灯,画面中心亮边界暗,角点检测在边缘区域经常丢格,换了漫射条形光源之后问题直接消失。
4.2 关键参数调节方法
参数表我按“先到后到”的顺序说一下:
| 参数 | 位置 | 作用 | 实测推荐值 |
|---|---|---|---|
| 角点细化窗口 | CornerSubPix的winSize | 控制亚像素拟合邻域大小 | 5x5或7x7 |
| RANSAC阈值 | FindHomography的ransacReprojThreshold | 决定哪些点被当作离群点过滤 | 2到5像素 |
| 棋盘格标志位 | FindChessboardCorners的flags | 控制检测预处理策略 | 默认Flags组合即可 |
| 图像分辨率 | 相机分辨率 | 角点定位精度上限 | 尽量保留原始分辨率 |
| 曝光时间 | 相机参数 | 避免过曝或欠曝 | 让黑白格对比最大化 |
网格尺寸的选择也很重要:squareMM越接近实际需要的测量精度,单应矩阵的尺度越准确。比如被测面尺寸是100mm,你拿一个10mm格宽的棋盘格,格子数很多,角点数多,平均误差会被摊薄,但单格本身太小,角点检测相对误差会变大。反过来,如果格子太大,角点太少,拟合平面会偏粗。一般情况下,让棋盘格图案覆盖被测面的60%到80%,单个格子边长在5到15mm之间,效果最稳定。
4.3 提升重复精度的几个细节
多帧平均是性价比最高的优化手段。相机的传感器噪声、环境微振动都会让角点定位出现小幅抖动,连续拍5到10帧图像,分别计算每个角点坐标后再取平均,能明显降低随机误差。这种办法不需要动硬件,代码也就多写十几行。
另一个隐藏细节是棋盘格贴纸本身的平整度。打印在普通相纸上的图案会因为纸张受潮或者胶水厚度不均产生局部凸起,这会直接混进测量结果。我后来用的是PET材质的防水棋盘格贴纸,贴的时候用刮板从中间向两边赶气泡,贴完静置几分钟再测。很多人死活测不准,回头查半天,其实问题就出在这张贴纸上。
5. 常见问题排查与避坑实录
这套方案跑下来,最容易被忽略的问题基本都在检测稳定性和误差解释上。我把自己实战中踩过的问题整理成一张排查表,方便遇到问题的时候对照着查。
5.1 角点检测不稳定,时有时无
现象:同一个工件,上一帧检测成功,下一帧直接报“未找到完整棋盘格”,或者检测到的角点数量不对。
排查思路:先看图像,保存几帧实时图,检查是否有局部反光、污渍遮住了某个格子、工件边缘出界。再用直方图看一眼对比度,黑白格灰度差值低于80左右就很容易检测失败。
解决办法:优先加漫射光、调整曝光,让黑白对比度足够大;其次检查棋盘格区域是否完整在视场内,至少留出一圈安全边距;最后才考虑调整检测标志位,比如把AdaptiveThresh | NormalizeImage换成自定义的预处理。
5.2 检测到角点但误差很大,数值不符合常理
现象:角点全找对了,程序也跑通了,但RMS算出来是0.5mm甚至1mm,明显不符合常理。
排查思路:先确认patternSize和squareMM设的是不是和内角点、实际格宽对应。很多人把patternSize填成了总格子数,或者把格子边长填错了,导致理论坐标整体错位,误差当然大。另外看看单应矩阵H有没有被透视畸变扭曲,把H打印出来观察最后一行数值,如果和正常值差异过大,说明输入的对应点有大量错误匹配。
解决办法:先从代码层面检查参数,再用标准平整板做基线测试,确认系统误差在0.05mm以下再测量样品。如果基线都不对,别急着测工件,先把相机姿态和贴纸质量搞定。
5.3 测量结果随拍摄角度变化明显
现象:同一个平面,稍微换个角度重新拍,RMS结果相差很大。
定性问题:如果被测平面真的是刚性平面,理论上单应矩阵能够把任意视角下的图像矫正到同一个平面坐标系。结果随角度变化说明两件事,一是原平面确实有翘曲,二是单应矩阵无法完全补偿由翘曲引起的局部透视变化。严格来说,这不全是误差,里面包含了真实形变信息。
但如果是刚性标准平板上也出现这种现象,那就是相机光轴偏了,或者棋盘的投影面积太小导致单应矩阵估计不稳。这时候需要重新调整相机姿态,或者换更大的棋盘格贴纸。
5.4 调参经验表
| 问题特征 | 优先排查项 | 次要排查项 | 备选手段 |
|---|---|---|---|
| 角点丢失 | 反光、污渍、边缘出界 | 对比度过低、图案破损 | 换光源、加滤波 |
| 误差偏大 | patternSize、squareMM | 贴纸平整度、相机光轴 | 基线校准、多帧平均 |
| 重复性差 | 曝光、振动、环境光变化 | 贴纸位置变化、工件固定方式 | 增加拍摄次数取均值 |
| 检测时间过长 | 图像分辨率过高 | 窗口参数过大 | 裁剪ROI、降采样 |
6. 专业场景落地建议
代码跑通只是第一步,真要在产线上稳定运行,至少还得考虑两个层面的问题。
6.1 从算法Demo到产线工具的转换
算法程序直接搬到产线会吃亏,因为现场没有人盯着控制台看输出。我建议把检测逻辑封装成一个独立类库,对外暴露一个方法:传入图像,返回平整度结果和判定OK/NG。UI层只负责显示结果,和相机及其他设备通信单独抽成一个服务,这样算法替换、参数调整都不会把主流程改坏。
判定逻辑最好做成可配置的。比如产品A要求RMS小于0.05mm,产品B要求小于0.08mm,阈值放在配置文件或者数据库中,而不是硬编码在代码里。产线换型时操作工只要在中控屏上选一下产品型号就行。
6.2 数据记录与追溯
每次测量结果至少要记录:时间戳、产品批次、RMS、最大偏差、平均值、设备编号、温湿度(如果对精度敏感)。这些数据后期用来做SPC统计分析,能直接看到产线的平整度趋势。如果某一天的RMS均值突然往上漂,那就是设备或模具出了问题,可以提前预警,不用等不良品流到客户手里才被发现。
我在实际项目中加了一个简单的SQLite存储,每次检测自动插入一条记录,界面端只显示最近500条的趋势曲线。这个功能很轻量,但用处很大,后来排查一个疑似模具磨损的问题,就是靠趋势数据反推出来的。
一点个人体会
整套方案做下来最大的感触是:视觉检测的难点从来不只是算法本身,而是怎么让算法在真实环境里稳定执行。OpenCVSharp的角点检测代码量并不多,但要把平整度测到能用的精度,花在光线、贴纸、相机姿态和参数标定上的时间,比写代码多得多。如果你正准备上手,建议先拿一块已知平整度的标准板跑通基线和流程,再测真正的工件。这样无论后面数据长什么样,你都有一条清晰的参照系,不会对着错误数据瞎调。