
前阵子做圆柱销直径在线测量一个典型的工业级视觉测量需求公差正负0.02mm。起初我想得很简单OpenCV里现成的findContours加fitEllipse轮廓一捞拟合一下不就把圆拟合出来了吗实际上线之后上午能测下午光照一变边缘就开始跳尺寸忽大忽小。被现场工程师拉着调了一周最后把方案改成卡尺工具才把重复性稳定到0.003mm以内。这篇博客就把这套C实现完整写出来包含圆与直线拟合的完整流程和源码。1. 第1步一个真实项目案例搞清楚卡尺工具为什么比轮廓拟合稳1.1 现场需求与最初的轮廓拟合方案当时的需求很常规一条连接器产线上要给金属圆柱销做在线直径测量销子名义直径10mm公差正负0.02mm。相机用的是2048x1536的黑白工业相机视野大概30mm x 22mm换算下来一个像素大约对应0.0146mm。这个精度压力不小因为0.02mm的公差折算到像素上只有1.37个像素左右意味着如果边缘定位只能做到±1像素那测量结果可能直接超差。最开始我的方案很直白对图像做阈值分割findContours拿到轮廓再拟合最小外接圆或者fitEllipse。这套流程在实验室里跑demo确实很顺图像干净边缘锐利拟合出来的圆也很圆。但一上线就露馅了阈值分割对光照太敏感。上午车间阳光照进来同一张图的最佳阈值和下午完全不一样hardcode一个固定阈值根本撑不过一天。分割出来的轮廓经常带毛刺。销子表面有轻微加工纹路在分割图上会形成锯齿状边界fitEllipse的圆度指标波动很大。伪轮廓和遮挡没法处理。销子旁边有夹具边缘、反光点都会混进轮廓点集里拟合时这些离群点会把圆心和半径拉偏。最头疼的是第3点。轮廓拟合是全量盲目拟合算法并不知道哪些轮廓点属于被测圆、哪些属于干扰。我试图加滤波、加距离阈值剔除离群点但每次现场工况一变滤波参数又要重新调。这种方案本质上是在跟光照和场景变化打游击战不可能稳定。1.2 卡尺工具解决的三个核心问题后来把方案换成卡尺工具逻辑完全变了。卡尺工具不去做全局阈值分割而是直接在原图上沿着一条指定的搜索线扫描灰度变化找到边缘位置。它天然绕开了我前面踩的几个坑不依赖全局阈值。卡尺工具关心的是局部灰度梯度而不是整幅图的绝对灰度。光照整体变亮或变暗只要边缘处的相对灰度差还在梯度信号就还在。可以指定搜索方向和边缘极性。比如测量圆柱销时我知道边缘一定是从亮到暗的过渡那我就可以只采这个极性的边缘点把背景里的暗条纹、亮反光直接过滤掉。定位精度是亚像素级。对梯度序列做抛物线插值边缘位置可以精确到0.1像素甚至更高这在像素当量0.0146mm/pix的情况下直接决定了测量能否压住0.02mm的公差。我用一个表格说明两种方案的实际差距这是同一批静止图像上连续测30次的结果方案直径均值mm标准差mm最大偏差mm轮廓拟合fitEllipse10.00420.01170.038卡尺工具圆拟合10.00190.00230.006对比很直观。轮廓拟合的最大偏差接近0.04mm公差早就超了卡尺工具做出来的标准差只有0.0023mm稳定性和精度完全在一个量级。这就是工业级的关键不是某一帧测得多准而是每一帧都稳定地准。2. 第2步卡尺测量的底层原理——一维信号、梯度与亚像素定位2.1 为什么只沿一条搜索线采样卡尺工具这个词听起来神秘本质上做的事情非常简单在二维图像里取一条一维的扫描线然后在这条扫描线上找边缘。实际操作中我们需要在图像上定义一条搜索线起点和终点各是一个点搜索方向从起点指向终点。然后沿线等间距采样灰度值得到一条一维灰度信号曲线。为什么这么做因为边缘在图像中通常呈现为某个方向上灰度突然变化如果我只关心这个变化那么沿着垂直于边缘的方向采样把所有信息压缩到一维问题就简化成了一个一维信号处理问题。但直接取搜索线经过的那一排像素抗噪能力很差。一个像素被灰尘遮住或者传感器有坏点整条信号就出现一个假尖峰。所以标准的卡尺工具会做法线方向投影在搜索线上的每一个采样点取垂直于搜索线方向的一小段邻域对邻域内的像素灰度做平均用平均值作为这一位置的信号值。这个过程可以理解为沿边缘方向做了一次平滑。被测边缘在图像里通常有一定的长度沿边缘方向平均并不会模糊边缘反而能把随机噪声、孤立的颗粒干扰压下去。我的经验是投影半径设3像素最常用太小噪声压不住太大会把相邻的弱边缘也“糊”进来导致边缘位置偏移。2.2 抛物线插值从像素边缘到亚像素坐标采样得到一维信号后下一步就是找边缘位置。对一维信号做中心差分可以得到梯度响应曲线grad[i] (profile[i1] - profile[i-1]) / 2梯度值大的位置就是边缘候选点。但这里有个很现实的问题图像像素是离散的梯度峰值落在哪个像素网格上只能给出整数像素坐标。如果直接拿这个整数坐标去做测量精度顶多到1个像素。在像素当量0.0146mm/pix的场景下1个像素的量化误差就是0.0146mm而公差只有0.02mm算上其他误差源根本压不住。所以必须做亚像素插值。经典做法是取梯度峰值点以及左右相邻两个点用一条抛物线去拟合这三个点抛物线的顶点位置就是更精确的边缘位置。插值公式是这样的。设峰值索引为i梯度值为g[i]左右相邻点梯度为g[i-1]和g[i1]则亚像素偏移量delta为delta 0.5 * (g[i-1] - g[i1]) / (g[i-1] - 2*g[i] g[i1])注意这里的delta通常限制在[-0.5, 0.5]范围内。如果算出来超过这个范围说明峰值检索引擎找错了点直接丢弃比硬要更稳。实际边缘坐标就是edgePos i delta这个插值公式的原理并不复杂抛物线顶点的x坐标在三点二次插值下正好可以用相邻差分的比值表示。它不是物理上的真实边缘模型但在工业视觉领域被验证了无数次重复性非常好。加上法线方向投影平均已经在空间上做了平滑梯度曲线通常很单峰抛物线插值的结果非常稳定。2.3 极性、阈值与多边缘选择策略实际工业场景里沿着一条搜索线扫过去往往不止一条边缘。圆柱销有左右两个边界表面可能还有一道刻痕背景里也可能有别的物体边缘。卡尺工具必须提供两套筛选机制极性过滤和边缘选择策略。极性指的是边缘过渡的方向。沿搜索线方向看如果灰度从亮变暗这是一个亮到暗边缘从暗变亮则相反。在代码里我用grad值的正负号来表示梯度大于0表示沿搜索线方向灰度在增加也就是暗到亮梯度小于0表示亮到暗。工业测量里这个信息非常有用。比如测圆形销子时从背景亮区进入暗色销子边缘极性是固定的只采这个极性就能把背景里的杂乱边缘全部排除。边缘选择策略解决的是一条线上出现多个边缘取哪个的问题。工业卡尺工具通常提供三种模式最强边缘取梯度幅值最大的那个峰适合目标边缘清晰、干扰边缘较弱的情况。第一个边缘沿搜索方向取遇到的第一个合格边缘适合测量物体最外侧轮廓。最后一个边缘取最后出现的合格边缘适合测内孔之类需要穿过外层结构的场景。每个模式都有对应场景不能指望一个设置走天下。我的建议是如果一张图里边缘结构比较复杂优先画几条搜索线在图上可视化肉眼确认边缘顺序再决定用哪种选择策略。盲目套最强边缘有时会被反光造成的强梯度带偏。3. 第3步C实现一个可复用的CaliperTool类3.1 类设计参数优先接口简洁写工业代码和写算法demo不一样最重要的是可维护性。测量项目的参数经常要在现场微调如果每次调参都要改代码重新编译效率太低。所以我把CaliperTool设计成参数优先的结构所有测量行为都通过Params结构体控制调用方只需给定参数就能创建一个工具实例。class CaliperTool { public: struct Params { cv::Point2f startPt; // 搜索线起点 cv::Point2f endPt; // 搜索线终点 int sampleCount 80; // 采样点数 int projectRadius 3; // 法线方向投影半径像素 float gradThreshold 25.0f; // 梯度阈值 int maxEdgeNum 1; // 最多输出几个边缘 int polarity 0; // 0任意, 1亮到暗, 2暗到亮 }; struct EdgePoint { cv::Point2f pt; // 亚像素边缘坐标 float score 0.0f; // 梯度强度 float direction 0.0f; // 方向角弧度 int polar 0; // 实际极性 }; explicit CaliperTool(const Params params) : params_(params) {} void setParams(const Params params) { params_ params; } bool measure(const cv::Mat gray, std::vectorEdgePoint edges); private: Params params_; };每个EdgePoint输出的是一个亚像素坐标点同时附带梯度强度和极性信息。这样上层做直线拟合或圆拟合时可以直接拿到干净的点集。3.2 完整实现采样、梯度计算与峰值搜索核心的measure函数我拆成三个步骤采样一维信号、计算梯度、峰值搜索加亚像素插值。代码逻辑不复杂但每一步都有细节要注意。bool CaliperTool::measure(const cv::Mat gray, std::vectorEdgePoint edges) { edges.clear(); if (gray.empty()) return false; // 搜索线方向向量归一化 cv::Point2f dir params_.endPt - params_.startPt; float len cv::norm(dir); if (len 1e-6f) return false; dir * 1.0f / len; // 法线方向 cv::Point2f normal(-dir.y, dir.x); const int n params_.sampleCount; std::vectorfloat profile(n, 0.0f); std::vectorfloat weight(n, 0.0f); // 步骤1沿搜索线逐点采样法线方向投影平均 for (int i 0; i n; i) { cv::Point2f pt params_.startPt dir * (float)i; for (int r -params_.projectRadius; r params_.projectRadius; r) { cv::Point2f sp pt normal * (float)r; int x cvRound(sp.x); int y cvRound(sp.y); if (x 0 x gray.cols y 0 y gray.rows) { profile[i] (float)gray.atuchar(y, x); weight[i] 1.0f; } } if (weight[i] 0) profile[i] / weight[i]; } // 步骤2中心差分计算梯度同时记录极性 std::vectorfloat grad(n, 0.0f); std::vectorint gradPolar(n, 0); for (int i 1; i n - 1; i) { float g (profile[i 1] - profile[i - 1]) * 0.5f; grad[i] std::abs(g); gradPolar[i] (g 0) ? 1 : ((g 0) ? -1 : 0); } // 步骤3峰值搜索 抛物线亚像素插值 for (int i 1; i n - 1; i) { if (grad[i] params_.gradThreshold) continue; if (grad[i] grad[i - 1] || grad[i] grad[i 1]) continue; if (params_.polarity 1 gradPolar[i] ! -1) continue; // 亮到暗 if (params_.polarity 2 gradPolar[i] ! 1) continue; // 暗到亮 float denom grad[i - 1] - 2.0f * grad[i] grad[i 1]; if (std::abs(denom) 1e-9f) continue; float delta 0.5f * (grad[i - 1] - grad[i 1]) / denom; if (delta -0.5f) delta -0.5f; if (delta 0.5f) delta 0.5f; float pos (float)i delta; cv::Point2f edgePt params_.startPt dir * pos; EdgePoint ep; ep.pt edgePt; ep.score grad[i]; ep.direction std::atan2(normal.y, normal.x); ep.polar gradPolar[i]; edges.push_back(ep); if ((int)edges.size() params_.maxEdgeNum) break; } return !edges.empty(); }这个实现的几个关键细节我说一下采样循环里为什么在法线方向取整数偏移而不是做亚像素插值因为投影平均本身是平滑操作取最近的整数像素做平均和用双线性插值取浮点坐标的结果差异非常小但计算量少了很多。在追求实时性的产线场景里省掉无谓的双线性插值是值得的。峰值搜索时为什么用大于等于左右而不是严格大于如果两个相邻像素梯度值完全相同只有等号判断会同时触发两个峰导致重复输出。加上一个方向限制后更稳妥。极性匹配的符号对应关系在注释里写得很清楚沿搜索线方向灰度值是下降的亮变暗对应极性-1灰度值上升暗变亮对应极性1。3.3 使用示例与调试可视化有了这个类实际调用的代码非常简单int main() { cv::Mat gray cv::imread(pin.jpg, cv::IMREAD_GRAYSCALE); if (gray.empty()) return -1; CaliperTool::Params params; params.startPt cv::Point2f(100, 200); // 从销子左侧往右侧扫 params.endPt cv::Point2f(500, 200); params.sampleCount 120; // 采样点越多边缘越密 params.projectRadius 3; // 3像素法向平滑 params.gradThreshold 20.0f; // 阈值先设低看效果再往上调 params.maxEdgeNum 2; // 左边缘和右边缘各一个 params.polarity 0; // 先不限制极性 CaliperTool caliper(params); std::vectorCaliperTool::EdgePoint edges; if (caliper.measure(gray, edges)) { for (const auto e : edges) { cv::circle(gray, e.pt, 2, cv::Scalar(255), -1); std::cout edge at ( e.pt.x , e.pt.y ) score e.score polar e.polar std::endl; } } // 把搜索线画出来一眼就能看出采样区域是否合理 cv::line(gray, params.startPt, params.endPt, cv::Scalar(128), 1); cv::imshow(result, gray); cv::waitKey(0); return 0; }我强烈建议在调试阶段把搜索线和边缘点可视化。很多问题一眼就能看出来搜索线方向反了极性全部反号搜索线没有垂直跨过边缘梯度响应模糊投影半径太大边缘位置被相邻物体影响。先看图再改参数比盲调效率高得多。4. 第4步把边缘点聚成直线——直线拟合的工程实现4.1 直线拟合为什么不能简单用ykxb拿到卡尺工具输出的边缘点集后如果被测对象是直线边下一步就是做直线拟合。很多初学者第一反应是拟合ykxb这个思路在数学上没错但工程上有个致命的坑当直线接近垂直时k趋近无穷大数值稳定性完全崩掉。实际测量中工件边缘可能以任意角度出现在视野里。比如测一个方形工件的侧边边缘角度接近90度是常态ykxb这个模型就不适用了。更稳健的做法是用ax by c 0的隐式方程或者用线上一点 方向向量的表示方式。有两种工程上常用的实现路径PCA主方向法对点集求质心构建协方差矩阵最大特征值对应的特征向量就是直线方向。这种方法数学简洁没有迭代速度极快而且对点集沿某个方向拉长的描述非常本质。OpenCV的fitLine底层是M估计器Huber或者Tukey自带离群点鲁棒性。实际测量中边缘点偶尔会被毛刺、灰尘带偏fitLine能自动降低这些离群点的影响。我的建议是如果边缘点已经经过了卡尺工具的极性过滤点质量通常不错用PCA就够了。但如果你想更稳一点直接调fitLine一行代码省心很多。4.2 拟合代码与残差评估PCA方式实现如下void fitLinePCA(const std::vectorcv::Point2f pts, cv::Point2f pointOnLine, cv::Point2f direction) { cv::Scalar mu cv::mean(pts); pointOnLine cv::Point2f((float)mu[0], (float)mu[1]); cv::Mat cov(2, 2, CV_64F, cv::Scalar(0)); for (const auto p : pts) { double dx p.x - pointOnLine.x; double dy p.y - pointOnLine.y; cov.atdouble(0, 0) dx * dx; cov.atdouble(0, 1) dx * dy; cov.atdouble(1, 0) dy * dx; cov.atdouble(1, 1) dy * dy; } cv::Mat eigenvalues, eigenvectors; cv::eigen(cov, eigenvalues, eigenvectors); direction cv::Point2f((float)eigenvectors.atdouble(0, 0), (float)eigenvectors.atdouble(0, 1)); }如果要走fitLine代码更短cv::Vec4f fitLineRobust(const std::vectorcv::Point2f pts) { cv::Vec4f line; if (pts.size() 2) return line; cv::fitLine(pts, line, cv::DIST_HUBER, 0, 0.01, 0.01); return line; }fitLine返回的Vec4f前两个元素是方向向量后两个是直线上的一个点。拟合完直线不是直接拿去用就行了一定要评估拟合质量。最直观的指标是残差RMS计算每个边缘点到拟合直线的垂直距离求均方根。垂直距离用向量叉积算double lineResidualRMS(const std::vectorcv::Point2f pts, const cv::Vec4f line) { double sum 0; for (const auto p : pts) { cv::Point2f dir(line[0], line[1]); cv::Point2f delta p - cv::Point2f(line[2], line[3]); double cross delta.x * dir.y - delta.y * dir.x; sum cross * cross; } return std::sqrt(sum / pts.size()); }残差RMS单位是像素。如果残差在0.1像素以内说明边缘提取得很干净如果超过0.5像素我基本会判断是边缘点混入了离群值或者被测边缘本身不是一条直线。这时候强行用这个拟合结果去算角度结果一定是飘的。直线拟合最常见的应用是测平面工件的直线度和边缘角度。比如芯片引脚共面性检测需要在引脚上投多条搜索线每条搜索线输出一个边缘点再把所有边缘点拟合成一条直线看引脚是否共面。这套流程配合卡尺工具的极性过滤稳定性非常高。5. 第5步把边缘点聚成圆——圆拟合的实现与小弧段陷阱5.1 Kasa代数拟合的C实现圆拟合比直线拟合麻烦一些。一个圆有三个参数圆心坐标x、y和半径r最少需要三个点才能确定。实际测量中圆上往往分布着几十个边缘点我们需要找到一个最合适的圆让所有点到圆周的距离整体最小。工业视觉里最常用的圆拟合方法是Kasa代数拟合。思路是把圆的方程改写成线性形式x^2 y^2 a*x b*y c 0其中圆心为(-a/2, -b/2)半径为sqrt((a/2)^2 (b/2)^2 - c)。这样问题就变成了一个最小二乘线性方程组用SVD解一下就能得到a、b、c。这个方法速度快代码短在圆周覆盖完整、噪声适中的情况下精度很高。我在此基础上加了一个迭代加权的小改进第一次解完得到圆心和半径后计算每个点到圆周的距离作为残差然后用残差的倒数作为下一次迭代的权重。这样离群点的影响会被自动压低相当于内置了一个简单的鲁棒机制。bool fitCircle(const std::vectorcv::Point2f pts, cv::Point2f center, double radius, int iterations 3) { int n (int)pts.size(); if (n 3) return false; std::vectordouble x(n), y(n); for (int i 0; i n; i) { x[i] pts[i].x; y[i] pts[i].y; } cv::Mat A(n, 3, CV_64F); cv::Mat b(n, 1, CV_64F); cv::Mat w cv::Mat::ones(n, 1, CV_64F); for (int it 0; it iterations; it) { for (int i 0; i n; i) { double wi w.atdouble(i, 0); A.atdouble(i, 0) x[i] * wi; A.atdouble(i, 1) y[i] * wi; A.atdouble(i, 2) wi; b.atdouble(i, 0) (x[i] * x[i] y[i] * y[i]) * wi; } cv::Mat sol; if (!cv::solve(A, b, sol, cv::DECOMP_SVD)) return false; double a sol.atdouble(0, 0); double bb sol.atdouble(1, 0); double c sol.atdouble(2, 0); center.x (float)(-a / 2.0); center.y (float)(-bb / 2.0); double rr center.x * center.x center.y * center.y - c; if (rr 0) return false; radius std::sqrt(rr); if (it iterations - 1) break; // 更新权重残差越大的点权重越小 for (int i 0; i n; i) { double dist std::hypot(x[i] - center.x, y[i] - center.y); double res std::abs(dist - radius); w.atdouble(i, 0) 1.0 / std::max(0.1, res); } } return true; }第一次迭代时权重全是1解出来的就是标准Kasa拟合之后每轮迭代远离圆周的点权重会变小圆心和半径逐渐被干净点主导。实测下来这个改进对毛刺、反光点的鲁棒性提升非常明显。5.2 为什么小弧段拟合结果会飘圆拟合一个很容易翻车的场景是只有一小段圆弧可见。比如测一个大的法兰盘外圆视野只覆盖了其中30度的弧段这时候用卡尺工具在弧上取边缘点再去拟合圆结果往往非常不稳定——圆心沿圆弧法线方向稍微偏一点半径就大幅度变化。根本原因在于圆的三个参数在小弧段约束下存在严重的参数退化。大圆周上截取的一小段视觉上非常接近一条弧线甚至接近一条直线它可以被无数个不同半径的圆近似拟合。数学上表现为SVD求解时最小奇异值非常小方程接近奇异解对噪声极度敏感。我举个直观的例子同样加0.1像素的噪声在300度圆弧上拟合半径偏差可能只有0.2像素在30度圆弧上拟合半径偏差可能到5像素以上。这种精度波动在工业测量里是完全不能接受的。应对办法有几种尽量覆盖完整圆周。测整圆时在圆周上均匀布置4到8条卡尺搜索线保证边缘点分布覆盖整周360度这是最根本的解法。对圆心位置加先验约束。如果销子的机械定位做得好圆心大概落在某个已知小范围内可以把圆心坐标当成已知量只对半径做一维优化。这样即使弧段短半径也可以稳住。使用几何距离拟合。每个点到圆的真实垂直距离作为残差用高斯牛顿或Levenberg-Marquardt迭代优化。几何拟合在弧段不足时比Kasa稳定很多但代码量和计算量也相应增加。在实时性要求高的产线上我一般优先选择覆盖整圆而不是硬刚算法。总结一句话圆拟合的精度七分靠点云分布三分靠算法。边缘点覆盖越全面算法越轻松。6. 现场调优从能跑到稳定跑的调试顺序与避坑记录6.1 先调图像链再调算法参数卡尺工具虽然比轮廓拟合抗干扰能力强但前提是图像本身得过关。我在现场调试时有一个严格的顺序先确保图像稳定再调卡尺参数最后验证拟合结果。图像链排第一的是光源和光圈。卡尺工具基于灰度梯度如果边缘处过曝梯度值会被压缩边缘位置偏移如果欠曝梯度太弱阈值就不好设。最理想的图像是目标边缘处有清晰的灰度过渡且过渡区域占2到3个像素。然后是搜索线方向。卡尺搜索线要尽量垂直于被测边缘。如果搜索线和边缘的夹角小于30度梯度响应会明显变钝亚像素插值的精度也跟着下降。现场如果发现边缘点重复性差先检查搜索线方向别急着改阈值。调参顺序我的习惯是先把gradThreshold设低比如10让所有潜在边缘都浮出来。把maxEdgeNum设成5观察一条搜索线上到底有几条边缘。根据实际边缘的强度和位置逐步抬高阈值过滤掉弱干扰。确认极性设置正确把背景杂边缘排除。最后微调projectRadius平衡平滑效果和边缘保真度。这个顺序能让你在最短时间内理解当前图像长什么样而不是盲目套参数。6.2 像素当量标定与重复性验证卡尺工具输出的边缘点坐标和拟合结果是像素单位要换算成毫米必须做像素当量标定。最直接的方法是用一个已知直径的标准件在相同工作距离下测出像素直径然后用实际直径除以像素直径得到每像素对应的毫米数。但这里有个常见的坑只做中心视场标定然后全画面都用同一个当量。工业镜头的畸变会导致画面边缘处的实际当量和中心不同如果测量对象恰好偏离视野中心测量结果会带系统偏差。我的做法是先做棋盘格标定把畸变系数求出来用OpenCV的undistort或者remap做矫正矫正之后再做像素当量标定。对于精度要求在正负0.02mm的场景这一步不能省。标定完之后一定要做重复性验证。具体做法是固定工件不动连续采30张图跑同一套测量代码计算出30个直径测量值的标准差。这个标准差反映的是算法和图像噪声带来的随机波动。我给自己定的标准是最终测量公差的正负0.02mm重复性标准差至少要小于0.005mm也就是大约0.34像素才能给后面的其他误差源留出余量。6.3 多卡尺组合直径、厚度与同心度测量单个卡尺工具只能得到一个边缘点真正复杂的测量功能都是多个卡尺工具组合出来的。测直径在圆的左右两侧各放一条搜索线分别找到左右边缘点两个边缘点的距离就是直径。如果圆的朝向不确定可以放四组卡尺拟合圆后直接取半径乘2。测厚度在工件上下表面各放一条搜索线两条搜索线各找一个边缘纵向距离就是厚度。厚度测量特别需要注意极性设置上表面和下表面的边缘极性通常是相反的必须分别指定。测同心度对两个圆各放一组卡尺搜索线分别拟合出圆心两个圆心之间的距离就是同心度偏差。我现在的代码架构里CaliperTool是一个基础组件上层有FitLine、FitCircle、DistanceChecker这些测量单元每个测量单元都是一组参数实例。现场换产品时不需要改算法代码只需要在配置文件中重新定义搜索线位置、极性和阈值调试效率提高了很多。把卡尺工具做成一等公民组件是我在这个项目里最正确的决策。它不只是替代了findContours更让整个测量流程变得可解释、可调试、可复用。后面做直线度、圆度、同心度都是在拼积木。最后说一个我自己的小习惯每加一个测量项都会先让软件把搜索线和边缘点画出来截图存到调试报告里。这样过几个月回来调参不用满地找现场图片。卡尺工具这种东西越是基础越值得做成通用模块。把它放在测量函数库的第一层后续的视觉测量需求都受益。