刚入行做视觉检测那几年,我最大的困惑是:为什么每个新项目都要把代码推倒重来?今天检测手机外壳划痕,明天检测印刷品脏点,后天检测五金件尺寸,表面看是完全不同的活,但拆开来看,无非是图像采集、预处理、缺陷分割、特征提取、分类判定这几板斧。可实际开发时,很多人还是每个项目从零搭一套流程,连图像读取、参数配置、结果输出的代码都要重新写一遍。
直到后来我参与了一个需要同时兼容七八种产品检测需求的框架设计,才真正想明白一件事:视觉检测框架的灵活性,不是靠堆功能实现的,而是靠把"易变的部分"和"稳定的部分"彻底切开。易变的部分包括光源方案、相机角度、算法参数、缺陷类别;稳定的部分包括图像采集流程、处理管线、结果判定逻辑、数据存储结构。框架要做的,就是把稳定的部分做成骨架,把易变的部分全部交给配置和插件。
这篇文章我就结合自己做视觉检测框架的实战经验,聊聊如何设计一套能适配各类产品检测场景的柔性框架。内容偏工程落地,不讲虚的,适合正在做视觉检测平台、或者想把自己手头零散检测代码整理成框架的工程师参考。
1. 先搞清楚检测框架的"可变维度"到底有哪些
设计框架之前,得先回答一个问题:不同产品的检测需求,到底差在哪里?把这个问清楚了,框架该在哪些地方留接口、哪些地方做抽象,才会有依据。
我梳理过自己经手的项目,发现产品检测场景的差异主要体现在四个维度上。
第一个维度是检测目标差异。有的产品要检测外观缺陷,比如划伤、脏污、压伤;有的要检测尺寸偏差,比如长宽高、孔径、同心度;还有的要检测装配完整性,比如螺丝有没有锁到位、卡扣有没有扣紧。这些目标不同,直接决定了图像处理算法的选择——外观缺陷大概率要走形态学分割加特征筛选,尺寸测量要走边缘提取加亚像素拟合,装配检测往往要依赖模板匹配或深度学习目标检测。
第二个维度是成像环境差异。同一个产品放在不同产线上,光源类型、打光方式、相机型号、拍摄角度都可能不一样。明场、暗场、同轴光、背光、条纹光,各有各的适用场景,框架得能适应不同成像条件下图片的灰度分布特征差异。
第三个维度是缺陷形态差异。以印刷品检测为例,常见的缺陷有刀丝、漏印、套印不准、墨杠;以金属表面检测为例,常见的有划痕、凹陷、麻点、氧化色差。同样是"找缺陷",不同缺陷的灰度特征、形状特征、纹理特征差异极大,对应的分割参数、判据规则也完全不同。
第四个维度是判定标准差异。同一个缺陷,在A客户那边算良品,在B客户那边可能就算不良品。判定的临界值、区域权重、缺陷面积阈值,都是跟着客户需求走的,这部分必须是现场可调的,不能写死在程序里。
搞清楚这四个维度,框架设计的思路就清楚了:检测目标决定算法流程的模板,成像环境决定预处理链路的差异,缺陷形态决定特征算子和判据的多样性,判定标准决定规则引擎必须是配置驱动的。所以搭框架的第一步不是急着写代码,而是先把这几层可变性想明白,否则后面很容易做成一个大而全但谁也伺候不好的怪物。
我在做框架原型的时候,用了一招简单但有效的方法:把算法模块之间的流转写死,把模块内部的参数和策略全部做成配置。这样检测流程的主骨架稳定,而每个环节的行为可以被配置彻底改变,兼顾了稳定和灵活两头。
2. 框架的核心抽象:把检测流程拆成可插拔的流程节点
一个检测框架能不能灵活适配场景,关键看流程组织方式。很多失败的框架,问题就出在流程是散落的函数调用,改一个环节要牵连一片代码。我的做法是把整个检测流程抽象成一条由节点组成的流水线,每个节点只干一件事,节点之间通过统一的数据结构传递中间结果。
2.1 流程节点的划分粒度
节点的粒度划分很讲究,划粗了复用性差,划细了配置繁琐。从我的实际经验看,标准检测流程拆成以下六个节点比较合适:图像采集、图像预处理、目标定位、缺陷分割、特征提取、分类判定。
图像采集节点负责从相机、本地文件、网络图片池等来源读取图像,同时做基础的格式校验和位深转换。图像预处理节点负责打光不均校正、滤波去噪、对比度增强、几何校正这些通用操作。目标定位节点负责找到待检区域在哪里,比如定位手机壳的边框位置、印刷品的套印标记,目的是把后续算法约束在感兴趣区域内。缺陷分割节点负责把潜在的缺陷候选区域从背景中分离出来,通常输出的是二值掩膜。特征提取节点负责对每个候选区域计算面积、周长、长宽比、灰度均值、纹理特征等描述子。分类判定节点则基于特征值,结合规则或模型,决定这个候选区到底是缺陷还是干扰,是良品还是不良品。
每个节点定义好输入输出接口,内部怎么实现完全不管。这样做的直接好处是:换一种产品,只需要重新组合节点顺序、替换某个节点的具体实现,主框架代码一行都不用改。
2.2 中间数据结构的定义
节点之间传递的数据结构,是框架设计中最容易被忽略但又最重要的一环。我的定义是:一个统一的检测上下文对象,包含原始图像、当前处理图像、定位结果、候选缺陷列表、特征矩阵、判定结果、以及一条算法运行日志。
强调几点经验。第一,尽量让节点之间传递图像时用统一的图像格式和坐标空间,避免每个节点各搞一套坐标系,否则后面做缺陷框回映射到原图时会乱套。第二,缺陷候选区必须记录其所在的层级,因为有的缺陷是在全图上分割出来的,有的是在某个定位区域内二次分割出来的,层级信息对后续判定很重要。第三,运行日志不能省,每条检测记录带上前置节点的关键参数值,出问题时能快速复盘。
2.3 节点组合的两种方式
流程节点的组合方式,我建议支持两种。一种是静态管道模式,即预先定义好固定的节点顺序,把配置文件加载后按序执行。另一种是动态分派模式,即根据图像内容或产品类型自动计算走哪条分支。动态分派模式在复杂场景下很好用,比如先对图像做一次粗分类,判断当前来料是A面还是B面,然后走不同的检测子流程。
这里给一个简化版的节点调度伪代码,展示框架的组织思路:
class DetectPipeline: def __init__(self, nodes_config): self.nodes = [] for item in nodes_config: node_type = item["type"] node_params = item["params"] node = NodeRegistry.create_node(node_type, node_params) self.nodes.append(node) def execute(self, ctx): for node in self.nodes: ctx = node.run(ctx) if ctx.should_abort(): break return ctx看起来很简单,但实际运行时每个节点都要支持启停、跳过、替代这三个操作。启停好理解,跳过是指当图像不满足某前置条件时直接略过后续节点,替代是指当某个算法在当前参数下效果不佳时可以自动切换备选算法而不需要人工介入。这三个操作,配合配置文件的控制字段,就能覆盖绝大多数现场情况。
3. 配置体系怎么设计:算法参数与业务逻辑彻底分离
有了流程节点,下一步就是解决参数配置的问题。这是框架灵活性的核心环节,也是现场工程师最爱抱怨的地方——要么参数没法调,要么改参数要改代码重启程序。
3.1 配置文件的层级结构
我的配置体系分三层:全局限定层、产品定义层、批次覆盖层。
全局限定层保存框架级的通用配置,比如图像保存路径、日志级别、默认相机参数等,这部分基本固定。产品定义层是针对具体产品的一套完整检测配置,包括流程节点列表、每个节点的算法参数、判定规则、良品率统计口径等。批次覆盖层是现场临时调整用的,比如某批来料和之前没什么差别,但客户突然说线上光源变暗了,需要临时调高对比度增益,这时就通过批次级配置覆盖产品配置中的某个参数,不用动产品主配置。
阈值写法上有讲究,我一般推荐配置文件里不直接写死数值,而是写成带变量引用的表达式。例如某个缺陷的面积阈值,不写min_area = 50,而是写成min_area = ${param.area_base} * ${param.area_scale},这样现场调整时只需要改一个基准值,关联参数自动联动。
3.2 用配置中心管理多产品切换
当产品种类超过五六个以后,配置文件散落在各个目录里会非常痛苦。我后面的做法是引入一个简单的配置中心,按产品编号建立索引,每次产线切换产品时,框架自动根据当前批次的产品编号加载对应配置,并且在切换时做一次配置完整性校验。
配置完整性校验极其重要,我吃过不少亏。经常出现的情况是,新加了一个产品配置,忘了填某个必要字段,程序运行到一半直接崩溃,或者检测结果异常但日志里看不出原因。后来我强制要求配置文件在加载时做模式校验,所有必填字段缺失时直接拒绝启动,并且给出具体是哪个节点、哪个字段缺失的提示。这个设计看似增加了前期工作量,但现场省下来的排查时间远超预期。
3.3 参数热更新与版本回滚
视觉检测产线最忌讳频繁停机,参数热更新就成了一个刚需。我的实现方案是:检测框架每隔几秒检查一下配置文件的更新时间戳,如果检测到变化,就把当前正在跑的一轮检测跑完,然后在下一轮图像进来之前重新加载配置。这样既不会出现参数改了但本轮图像用了旧参数造成的混乱,也不会打断产线运行。
有热更新就一定要有版本回滚。我在配置文件里加入了一个简单的版本字段和变更历史记录,每次修改自动生成一个新的版本号,同时保留上一份可用版本。如果现场调参调坏了,快速切回上一个版本即可。千万别小看这个功能,产线半夜出异常的时候,能一键回滚比什么都宝贵。
4. 适配各类检测场景的算法组合策略
框架搭好之后,能不能适配具体场景,拼的就是算法组合的积累。这一节我把自己在用的一些组合策略列出来,重点是思路。
4.1 外观缺陷检测场景的算法组合
外观缺陷检测,核心诉求是"别漏判"。我通常的预处理链路是:先做同态滤波或背景扣除,把光照不均的影响去掉;然后根据缺陷类型选择分割算法。如果缺陷是暗场下的亮斑,用全局或者局部自适应阈值都能有不错的效果;如果是低对比度的划痕,直接用阈值很难分割出来,我会先做各向异性扩散滤波保留边缘,再通过频域滤波增强线状特征,最后结合形态学顶帽操作把划痕从背景中分离出来。
分割完的二值图会有大量噪点和小干扰块,这时从特征提取到分类判定的规则设计就很关键。一般来说,我会对每个候选缺陷区域计算面积、周长、最小外接矩形长宽比、灰度对比度这几个基础特征,然后根据历史良品样本划分可信区间。对拿不准的候选区,引入一个轻量级的检测模型做二次确认,是效率比较高的做法。传统视觉负责确定性的、可解释的缺陷,模型负责模糊的、难以用规则描述的缺陷,两者互补,比单靠哪一种都稳。
4.2 尺寸测量场景的算法组合
尺寸测量和缺陷检测的思路很不一样,它追求的是"准"和"稳",像素级别的误差都要控制。尺寸测量场景我通常不做复杂的预处理,因为预处理里的滤波操作往往会模糊边缘,直接影响测量精度。我的做法是优先保证成像均匀,然后在边缘提取环节下功夫。
用一个具体的例子来说明:测量一个圆形工件的直径。第一步,先用灰度重心法粗定位圆心,把搜索区域缩小;第二步,在搜索区域内做亚像素边缘提取,常用的是灰度插值加高斯拟合,找到边缘点的精确位置;第三步,对边缘点做圆拟合,用最小二乘法或更稳健的随机采样一致算法剔除离群点;最后,根据像素尺寸标定系数换算成物理尺寸。这里有个很实用的技巧:做边缘提取前,先对图像做一次直方图拉伸,确保边缘处的灰度过渡足够陡峭,能明显提高亚像素定位的重复精度。
4.3 装配完整性检测场景的算法组合
装配完整性检测,核心是"夹具到位没、螺丝有没有、卡扣扣上没"。这类场景最怕的是部件颜色相近、反光强,导致特征不明显。我通常先做模板匹配粗定位,再基于定位结果做局部区域的差异分析。与常见的差异模型做减影类似:拿标准模板图和待检图在同一位置做灰度差分,差分结果超过阈值的区域就是潜在异常区。
但这个方案有一个致命弱点:待检产品不能有太大的位置偏差。所以框架里一定得有一个可靠的定位前处理。我一般会先用边缘特征匹配算出一个粗略的仿射变换矩阵,然后做像素级微调,保证后面差分区域严格对齐。如果产品本身有轻微形变,我还会在局部区域做二次配准,避免形变区域误报为缺陷。这套组合下来的误报率,比直接拿模板图做减影低了一个数量级。
4.4 性能调优策略
框架要适配多个场景,性能问题也无法回避。视觉检测往往要求几十毫秒内出结果,算法节点组合不当,性能直接崩盘。几个我实测有效的方法:
第一,定位结果缓存。如果同一批次的产品位置偏差不大,首帧做一次精定位之后,后续帧可以直接用上一次的定位结果做小幅搜索,省掉大部分模板匹配耗时。第二,ROI裁剪前置。在预处理前先基于粗定位结果把感兴趣区域裁剪出来,后续算法只在小图上跑,速度提升非常明显。第三,图像金字塔降采样配合粗筛,先用低分辨率快速排除大多数不可能是缺陷的区域,剩下的小区域再上高分辨率精查。这三个策略做进去之后,框架基本能适应大多数产线的节拍要求。
5. 场景适配的实战落地:切换产品线的全过程复盘
理论说了一堆,落到实际项目里到底是什么感觉,我用一个完整的切换案例来还原。
5.1 场景描述与需求转化
某个代工厂的产线需要共线检测两种差异很大的产品:一种是金属外壳,主要检测表面划痕和凹坑;另一种是透明塑料件,主要检测内部气泡和外部流痕。这两种产品外观差异大,检测目标也完全不同,但共用一套工装和上位机系统。客户要求切换产品时操作员改一下工单号就能自动切换检测方案,不能同时维护两套独立的检测软件。
我把需求拆成了下面这张表:
| 维度 | 金属外壳 | 透明塑料件 |
|---|---|---|
| 光源类型 | 明场低角度环形光 | 背光+同轴光 |
| 预处理重点 | 反射不均校正 | 对比度增强,抑制灰尘干扰 |
| 定位方式 | 边缘轮廓匹配 | 模板匹配+边缘校验 |
| 缺陷分割 | 形态学顶帽+阈值 | 灰度梯度+区域生长 |
| 分类判定 | 面积/长宽比规则 | 圆形度+深度方向灰度分布规则 |
可以看到,两个产品在每一个节点上的具体实现都不同。如果按照传统方式,这就是两套代码;但在框架体系下,只是两份不同的配置文件。
5.2 配置编写与灰度验证过程
我先为两种产品分别编写了完整的配置文件,然后分别拉了几百张真实产线图片做离线回放验证。离线回放这个步骤值得多说一句,它比在线跑更有利于调试,因为可以看到每一帧图像在每个节点上的中间结果。我会刻意输出节点中间结果到调试目录,逐张翻看缺陷分割环节的掩膜,确认缺陷都被分割出来且干扰被抑制。
回放中发现透明塑料件有一个典型的坑:灰尘和气泡在灰度图上长得非常像,单纯靠形状特征很难区分。后来我加了一个特征——边缘的清晰度,气泡的边缘通常是光滑的弧线,灰尘的边缘是毛糙不规则的不均匀过渡。这个特征加入判定节点之后,误判率明显下降。
验证通过后,我在产线上做了小批量试跑,重点看两方面:一是切换产品时的配置加载速度,二是连续运行时的稳定性。实测中单个配置加载大约200毫秒,完全满足换产品时的节拍要求,连续运行八个小时没有出现内存泄漏或者配置丢失的问题。
5.3 现场调参踩过的真实坑
现场调参阶段,我踩过几个印象深刻的坑,写出来供大家参考。
第一个坑是改了配置但不生效。排查了很久才发现是配置文件的多级继承关系出了问题,产品配置覆盖了全局配置中的一项,但操作员改的是全局配置里的同名参数,系统默认产品配置优先级高,所以怎么改都无效。后来我在参数校验日志里加了"本次实际生效参数表",每次运行前打印一遍,这个问题再也没出现过。
第二个坑是热更新时图像撕裂。刚开始实现热更新时,直接在主处理线程里重新加载配置,结果有一帧图像正处于预处理阶段,突然换了参数,导致后续节点拿到的中间结果完全错乱,还特别难复现。后来把配置加载放到一个单独的线程,并且加了"当前帧处理完成之后再应用新配置"的机制,才算彻底解决。
第三个坑是算法参数写得太离散,现场没法微调。一开始每个产品的阈值都写成独立的硬编码数字,现场产品批次发生变化时,操作员不敢动参数,每次都得找我远程调。后来我总结了一套更合理的参数命名方式:把参数按"缺陷类型+特征类型+环节"三层来组织,比如scratch.min_area、scratch.min_contrast、bubble.max_roundness。操作员拿到配置后,看到名字就能大概知道是管什么的,自己就能尝试调整,效率高了很多。
6. 框架演进的方向:从复用积木到自学习配置
框架做完之后,我一直在想一个问题:能不能让这套框架越来越聪明,减少人工调参的依赖?
目前我尝试了一个方向:积累评判规则。框架每跑一轮检测,我都把图像、中间分割结果、特征向量、人工复判结果这几样东西存进一个样本库。当某个产品的误判样本积累到一定量后,就可以用这批样本自动校准判定阈值,或者训练一个针对该产品的轻量级分类模型。这相当于给框架加了一个"经验沉淀"的能力,时间越长,针对这个产品场景的检测效果越稳定。
另一个方向是把常用算法组合沉淀为可复用的检测模板。比如我把金属表面检测的整套流程抽象成了"表面金属检测模板",把透明件检测抽象成了"透明材质制品检测模板"。新项目来的时候,不需要从零开始配参数,只需要选择一个最接近的模板,然后根据新产品做局部调整。模板的积累才是框架灵活性最实在的体现,配置架构设计得再好,模板库里空荡荡的也没意义。
回头看我最初做框架的动机,其实很简单——不想每个项目都重复造轮子。但做着做着发现,框架带来的收益远不止省代码,更重要的是它逼着你去思考什么是业务本质、什么是易变需求,这种思考深度,是写十个独立项目都换不来的。
如果你也打算梳理手头的检测代码,我的建议是:别一上来就追求大而全,先把手头两三个最典型的项目流程吃透,抽象出上面说的那四个可变维度,然后一个一个维度地把接缝做干净。框架不是一次设计出来的,是随着项目迭代慢慢长出来的,保持重构的节奏,比憋一个大招更重要。