工业视觉检测中的通用设计模式与技术笔记

工业视觉检测中的通用设计模式与技术笔记

讨论通用编程思想、数学原理、语法技巧与架构模式,不涉及任何具体产品的实现链路。

文中伪代码仅用于说明概念,不反映任何真实代码。


一、变换与校正:同一个数学工具,两种数据流

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_0Type_1 这种数字后缀比 TypeLeftTypeRight 更灵活:

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 也会出现在结果里

WhereSelectTake 等大部分 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() // 独立副本

三种实现思路:

  1. 序列化/反序列化(如 JSON):简单但慢
  2. 反射递归:遍历所有字段,值类型照抄,引用类型递归
  3. 手写 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 传播

九、总结

从硬件通信到检测算法,从状态机到坐标变换,这些工程实践背后的通用原则其实很朴素:

  1. 分离关注点:数据采集和数学计算是两个世界,它们之间的桥只是一份数据契约
  2. 状态比布尔值有力:当系统超过 3 种状态时,boolean flag 会成为 bug 温床
  3. 所有外部 IO 都必须有超时:硬件会故障、网络会断、串口会丢帧
  4. 配置优于硬编码:把可变参数从代码移到配置文件,换产品不需要重新编译
  5. 数学抽象是通用语言:同一个变换矩阵可以服务 Mark 标记、也可以服务钻孔定位,因为数据格式一致
  6. 工业检测概念是公开的:灰度二值化、空隙率、圆形度、桥接检测都是教科书级别的标准知识,真正需要保护的是参数值和实现链路

以上所有内容均为通用软件工程与工业视觉讨论。文中伪代码仅用于说明设计思想,不反映任何真实代码实现。

相关推荐
用户3721574261351 小时前
C# 如何在 Excel 中创建下拉列表:3 种常见数据源实现方式
c#
明如正午2 小时前
【C#】volatile 关键字深度解析:为什么我的停止按钮有时失灵?
c#
猿长大人17 小时前
C# | JSON 序列化中的多态接口处理:基于 Attribute 实现精准 $type 控制
c#·json
一位狮子座的程序员21 小时前
如何用RAG解决AI智能体的知识盲区?
开发语言·c#
Nemo_XP1 天前
C# gridlookupedit选中内容重复还原操作
服务器·前端·c#
猿长大人1 天前
C# | 函数式编程入门
开发语言·c#·.net
unityのkiven1 天前
C# 中的奇异递归模板模式:MonoSingleton<T> 的实现
java·开发语言·c#
雪隐1 天前
WPF + MVVM 实战系列02-告别 INPC 手写时代,做个体面的现代 WPF 人
前端·后端·c#