做机器视觉的工程师应该都遇到过这样的场景:一幅画面里有几十个不规则的目标,你需要知道每个目标的面积、坐标、有没有粘连,还需要在几毫秒内给出结果。软件里跑个连通域标记很轻松,Python几行、C++十几行就完事,可一旦要求做到每秒处理几十帧高分辨率图像,或者直接嵌进相机模组里,CPU就有点扛不住了。这也是为什么我一直觉得,连通域标记特别适合搬进FPGA里做,甚至可以说是FPGA图像预处理里性价比最高的算子之一。
所谓连通域标记,说的直白一点,就是在一张二值图上,把所有相邻的白色像素(或黑色像素)归成一个个独立的“块”,给每个块编个号。有了这个编号,后面的面积过滤、目标锁定、中心坐标计算就全都能顺着往下走了。FPGA上做这件事,最大的挑战不是“找连通域”,而是“标记这么多目标时,怎么管理标签、怎么处理等价关系、怎么保证整条流水线不中断”。我前前后后做过几个版本的硬件实现,踩过不少坑,这里把架构设计上真正关键的东西整理出来。
1. 为什么偏偏要在FPGA上做连通域标记
1.1 连通域标记到底在做什么
先把这个算子的本质说清楚。输入是一张二值图像,每个像素只有0和1两种状态。输出是一张同样尺寸的标签图,每个目标内部的像素都被赋予同一个编号,不同目标编号不同,背景保持为0。通常还会顺带统计出每个目标的面积、外接矩形、重心坐标等特征数据。
判断“相邻”有两种常用规则。四邻域只看上下左右四个方向,八邻域会把斜对角也算进来。工程上大多数场景用八邻域,因为斜向接触的目标在视觉上确实是一个整体,四邻域容易把一个斜向运输的工件拦腰截断。但这一个规则的选择,就直接影响了后面设计的邻域比较逻辑和等价关系数量。
算法层面,软件界最经典的做法是两遍扫描(first pass / second pass)。第一遍从左到右从上到下扫过图像,给每个像素临时分配一个标签,同时记录标签之间的等价关系;第二遍再根据等价关系把临时标签统一成最终标签。软件实现并查集很方便,第一次扫完等价关系整理清楚,第二次直接重写标签即可。
但是仔细想想,这个两遍扫描本质上反射了软件的内存访问模式:必须先完整存储整幅图像,而且第二遍扫描时访问顺序和第一遍一样,相当于数据被读了两次。硬件流水线最忌讳的就是这种“回头路”。
1.2 软件方案为什么扛不住实时场景
我做过一次对比实验。在某款处理器上,对一幅1080P的二值图跑标准两遍扫描,耗时大约在十几毫秒到几十毫秒不等,具体看目标复杂度。这个速度放在离线分析场景里完全没问题,可一旦要处理30fps甚至60fps的视频流,处理器的时间预算只有33毫秒甚至16毫秒,连通域标记本身就把预算吃光了,后面还要做形态学处理、目标跟踪,根本没有余量。
更麻烦的是软件算法的访存模式。扫描过程中,除了图像本身,还需要随机访问每个标签的等价关系表和特征统计表。目标数量越多,标签表越大,Cache命中率就越差。目标一大,耗时不是线性增长,而是会出现台阶式跳变。做工程的人最怕这种不可控的波动。
1.3 FPGA方案的核心价值:单遍扫描和确定性延迟
FPGA上的做法和软件完全不一样。图像数据按像素时钟流式进入,流水线逐级处理,每一个时钟周期吞吐一个像素,延迟固定几十个时钟周期,数据进来多少就处理多少,不占外部DDR带宽,也不依赖操作系统调度。
这种“数据流式单遍扫描”的思路,本质上是在像素到达的当下就把标签分配好、特征累加好,图像还没扫描完,特征表已经就绪。整幅图扫完的下一拍,就能直接从寄存器或BRAM里读出所有目标的面积和位置。对于生产线质检、实时目标计数这类场景,这种确定性是非常宝贵的。
值得一提的是,FPGA方案的功耗通常只有CPU方案的几分之一。在一些供电紧张或散热受限的边缘设备里,这个优势甚至比性能更有决定性。
2. FPGA实现连通域标记的选型剖析:像素级还是游程级
2.1 像素级方案:直观但暗藏隐患
最直观的硬件化方案,就是把软件两遍扫描里的“第一遍”完全搬到流水线上。当前像素进来时,把它和左邻像素、左上像素、上邻像素、右上像素依次比较。如果当前像素是前景,就根据邻域标签决定自己的标签。
这个方案的核心逻辑是“邻域标签裁决”。比如当前像素的左边邻居是标签5,上边邻居也是标签5,那当前像素直接继承标签5;如果左边是5、上边是7,那么5和7就构成一组等价关系,需要归并到同一个最终标签里。
听起来很顺,但真做起来有几个很头疼的点。一是邻域比较需要同时缓存至少一行的像素标签,这个“行缓存”规模不大,1080P也就需要存2K个标签值,问题不大。二是等价关系的数目会非常多。八邻域情况下,一个像素最多产生三组邻域组合,每一组都可能引入新的等价关系。标签一多,等价合并的硬件逻辑就会变得非常复杂。
个人体验是,像素级方案适合目标比较稀疏、体积比较小的二值图,比如PCB板上几个元件的位置识别。一旦图像里出现大面积连片的纹理,比如布匹表面的织物纹路分割结果,等价表就会被塞爆,整条流水线会频繁进入“等待合并完成”的状态。
2.2 游程级方案:工程上真正的主流
后来我逐渐转向了游程编码(RLE)的方案。核心思路是:不处理单个像素,而是把每一行中连续的1像素压缩成一个“游程”。每个游程有三个要素:行号、起始列、结束列。那么连通域标记的比较对象,就从一个一个像素,变成了上一行里的游程列表。
两个游程是否连通,只需判断当前行游程和上一行某游程在X轴上是否有交集。有交集即连通。如果当前游程和上一行多个游程都有交集,那么上一行那几个游程连同当前游程,全部属于同一个连通域。
这个方案的优势非常明显:一个1000像素宽的行,如果是密集纹理,可能压缩成几十个游程;如果是稀疏目标,可能只有两三个游程。状态数量从几千个像素锐减到几个对象,后续标签管理、等价关系处理的压力都小了一个数量级。
硬件上相当于是把“逐像素裁决”变成了“逐游程裁决”。每个时钟周期输入一个像素,但只需要在游程结束的时候做一次裁决。裁决频率低,占用的逻辑资源和时序压力都小很多。
2.3 怎么选?我给实际项目的决策建议
如果你要处理的图像目标小且稀疏、标签数少、图像尺寸不大,像素级方案实现简单,值得选。如果你要处理的是高分辨率图像、目标数量不确定、纹理比较复杂,强烈建议上RLE方案,逻辑虽然多一层游程拼接,但整体稳定性好很多。
还有一条经验:如果后续算法需要利用像素级标签图,比如把每个像素的标签值当作特征送进神经网络,那么像素级方案能直接输出对齐的标签图;RLE方案则更适合只需要目标统计特征的场景。这点要在架构设计之前就决定好,否则后面返工成本非常高。
3. 核心架构拆解:标签管理与等价关系合并
3.1 标签分配:计数器、上限和标签复用
连通域标记的硬件架构里,标签管理模块往往是整个设计中最容易出bug的地方。标签本质上就是一个编号,硬件里通常用一个计数器来产生。每遇到一个新的、和前面任何游程都不连通的目标,就分配一个新的标签号。
这里立刻会遇到一个容量问题:标签计数器需要多宽的位宽?如果图像里可能有200个目标,那至少需要8位;如果可能有5000个,那就需要13位。标签号会作为后续特征统计的地址,也会作为等价表的索引,位宽直接决定存储资源的消耗。
实际工程里我不会简单地拉满位宽,而是先预估目标数量的上限,然后留出一定的裕量。比如预期场景最多300个目标,就按512个标签来设计,再往上就得触发“标签溢出”错误标志,由上位机决定是否调整二值化阈值或视野。
标签复用则是另一个关键机制。因为整幅图像的目标数量是有限的,扫描到某一帧结束之后,所有标签都可以重置归零。但如果图像尺寸特别大、目标特别多,单帧的标签数超过设计上限,就需要考虑“局部复用”:一行的游程信息处理完之后,有些标签已经确定不会再出现了(因为当前行和下一行没有交集),这些标签可以提前回收。
这里有个容易出错的地方:标签回收之前,必须确认等价关系已经处理完,并且特征统计已经完成。否则一个标签被分配给新目标,旧目标的面积数据还在累加器里,就会串数据。
3.2 等价关系的硬件实现:从软件并查集到硬件查找表
等价关系是连通域标记的灵魂。软件里并查集一个数组就能搞定,硬件里却没有那么轻松。我见过不少人直接把并查集逻辑翻译成状态机,结果资源爆掉或者时序收敛不了。
硬件实现等价关系,我推荐的方法是等价表+迭代合并。等价表是一块RAM,表的索引是临时标签号,表内存放的是“需要合并到哪个更小的标签”。初始时每个临时标签映射到自己。每次发现两组游程连通,就把它们对应的标签在表中合并起来。
合并操作本身需要一个“求传递闭包”的过程。比如标签A和B等价,B又和C等价,那么A、B、C必须归并到同一个最终标签。硬件上可以做成一个迭代循环:反复扫描等价表,把所有间接等价的关系都补全,直到某次扫描后表内容不再变化。
这个过程在像素级方案中会打扰正常流水线,但在RLE方案中足够快——因为游程数量少,等价表也比较小,整个闭包计算往往能在几个时钟周期内完成,甚至可以安排在游程裁决后的空闲周期里做。如果真到了必须流水线不停歇的场景,还可以把等价表做成分块并行扫描,用面积换时间。
3.3 行缓存和游程缓存的存储组织
RLE方案的一个核心存储需求是:上一行所有游程的列表,以及每个游程对应的标签号。行缓存在这里不是存像素,而是存游程描述符。每个游程描述符包含起始列、结束列、标签号,打包成几十位的向量。
这里有一个细节值得留意:游程数量不是固定的,不同行的游程数可能差异很大。为了存储效率,我不会为每一行都分配相同大小的存储空间,而是用一个环形缓冲结构,按实际游程数量动态写入。这样做的代价是控制逻辑复杂一些,但对BRAM利用率有明显改善。
存储容量按“每行最大游程数”来设计。比如2048像素宽的子图像,极端情况可能是全纹理图案,一行有上千个游程;但实际二值化后的工业图目标边缘比较平滑,一行通常只有几十个游程。我的经验是取“理论极值的四分之一到二分之一”做初始设计,然后做压力测试,如果出现截断就扩展深度。
4. 实操过程:一套可落地的RLE流式流水线架构
4.1 整体流水线框架
下面是我在实际项目中验证过的一套RLE流式流水线架构,适配1080P30帧视频输入,目标数量上限512个。
整个流水线分六个主要阶段:
- 二值化:把灰度图像按阈值转成0/1,这一步通常在FPGA里直接做,不额外占资源
- 游程生成:逐像素扫描,遇到0到1跳变时开启新游程,遇到1到0跳变时终止当前游程并输出
- 游程裁决:把当前行游程与上一行游程列表比较,判定连通关系,产生临时标签和等价对
- 标签与等价管理:分配新标签、维护等价表、迭代合并
- 特征统计:以最终标签号为地址,累加面积、最小最大行列坐标
- 输出与复位:帧结束信号到来时,把特征表内容打包输出,同时复位所有状态
这里面最关键的是游程裁决和等价管理之间的接口。裁决模块每输出一个游程的标签关系,等价管理模块就要及时处理,不能出现等待死锁。我的做法是在两者之间加一个FIFO,解耦两个模块的处理节奏。
4.2 关键模块设计细节
游程生成模块的状态机相对简单,核心就是记住当前是否处于“前景游程内”。每来一个像素,如果当前是前景并且像素值是1,就延续游程,更新结束列;如果当前是前景并且像素值是0,就关闭游程,输出“游程结束”事件;如果当前是背景并且像素值是1,就开启新游程,记录起始列。
游程裁决模块要复杂得多。它需要同时维护上一行游程列表和当前行游程列表。因为两行的游程在列方向上是交错的,评判两个游程是否连通,只需比较它们的区间是否重叠。当前行游程可能同时与上一行第3、4、5个游程重叠,这3个上一行游程归属的标签,就会被记录为一组等价关系。
标签与等价管理模块的核心是一个查找表(LUT),每个标签对应一个最终标签。所有等价对都会在这里执行Union操作。为了保证等价合并不阻塞后续游程处理,我在这个模块里实现了双缓冲机制:处理当前帧的等价关系时,下一帧的游程数据已经开始写入缓冲。
特征统计模块的数据通路很清晰。每个标签对应一组寄存器:面积计数器、最小行号、最大行号、最小列号、最大列号。每当游程裁决确认某个游程归属某个标签,就将游程长度累加到面积计数器,并更新外接矩形边界。
4.3 资源与时序估算实战
以1080P、每行最大游程数128、标签上限512来估算资源:
- 行缓存:128 × 游程描述符位宽(假设56位) ≈ 7168比特,约两个BRAM
- 等价表:512 × 9位 ≈ 4608比特,一个BRAM足够
- 特征表:512 × 64位 ≈ 32768比特,约两个BRAM
- 逻辑资源:游程裁决模块比较器较多,LUT消耗在2000到4000之间;标签管理模块状态机较多,FF消耗在1500到2500之间
这个资源规模在主流FPGA里只是很小的一部分,整条链路跑在150MHz不困难。处理吞吐量等于像素时钟,150MHz下处理1080P30绰绰有余,甚至能跑到1080P60。
时序收敛方面,最容易出问题的是游程裁决模块里的“比较器树”。如果一行游程数设计过大,比如256个游程,一组比较器就有256路,组合逻辑延迟会变得很夸张。我的经验是不要让单个模块组合逻辑层数超过12层,必要时对裁决结果打一拍,牺牲一个时钟的延迟,换全局时序收敛,非常划算。
5. 常见问题与排查经验实录
5.1 等价标签没合并干净:目标被拆成两半
这是连通域标记硬件实现里最典型的bug。现象是:一个明显完整的长条形目标,输出时变成了两个或多个独立的区域,各自的面积都不对。
排查思路从等价表入手。首先要确认游程裁决模块是否把应该连通的游程关系都上报了。我遇到过的情况是:当前行游程和上一行第N个游程有交集,但和第N+1个游程仅有1个像素的“点接触”,因为裁决用的区间比较条件里开了“不包含端点”的边界条件,硬生生把这个连通关系漏掉了。
八邻域连通约定下,两个游程即便只有一个像素的斜向接触,也必须判定为连通。区间比较必须使用闭合区间比较:当前游程起始列 ≤ 上一游程结束列,且当前游程结束列 ≥ 上一游程起始列。这个条件少了任何一个等号,都会漏判。
5.2 标签数量爆炸,特征统计表溢出
某个版本的测试里,我对着一幅满屏噪点的图像做连通域标记,每一行几乎每个像素都是一个独立游程,标签数瞬间飙到设计上限之外。现象是特征表的部分目标数据被覆盖,输出结果里出现“幽灵目标”和面积异常。
对策有几个方向。一是从源头控制:二值化之前加上一个3×3或5×5的中值滤波,把孤立噪点滤掉,目标数量会大幅下降。二是特征表溢出保护:当标签分配模块发现当前标签数已达上限时,直接把帧标记为“溢出”,宁可不输出本帧结果,也不要输出错误数据。
实测下来,中值滤波这一招最管用。二值化图像里的噪点通常是单像素或双像素的孤立点,中值滤波能直接消除,连通域数量能下降一个数量级。
5.3 帧间干扰:上一帧的标签跑到了这一帧
这个问题比较隐蔽。特征统计模块在帧结束时输出数据并复位,但如果在复位信号到来之前,下一帧的第一个游程已经开始处理,新数据就会累加到旧标签上。
解决方法是输出和复位做成原子操作:帧结束信号拉高后,先冻结特征表的写入,等待输出FIFO把数据全部读走,然后统一复位所有标签计数器和等价表,最后再释放写入使能。这里我额外加了一个“复位完成”信号,下游模块只有看到这个信号才认为新一帧可以开始。
排查这类问题有一个笨办法:查看输出面积序列,如果发现第一个目标的面积等于上一帧最后一个目标的面积,那基本就是帧间干扰了。
5.4 时序收敛困难:比较器层级太多
RLE方案的游程裁决模块,当上一行游程数上限比较大时,组合逻辑会变得非常长。发生的场景通常是把一行游程数从64改成256之后,时序报告里出现红色的WNS。
我的调整方式分两步。第一步,把裁决模块拆成两级流水:第一级只做当前游程与上半部分游程的比较,第二级做下半部分游程的比较,最后合并结果。第二步,对等价表的迭代合并次数做上限限制,如果经过8次迭代还没收敛,直接判定为等价表异常并报警。这样时序和功能稳定性都有了保障。
5.5 边界行处理:图像第一行和最后一行
第一行游程没有上一行可比较,必须有初始化逻辑把所有游程当作新目标处理。最后一行处理完之后,要额外触发一次“清理逻辑”:把依然没有关闭的特征表条目强制输出。很多初版设计会漏掉这两个边界条件,导致第一行目标面积少算或者最后一行目标不输出。
6. 架构设计之外的工程思考
6.1 特征输出:从标签图到决策参数
实际项目里,连通域标记不是终点,而是后续逻辑的数据来源。我会在标签分配的同时,并行计算每个目标的面积、外接矩形、重心、甚至周长估计。这些特征在FPGA里都是简单的累加器,开销很小,但能省掉CPU端的大量重复计算。
对于目标数量的大头,通常是按面积阈值过滤。硬件里做一个阈值比较器,只输出面积在指定范围内的目标标签。比如在工业质检场景里,微小碎片面积小于50像素、大型液滴面积大于2000像素,这些都不关心,只保留中间段的目标。这样输出给上位机的数据量就非常小了。
6.2 和二值化、形态学处理的协同
连通域标记对输入图像质量非常敏感。我通常会在二值化和连通域标记之间加一层轻量级滤波,既可以是中值滤波,也可以是开运算。这几个模块在FPGA里天然级联,不会增加额外访存开销。
但要注意:形态学操作会占用额外的行缓存资源,而且开运算对连通域边界的平滑效果会直接影响游程数量。如果二值化阈值没有调好,图像里出现大量碎边缘,无论怎么优化连通域的RLE,状态量都会上去。我建议先把二值化阈值统计清楚,再决定要不要加入形态学预处理。
6.3 我个人在实际操作中积累的心得
踩过这么多坑之后,我最大的体会是:连通域标记这个算子,难点不在“算法”本身,而在“状态管理”。软件里自然而然处理好的标签生命周期、等价关系统一、边界输入输出,在硬件设计里每一个都要显式地建模和处理。
所以我不建议一上来就写RTL。先把整套架构在软件模型里跑通,用Python或者C++模拟出每个模块的输入输出接口和数据格式,生成黄金向量,然后再开始FPGA编码。这样后期调试的效率会高很多,因为大部分边界问题在软件阶段就能发现。
如果你正在做相关设计,我建议第一版千万不要追求极致性能和极致资源,先保证:标准图正确、含噪图不爆标签、连续多帧不串扰。这三个基本盘稳了,再考虑怎么把频率从100MHz提到200MHz,怎么把BRAM省掉一两块。连通域标记这种算子,稳定可靠比炫酷重要得多,毕竟它是整条视觉流水线的地基,地基稳了,上面盖什么都踏实。