工业视觉检测中的通用设计模式与技术笔记
讨论通用编程思想、数学原理、语法技巧与架构模式,不涉及任何具体产品的实现链路。
文中伪代码仅用于说明概念,不反映任何真实代码。
一、变换与校正:同一个数学工具,两种数据流
1.1 刚性变换的数学原理
任何需要「理论坐标对齐实际位置」的场景,都可以抽象为同一个数学问题:
刚性变换 = 只允许旋转 + 平移,不允许拉伸或缩放。
直觉理解:把所有理论点印在一张透明塑料片上,把塑料片放到实物上方,只能转 和挪,转到印的点跟下面的实际位置对齐了,所有点就全对齐了。
数学上是一个 2×3 仿射变换矩阵:
[ cosθ -sinθ Tx ]
[ sinθ cosθ Ty ]
对任意一点 (x, y):
x' = x·cosθ - y·sinθ + Tx
y' = x·sinθ + y·cosθ + Ty
1.2 两种数据来源,同一套数学
实际工程中有两个场景需要做这个变换:
| 场景 | 测量手段 | 拍摄目标 | 参考点 |
|---|---|---|---|
| Mark 点对准 | 光学相机 | 铜箔上的基准标记 | 2 个以上 |
| 钻孔位置矫正 | X 射线扫描 | 钻孔 | 2 个框 |
核心流程一致:
1. 理论坐标已知(CAD 导入)
2. 实际测量 → 得到 2+ 组 (理论坐标, 实测坐标) 配对
3. 输入变换算法 → 输出 2×3 刚性变换矩阵
4. 所有坐标 × 矩阵 = 矫正后坐标
这个模式的精妙之处在于接口统一、数据解耦:变换模块只接收「两组配对坐标」这个抽象输入,完全不知道坐标是用哪种传感器采集的、用来做什么。
为什么选对角两端的两个参考点? 离得越远,旋转角的信噪比越高。两个参考点太近 → 微小测量误差会被放大成旋转误差。
1.3 为什么用「两两配对」而不是最小二乘
只需 2 组配对就能解出旋转 + 平移(2 个未知数 θ 和 Tx,Ty,2 组提供 4 个方程)。如果提供更多点(3 组、4 组),则可以用最小二乘法提高抗噪能力:
2 组配对 → 确定唯一解(点越远越好,减少角度误差)
N 组配对 → 最小二乘最优解(抗噪声更强)
1.4 设计启示
数学工具只定义接口,不关心数据来源。调用方负责提供数据,工具方负责计算。这是依赖倒置的数学版本。
类比:Sort() 不关心数组里的东西是人名还是数字,只关心怎么比大小。
二、图像检测算法:公开概念与参数化设计
以下所有检测概念均为工业视觉领域的公开标准,不涉及任何私有算法。
2.1 空隙率检测(Void Rate)
原理:计算缺陷区域面积占目标区域面积的百分比,超过阈值判不合格。
空隙率 = 空洞面积 ÷ 目标区域面积 × 100%
判定规则:空隙率 ≤ 阈值 → OK;空隙率 > 阈值 → NG
这个阈值叫「归一化阈值」,是用百分比而非绝对值来判定,因为不同尺寸的元件不能共用同一个绝对面积阈值。
csharp
// 伪代码:空隙率检测
bool CheckVoidRate(Region targetArea, Region voidAreas)
{
double ratio = voidAreas.Area / targetArea.Area * 100.0;
return ratio <= config.VoidNormMax; // 如 30%
}
2.2 灰度阈值法与二值化
原理:图像每个像素是 0~255 的灰度值。二值化就是把像素按灰度区间分类------落在区间内的变 1(白,前景),其余变 0(黑,背景)。
原始图像(256 级灰度)
↓ 二值化:灰度在 [low, high] → 1;其余 → 0
二值图(只有 0 和 1)
↓ 连通域分析
提取出感兴趣的特征区域
为什么需要二值化? 原始灰度图信息太多,算法需要先「过滤」出目标特征。比如检测金属间的桥接缺陷时,只需要高灰度值(金属)的区域,低灰度值(背景/空洞)可以忽略。
csharp
// 伪代码:灰度区间二值化
BinaryImage Threshold(GrayImage input, byte low, byte high)
{
var result = new BinaryImage(input.Width, input.Height);
for each pixel (x, y):
result[x, y] = (low <= input[x, y] <= high) ? 1 : 0;
return result;
}
2.3 桥接检测(Bridge / Short)
原理:检查二值图中的连通域是否「桥接」了两个本应隔离的区域。先做灰度二值化提取高亮连通域,再判断连通域是否跨越了不该连接的位置。
csharp
// 伪代码:桥接检测
bool CheckBridge(BinaryImage input)
{
// Step 1: 灰度区间二值化
var bridges = Threshold(input, grayLow, grayHigh);
// Step 2: 连通域分析
foreach (var region in ConnectedComponents(bridges))
{
// 判断当前连通域是否连接了两个独立区域
if (region.CrossesIsolatedZones())
return false; // NG:存在桥接
}
return true; // OK
}
桥接的本质:在电路制造中,相邻的焊点/导线之间应该是绝缘的。如果焊锡连成一片跨越了隔离区,就形成了桥接(短路)。检测算法就是通过图像判断是否有不该出现的连通。
2.4 圆形度检测(Circularity)
原理:圆形度衡量一个 blob 有多「圆」,公式为:
圆形度 = (4π × 面积) ÷ (周长²)
完美圆的圆形度 = 1.0。形状越不规则,值越小。
直觉解释:相同面积的所有形状中,圆的周长最短。面积固定,周长越短 → 越接近圆 → 圆形度越接近 1。
csharp
// 伪代码:圆形度计算
double ComputeCircularity(Region blob)
{
// 完美圆 = 1.0
double c = (4.0 * Math.PI * blob.Area) / (blob.Perimeter * blob.Perimeter);
return c * 100.0; // 转百分比,方便设定阈值如 70%
}
bool IsRoundEnough(Region blob)
{
return ComputeCircularity(blob) >= config.CircularityNormMin;
}
应用场景:检测钻孔是否圆、焊点是否正常成形。非圆形往往意味着工艺缺陷。
2.5 多版本参数的命名约定
当一个检测类型需要多套参数时(如不同区域/不同方向的检查标准不同),用 Type_0、Type_1 这种数字后缀比 TypeLeft、TypeRight 更灵活:
csharp
// 可扩展,加第三个只需 +1
var checkers = new List<Checker>
{
new(BridgeConfig._0), // 第一套规则
new(BridgeConfig._1), // 第二套规则
new(BridgeConfig._2), // 第三套 --- 新增,不改架构
};
数字后缀 vs 语义命名:
- 语义命名(
Left,Right,Top)→ 直观,但数量固定 - 数字索引(
_0,_1,_2)→ 可动态扩展,方便数组遍历
选择取决于「参数套数是固定的还是动态的」。
2.6 参数可配置化的核心价值
阈值不应该写死在代码里。可配置 = 把决策权从编译时推迟到运行时------换一个产品型号就换一套参数,不需要重新编译。
csharp
// 坏:硬编码
if (voidRatio > 30) Report("NG");
// 好:可配置
if (voidRatio > config.NormMax) Report("NG");
三、状态机设计:从简单到复杂
3.1 不允许跨状态跃迁
设计原则:状态图不是完全图。只有预设的几条边是合法的。
错误设计:任何两个状态之间可以直接切换
运行中 → ✗ → 关机完成(跳过冷却步骤,硬件损坏)
正确设计:状态跃迁路径预定义
运行中 → 冷却中 → 待命 → 关机中 → 关机完成
实现方式通常是有向图(邻接表 / 字典),每次切换前查表验证:
csharp
// 通用状态机骨架
class StateMachine<TState> where TState : Enum
{
Dictionary<TState, HashSet<TState>> _edges = new();
void AddTransition(TState from, TState to)
=> _edges[from].Add(to);
TState Transition(TState target)
{
if (!_edges[_current].Contains(target))
throw new InvalidTransitionException(_current, target);
_current = target;
OnStateChanged(target);
return _current;
}
}
3.2 状态机用作「只做一次」的守卫
一个常见模式:用状态本身承载「已经做过什么」的记忆,调用方不需要自己记录。
没有 FSM:
if (执行过 → 跳过) ← 逻辑散落各处,容易漏改
有 FSM:
状态A → 执行动作 → 自动进入状态B
调用方无论重复调用多少次,只会走向状态B的分支
3.3 两种 vs 无限状态------何时用状态机
| 场景 | 方案 |
|---|---|
| 2 种状态互斥 | boolean flag 够用 |
| 3~5 种状态线性流转 | 简单的枚举 + switch |
| 5 种以上状态,多条分支路径 | 正式状态机 + 跃迁表 |
判断标准:如果画得出来一张清晰的状态图,就应该用状态机;如果画不出来,说明你还没理解自己的系统。
3.4 设备控制中的多状态模型
以射线出束设备为例,一条完整的生命周期可能有 7 个状态:
ERROR --- 故障(任意状态都可能进入)
IDLE --- 空闲
WARMUP --- 预热(逐步升到目标温度)
STANDBY --- 待命(预热完毕,等待触发)
READY --- 就绪(可以开始工作)
WORKING --- 正在工作
COOLDOWN --- 冷却中
SHUTDOWN --- 正常关机
关键约束:不允许跨状态跃迁。不能从 IDLE 直接跳到 WORKING,必须走 IDLE → WARMUP → STANDBY → READY → WORKING。
_STS 命名约定 :表示这是一个状态寄存器,不是普通变量。这是一种自文档化的命名技巧,团队看到 _STS 后缀就知道这个变量承载状态转换逻辑,不会随便赋值。
3.5 外部重置 vs 内部重置
csharp
class Workflow
{
State Current { get; private set; } = State.Idle;
void Start()
{
if (Current != State.Idle)
throw new InvalidOperationException("已启动,不能重复启动");
// ... 执行启动逻辑
Current = State.Running;
}
void Reset()
{
// 内部重置:自己把状态拉回 Idle
// 外部调用方不应该关心内部状态、也不应该手动设置状态值
if (Current == State.Error || Current == State.Done)
Current = State.Idle;
}
}
状态切换全部由方法封装,State 的 setter 是 private。这就是「封装变化」------状态是内部实现细节,外部只调用语义化方法。
四、串口通信中的并发控制
4.1 生产者-消费者模式在串口上的应用
经典问题:一个线程发指令,另一个线程收应答。它们之间怎么配合?
发送线程 接收线程
│ │
├─ 发数据 → 等待信号 │
│ WaitOne(timeout) ├─ 收到完整帧
│ (阻塞,不占 CPU) │ Set() ← 唤醒
│ │
├─ 被唤醒,读缓存 ←─────────┘
│
└─ 解析应答,返回结果
4.2 通信协议:请求-响应模型
工业设备常用的通信链路:
物理层:RS232 / RS485(三条线:TX, RX, GND)
数据链路层:Modbus RTU(主从问答 + CRC16 校验)
帧格式:[从站地址 1B] [功能码 1B] [数据区 0~252B] [CRC16 2B]
常用操作:
- 读保持寄存器(功能码 03):主站查询从站状态
- 写保持寄存器(功能码 06):主站下发控制指令
4.3 AutoResetEvent vs ManualResetEvent vs Semaphore
csharp
// AutoResetEvent:一对一,唤醒后自动关门
AutoResetEvent a = new(false);
a.Set(); // 唤醒 1 个,自动 Reset
// ManualResetEvent:广播,关门需手动
ManualResetEvent b = new(false);
b.Set(); // 全部唤醒,门一直开着,必须手动 Reset()
// Semaphore:允许 N 个线程同时走
Semaphore c = new(3, 3); // 最多 3 个
c.WaitOne(); // 占用 1 个槽
c.Release(); // 释放
选择原则:
- 一对一问答应 →
AutoResetEvent - 广播通知(如「程序即将退出」)→
ManualResetEvent - 限制并发数(如「最多 5 个线程同时写磁盘」)→
Semaphore
4.4 超时设计为什么重要
所有带 WaitOne 的地方都应该有超时参数。没有超时 = 可能永远卡死。
csharp
// 危险:无限等待
signal.WaitOne();
// 安全:最长等 500ms
if (!signal.WaitOne(TimeSpan.FromMilliseconds(500)))
{
throw new TimeoutException("硬件无响应");
}
五、C# 的那些小语法,大用场
5.1 switch 语句 vs switch 表达式
传统 switch 语句 只干动作不返回东西;switch 表达式直接产生结果:
csharp
// 语句:需要中间变量 + break
string label;
switch (code)
{
case 0: label = "OK"; break;
case 1: label = "NG"; break;
default: label = "Unknown"; break;
}
// 表达式:一行搞定,编译器保证穷尽
string label = code switch
{
0 => "OK",
1 => "NG",
_ => "Unknown"
};
表达式比语句强在两点:
- 编译器自动检查是否覆盖所有可能值(穷尽性检查)
- 可以内联嵌入其他表达式,减少临时变量
5.2 ?. + ?? 组合拳
csharp
// 三层防护:空引用 → 默认值
var result = obj?.Property?.SubProperty ?? defaultValue;
// 等价于:
if (obj == null) result = defaultValue;
else if (obj.Property == null) result = defaultValue;
else result = obj.Property.SubProperty;
?. 是「活着才碰」,?? 是「为空就拿备胎」。两者配合覆盖了 90% 的空引用处理场景。
5.3 LINQ 的延迟执行陷阱
csharp
var items = new List<int> { 1, 2, 3 };
var filtered = items.Where(x => x > 1); // 还没执行!
items.Add(0); // 修改源
// filtered 遍历时才会执行 → 0 也会出现在结果里
Where、Select、Take 等大部分 LINQ 方法是惰性求值 。只有 .ToList()、.ToArray()、foreach 才真正执行。
常见坑:foreach 遍历 .Where() 结果时修改原集合 → InvalidOperationException: Collection was modified。
csharp
// 正确做法:先 ToList() 生成快照
var snapshot = original.Where(x => x.IsActive).ToList();
foreach (var item in snapshot)
{
original.Remove(item); // 安全:遍历的是快照
}
5.4 深拷贝:什么时候需要,怎么实现
浅拷贝只复制引用,深拷贝递归复制整个对象图。
浅拷贝:b.Name = a.Name // 共享同一个对象
深拷贝:b.Name = a.Name.Clone() // 独立副本
三种实现思路:
- 序列化/反序列化(如 JSON):简单但慢
- 反射递归:遍历所有字段,值类型照抄,引用类型递归
- 手写 Copy 构造函数:最可靠,但工作量大
选择:简单值对象用方案 1,复杂对象图且关心性能用方案 3。
5.5 浮点精度问题
csharp
// 经典陷阱
double a = 0.1 + 0.2;
Console.WriteLine(a == 0.3); // → False!
// 原因:0.1 在二进制中无限循环,存储时截断
// 0.1 ≈ 0.10000000000000000555
// 0.2 ≈ 0.20000000000000001110
// 0.1 + 0.2 ≈ 0.30000000000000004441
// 方案 1:容忍误差
Math.Abs(a - 0.3) < 1e-9;
// 方案 2:放大取整
Math.Round(a * 1e8) / 1e8;
原理:浮点数在二进制中无法精确表示 0.1,但整数是精确的。× 10^8 把小数位移到整数域 → Round 消除末尾误差 → ÷ 10^8 还原,代价是只保留 8 位有效小数。
金融计算用 decimal(128 位,10 进制精确)。
5.6 Math 取整三兄弟
csharp
double val = 3.7;
Math.Ceiling(val); // → 4 向正无穷
Math.Floor(val); // → 3 向负无穷
Math.Truncate(val); // → 3 向零(砍小数)
// 负数时注意区分
Math.Ceiling(-3.7); // → -3 (向正无穷,不是向绝对值大)
Math.Floor(-3.7); // → -4 (向负无穷)
Math.Truncate(-3.7); // → -3 (砍小数)
理解关键:Ceiling = 天花板(往上够),Floor = 地板(往下趴),Truncate = 砍掉小数位。
5.7 ! 的双重身份与 [..] 集合表达式
csharp
// 1. 逻辑取反
bool result = !flag;
// 2. Null 抑制运算符 (C# 8+) --- 编译器别报警,我确定它不是 null
string sure = maybeNull!;
// 3. 集合表达式 + spread (C# 12)
int[] a = [1, 2, 3];
int[] b = [4, 5, 6];
int[] merged = [..a, ..b]; // → [1, 2, 3, 4, 5, 6]
六、架构模式
6.1 三层嵌套模型
容器(最大范围)
└── 子区域(独立单元)
└── 元件(最小检测单元)
每层有独立的局部坐标系。元件局部 → 子区域 → 容器全局 的坐标转换通过累积变换矩阵实现。这是图形学里的经典场景树(Scene Graph)模式。
6.2 大块拆分的通用贪心策略
当一个元件太大,单次处理不了,需要切分:
1. 算出一轮能处理的最大范围(由硬件物理参数决定)
2. 用这个范围切成 N×M 网格
3. 每个格子独立处理
4. 相邻格子之间留 overlap 防止拼接缝隙
关键参数:格子的有效尺寸 = 硬件范围 − 2×边缘浪费量。如果不扣掉边缘,拼接时会有模糊/失真带。
6.3 权限系统的通配符匹配
权限码用冒号分隔的层级表示(区域:模块:操作),匹配支持通配符:
规则 "A:*:*" → 匹配 "A:X:read" ✓ → 不匹配 "B:X:read" ✗
规则 "A:**" → 匹配 "A:X:sub:op" ✓ (** 匹配任意段数)
段拆分后用循环逐段比对,* 遇到就跳过。这种设计比正则表达式更好理解和调试。
七、测量基准的「存-复测」模式
7.1 模式描述
建配方时:
→ 在特定位置测一次 → 存储测量值作为基准
每次运行时:
→ 回到同一位置复测 → 当前值 − 基准值 = 漂移量
→ 用漂移量补偿后续所有计算
7.2 为什么存「基准 + 坐标」而不是只存「基准」
只存基准值不够------下次你不知道去哪儿复测。必须把测量位置一起存下来:
错误:基准 = "45.3" → 知道偏了多少,不知道在哪偏的
正确:基准 = "45.3, X=320, Y=240" → 知道在哪测,知道偏了多少
7.3 补偿方向
| 情况 | 漂移量符号 | 含义 |
|---|---|---|
| 当前 > 基准 | 正 | 物体远了 |
| 当前 < 基准 | 负 | 物体近了 |
补偿量 = −漂移量(方向相反)。
八、设计原则总结
8.1 关注点分离
数学工具(变换矩阵)和数据采集(传感器)是两个独立模块。它们之间的桥只是一份数据契约:
传感器模块 → [坐标数据] → 变换模块 → [矫正结果]
↑ 数据格式是约定的接口
8.2 为扩展而设计
- 参数用后缀索引(
_0,_1)而不是语义命名 → 无限加号 - 状态机用邻接表而不是硬编码 if-else → 加一个状态只需加一行配置
- 权限用通配符匹配 → 加一个模块不需要改匹配逻辑
8.3 尽早失败、有超时兜底
- 状态跃迁:非法路径立即抛异常
- 通信:WaitOne 带超时,超时后重试或告警
- 空引用:
??提供默认值而不是 null 传播
九、总结
从硬件通信到检测算法,从状态机到坐标变换,这些工程实践背后的通用原则其实很朴素:
- 分离关注点:数据采集和数学计算是两个世界,它们之间的桥只是一份数据契约
- 状态比布尔值有力:当系统超过 3 种状态时,boolean flag 会成为 bug 温床
- 所有外部 IO 都必须有超时:硬件会故障、网络会断、串口会丢帧
- 配置优于硬编码:把可变参数从代码移到配置文件,换产品不需要重新编译
- 数学抽象是通用语言:同一个变换矩阵可以服务 Mark 标记、也可以服务钻孔定位,因为数据格式一致
- 工业检测概念是公开的:灰度二值化、空隙率、圆形度、桥接检测都是教科书级别的标准知识,真正需要保护的是参数值和实现链路
以上所有内容均为通用软件工程与工业视觉讨论。文中伪代码仅用于说明设计思想,不反映任何真实代码实现。