一种模板加卡尺的工业定位设计方案
回顾一个传统视觉自定义控件(Halcon+C#)的年轻设计。
目录
1. 开篇
最近把工作以来做过的东西翻了一翻,这个传统视觉自定义控件(Halcon+C#)是挺久之前做的了。现在看来还是踩了一些坑的。不过其设计也能满足一些比较固定的视觉定位需求,所以简单复盘记录一下。
传统的定位这一块主要就是两点,模板匹配与卡尺。当然,根据环境的不同,依靠特殊环境实现的各类方法都有,先不讨论。
- 模板匹配快且稳,但它的精度由金字塔搜索决定,做"引导"够用,做"测量"不够;
- 固定位置边缘测量精度高(亚像素),但它假设目标不动——现实中传送带、夹具、装配公差都会让目标漂移;
正确定位的答案是组合拳:
模板匹配负责"找到在哪",卡尺测量负责"量得准"。
- 模板匹配允许大范围搜索(包括旋转缩放);
- 卡尺只在匹配点位的小邻域内做一维边缘投影,天然屏蔽区域外的一切干扰;
2. 总体架构:模板粗定位 + 卡尺精定位
2.1 核心流程
简单来说就是:匹配给出位姿 → 位姿变换卡尺 → 卡尺给出边缘 → 边缘拟合出几何。
2.2 运行期完整时序
2.3 代码结构(类图)
ICaliper是一个基类,当时设计时打算设计成接口。后来发现忘记改了一直沿用至今(真是罪过)。
3. 卡尺测量
3.1 设计
设计上没有过多工程化设计,就是基础的多态设计。然后给创建出口配置一个建造工厂。
3.2 参数的意义
Halcon手册对各类算子给出了详细的解释。
3.3 Halcon Metrology(本项目) 调用骨架
所有卡尺(线/圆/弧/十字)共享同一套 2D Metrology 骨架,全流程 6 步:
对应核心代码(线卡尺):
HOperatorSet.CreateMetrologyModel(out var handle);
HOperatorSet.SetMetrologyModelImageSize(handle, width, height);
HOperatorSet.AddMetrologyObjectLineMeasure(handle,
startRow, startCol, endRow, endCol, // 示教线段两端点
Param.CaliperWidth, Param.CaliperLength, // length1 / length2
Param.Sigma, Param.Threshold,
new HTuple(), new HTuple(), out var index);
HOperatorSet.SetMetrologyObjectParam(handle, index, "num_instances", 1);
HOperatorSet.SetMetrologyObjectParam(handle, index, "measure_transition", Param.Transition);
HOperatorSet.SetMetrologyObjectParam(handle, index, "measure_select", Param.Select);
HOperatorSet.SetMetrologyObjectParam(handle, index, "min_score", Param.MinScore);
HOperatorSet.ApplyMetrologyModel(image, handle); // ⑤ 执行
HOperatorSet.GetMetrologyObjectResult(handle, index, "all",
"result_type", "all_param", out var parameter); // ⑥ 拟合结果
HOperatorSet.GetMetrologyObjectMeasures(out var contours, handle,
"all", "all", out var rows, out var cols); // ⑥ 边缘点
不同类型的差异只在第 ③ 步换一个算子:
| 类型 | 添加算子 | 输入几何 | 输出 |
|---|---|---|---|
| 线 | AddMetrologyObjectLineMeasure |
两端点 | 端点 + 角度 |
| 圆 | AddMetrologyObjectCircleMeasure |
圆心 + 半径 | 圆心 + 半径 |
| 弧 | Circle 版 + start_phi/end_phi 泛型参数 |
圆心/半径/起止角 | 圆心/半径/起止点/实际角度 |
| 十字 | 同一模型中加 4 个 线对象 | 四条边线 | 4 条线 + 中心(见第 5 章) |
3.4 min_score 两段式降级:先严后松
现场对比度是波动的。一条边平时 score=0.8,来了一批哑光件掉到 0.45——固定阈值 0.5 就废了。所以要用一个极简策略兜底:
if (parameter.Length < 1)
{
HOperatorSet.SetMetrologyObjectParam(handle, index, "min_score", 0.3);
HOperatorSet.ApplyMetrologyModel(image, handle);
HOperatorSet.GetMetrologyObjectResult(handle, index, "all",
"result_type", "all_param", out parameter);
}
为什么不一开始就用 0.3?因为低阈值下"测到的"未必是"要测的边"——先严后松,把误检风险限制在"严格模式已经失败"的场合。平均耗时几乎不变(成功路径只 Apply 一次),检出率却明显提升。
4. 让卡尺跟着模板结果仿射变换
因为卡尺是死的不会动,除非来料位置压的非常死或者前道线有机械件做位置修正,所以要做变换把卡尺ROI变换到匹配后的正确位置上:也就是把示教时画的卡尺 ROI,搬到匹配到的实际位置。
4.1 变换设计
4.2 三行核心代码
// 1. 由"模板示教中心 → 匹配位置 + 旋转角"生成刚性变换矩阵
HOperatorSet.VectorAngleToRigid(templateRow, templateCol, 0,
matchRow, matchCol, matchAngle, out var hom);
// 2. (可选)有缩放时再叠加缩放矩阵
HOperatorSet.HomMat2dScale(hom, scale, scale, templateRow, templateCol, out hom);
// 3. 一次性变换示教期点集(线段端点、圆心…)
HOperatorSet.AffineTransPoint2d(hom, caliperRows, caliperCols,
out var newRows, out var newCols);
每种卡尺实现一个 ApplyPreMatch 钩子:线卡尺变换两个端点,圆卡尺变换圆心(半径不变),十字卡尺变换 4×2 个端点。
4.3 圆弧的角度也要变换
圆弧要多处理半径和角度主要就多加三句话。不然旋转场景下卡尺区域会"转出"目标边缘:
HOperatorSet.AffineTransPoint2d(hom, arcRow, arcCol, out var newRow, out var newCol);
newRadius = arcRadius * matchScale; // 半径跟随缩放
newStartPhi = arcStartPhi + matchAngle; // 起始角跟随旋转
newExtentPhi = arcExtentPhi; // 跨度不变
记住有四个几何分量(圆心、半径、起始角、跨度)。
5. Mark中心定位
方形 Mark(十字基准、矩形对位 Mark)的中心定位是高频需求。由于当时手上的SmartWindow控件没写完,ROI只能画直线,所以用了四条直线拼成的十字卡尺。
5.1 直线卡尺求交
考虑到采集到的直线顺序不是一定的,我们需要一个不依赖输入顺序的求交算法。
Mark 还可能带任意旋转姿态,四条线传入的顺序也不确定——不能假设"L1 是上边、L3 是左边"。
5.2 解法:用"聚集度"筛选顶点
算法三步:
主要思想就是:顶点彼此聚集,对角远交点孤立。
// 每个"平均距离最小的 4 个点"就是四边形顶点
var fourPoints = allPoints
.Select(p => (Point: p,
AvgDist: allPoints.Where(o => o != p).Average(o => Distance(p, o))))
.OrderBy(ps => ps.AvgDist)
.Take(4)
.Select(ps => ps.Point)
.ToArray();
return GetCentroid(fourPoints); // 质心即中心
这个方法
- 不依赖线的排列顺序;
- 不依赖 Mark 姿态(旋转任意角度都成立);
- 对单边漏检有显式兜底(有效交点不足 4 个直接报错,而不是给出错误中心);
- 用起来还可以
6. 模板相关
6.1 用 XLD 轮廓建模板
当时选择了走 XLD 轮廓路线。我发现很多人很喜欢用CreateShapeModel。结果在很多场合下完全做不出来,比如拿打标纸打印的十字Mark(如下图)完全没办法采(即使有办法采,采某些产品时也经常出问题)。很多工程师都只愿意无脑CreateShapeModel(不理解)。

HOperatorSet.EdgesSubPix(reducedImage, out var edges, "canny", 8, 20, 40);
HOperatorSet.SelectShapeXld(edges, out var xld, "contlength", "and", length, 99999);
if (countObj < 1) // 宽松降级:低对比度工件兜底
{
HOperatorSet.EdgesSubPix(reducedImage, out edges, "canny", 12, 10, 40);
HOperatorSet.SelectShapeXld(edges, out xld, "contlength", "and", length / 1.5, 99999);
}
HOperatorSet.CreateAnisoShapeModelXld(xld, numLevels, angleStart, angleExtent, angleStep,
scaleRMin, scaleRMax, scaleRStep, scaleCMin, scaleCMax, scaleCStep,
optimization, metric, minContrast, out var model);
必要的时候还可以给他滤波一下。
6.2 匹配结果的可视化验证
匹配成功后,引擎把模板轮廓经"缩放→旋转→平移"三步齐次矩阵变换到匹配位置叠画在图上——现场调试时一眼可判"跟没跟上"(这一步显示很重要,很多客户就是他妈的爱看):
HOperatorSet.HomMat2dScale(identity, result.ScaleR, result.ScaleC, 0, 0, out var s);
HOperatorSet.HomMat2dRotate(s, result.Angle, 0, 0, out var r);
HOperatorSet.HomMat2dTranslate(r, result.Row, result.Column, out var t);
HOperatorSet.AffineTransContourXld(modelContours, out var matched, t);
注意矩阵顺序:Halcon 齐次矩阵是左乘组合,scale→rotate→translate 的书写顺序对应"先缩放、再旋转、最后平移",写反了轮廓会飞出图外。
7. 附录
创建模板时传出轮廓做显示,不然人家不知道建了个啥(_templateModel是CreateAnisoShapeModelXld的输出,最后结果拿Hobj的TempCounters接收然后再在显示层画出来)。
HOperatorSet.AreaCenter(templateRegion, out HTuple regionArea, out HTuple regionRow, out HTuple regionCol);
HOperatorSet.GetShapeModelContours(out HObject modelContours, _templateModel, 1);
HOperatorSet.VectorAngleToRigid(0, 0, 0, regionRow.D, regionCol.D, 0, out HTuple homMat2D);
HOperatorSet.AffineTransContourXld(modelContours, out HObject displayContour, homMat2D);
TempCounters = displayContour;
求十字质心的方法非常简单:
public static (double X, double Y) GetLineIntersection(
double x1, double y1, double x2, double y2,
double x3, double y3, double x4, double y4,
double tolerance = 1e-3)
{
double a1 = y2 - y1;
double b1 = x1 - x2;
double c1 = a1 * x1 + b1 * y1;
double a2 = y4 - y3;
double b2 = x3 - x4;
double c2 = a2 * x3 + b2 * y3;
double det = a1 * b2 - a2 * b1;
if (Math.Abs(det) < tolerance)
return (double.NaN, double.NaN);
double x = (c1 * b2 - c2 * b1) / det;
double y = (a1 * c2 - a2 * c1) / det;
return (x, y);
}
/// <summary>
/// 假设:四边形的4个交点聚集在一起,另外2个(对角线交点)距离很远。
/// </summary>
public static (double X, double Y) GetCenterFromFourLines(
(double sx, double sy, double ex, double ey) line1,
(double sx, double sy, double ex, double ey) line2,
(double sx, double sy, double ex, double ey) line3,
(double sx, double sy, double ex, double ey) line4)
{
var lines = new[] { line1, line2, line3, line4 };
var allPoints = new List<(double X, double Y)>();
for (int i = 0; i < 4; i++)
{
for (int j = i + 1; j < 4; j++)
{
var pt = GetLineIntersection(
lines[i].sx, lines[i].sy, lines[i].ex, lines[i].ey,
lines[j].sx, lines[j].sy, lines[j].ex, lines[j].ey);
if (!double.IsNaN(pt.X))
allPoints.Add(pt);
}
}
if (allPoints.Count < 4)
throw new InvalidOperationException("有效交点不足4个,可能有多条线平行");
// 找出聚集的4个点:计算每点到其他点的平均距离,取最小的4个
var pointScores = allPoints.Select(p =>
{
double avgDist = allPoints
.Where(other => other != p)
.Average(other => Distance(p, other));
return (Point: p, AvgDist: avgDist);
}).ToList();
// 平均距离最小的4个点就是四边形的顶点
var fourPoints = pointScores
.OrderBy(ps => ps.AvgDist)
.Take(4)
.Select(ps => ps.Point)
.ToArray();
return GetCentroid(fourPoints);
}
private static double Distance((double X, double Y) a, (double X, double Y) b)
{
double dx = a.X - b.X;
double dy = a.Y - b.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
private static (double X, double Y) GetCentroid((double X, double Y)[] points)
{
double sumX = 0, sumY = 0;
foreach (var p in points)
{
sumX += p.X;
sumY += p.Y;
}
return (sumX / points.Length, sumY / points.Length);
}
对卡尺的仿射变换也很简单,为了突出圆弧多的地方,我拆成两个
/// <summary>
/// 根据模板匹配结果,将示教时的卡尺点变换到目标图像中
/// </summary>
/// <param name="templateCenterRow">模板参考点 - 行坐标</param>
/// <param name="templateCenterCol">模板参考点 - 列坐标</param>
/// <param name="matchRow">匹配到的模板参考点 - 行坐标</param>
/// <param name="matchCol">匹配到的模板参考点 - 列坐标</param>
/// <param name="matchAngle">匹配到的旋转角度(弧度)</param>
/// <param name="matchScale">匹配到的缩放系数(无缩放则为 1.0)</param>
/// <param name="caliperRows">示教时卡尺点集 - 行坐标</param>
/// <param name="caliperCols">示教时卡尺点集 - 列坐标</param>
/// <param name="transformedRows">输出:变换后的行坐标</param>
/// <param name="transformedCols">输出:变换后的列坐标</param>
public static void TransformCaliperPoints(
HTuple templateCenterRow, HTuple templateCenterCol,
HTuple matchRow, HTuple matchCol,
HTuple matchAngle, HTuple matchScale,
HTuple caliperRows, HTuple caliperCols,
out HTuple transformedRows, out HTuple transformedCols)
{
HTuple homMat2D;
HOperatorSet.VectorAngleToRigid(
templateCenterRow, templateCenterCol, 0,
matchRow, matchCol, matchAngle,
out homMat2D);
if (Math.Abs(matchScale.D - 1.0) > 1e-6)
{
HTuple homMat2DScale;
HOperatorSet.HomMat2dScale(
homMat2D,
matchScale, matchScale,
templateCenterRow, templateCenterCol,
out homMat2DScale);
homMat2D = homMat2DScale;
}
HOperatorSet.AffineTransPoint2d(
homMat2D,
caliperRows, caliperCols,
out transformedRows, out transformedCols);
}
/// <summary>
/// 根据模板匹配结果变换圆弧卡尺参数
/// </summary>
public static void TransformCircularArc(
HTuple templateCenterRow, HTuple templateCenterCol,
HTuple matchRow, HTuple matchCol,
HTuple matchAngle, HTuple matchScale,
HTuple arcRow, HTuple arcCol, // 示教时圆心
HTuple arcRadius, // 示教时半径
HTuple arcStartPhi, // 示教时起始角(弧度)
HTuple arcExtentPhi, // 示教时角度范围(弧度)
out HTuple newRow, out HTuple newCol,
out HTuple newRadius,
out HTuple newStartPhi,
out HTuple newExtentPhi)
{
HTuple homMat2D;
HOperatorSet.VectorAngleToRigid(
templateCenterRow, templateCenterCol, 0,
matchRow, matchCol, matchAngle,
out homMat2D);
if (Math.Abs(matchScale.D - 1.0) > 1e-6)
{
HTuple homMat2DScale;
HOperatorSet.HomMat2dScale(
homMat2D, matchScale, matchScale,
templateCenterRow, templateCenterCol,
out homMat2DScale);
homMat2D = homMat2DScale;
}
HTuple transRow, transCol;
HOperatorSet.AffineTransPoint2d(
homMat2D, arcRow, arcCol, out transRow, out transCol);
newRow = transRow;
newCol = transCol;
newRadius = arcRadius * matchScale;
newStartPhi = arcStartPhi + matchAngle;
newExtentPhi = arcExtentPhi; // 跨度不变
}

浙公网安备 33010602011771号