FPGA连通域标记CCL:游程编码、等价表与上板避坑 简介面向FPGA图像处理开发者的一套二值图像连通域标记算法实现工程聚焦实时性要求较高的嵌入式视觉场景。算法仅需片上缓存若干行图像数据即可在固定行延时内输出标记结果全程不依赖外部SRAM或DDR适合资源受限的前端流水线部署。压缩包共592个文件约1.82MB以tcl脚本、vhd与v/sv源码、sh与bat批处理、do仿真脚本及txt说明为主另含ucf/xdc约束、xise/gise工程文件与ngc网表覆盖综合、仿真、约束与板级验证的完整链路asy与sym文件则为IP核与符号封装提供支持。已有1659人学习下载。读者可从中获得可综合的Verilog/VHDL源码、仿真与综合脚本、时序约束模板及工程配置流程便于对照理解行缓存调度、标记合并与延时控制的具体实现并直接复用到自有FPGA视觉项目中。1. 从一条 1080p 产线说起这份 FPGA 连通域代码包解决的是什么问题产线上做表面缺陷检测相机每秒吐 60 帧 1080p 二值图工控机上一遍连通域标记要几十毫秒帧立刻就堆起来了。把 FPGA 算法流水线化、让连通域标记跟着像素时钟走是这类场景里最直接的一条路。这份连通域代码.zip 给的就是一套可综合的硬件实现像素流进、游程编码、等价表合并、标号流出中间不留帧缓存。它适合已经能写 Verilog、正在找一份能仿真能上板能改参数的 CCL 参考实现的人也适合拿它当课程设计或项目预研的底座。它不教你语法它把连通域在硬件里的数据通路和边界条件摊开给你看。2. 拆开代码包像素流、游程流、标号流的三段式结构拿到一个 CCL 的 RTL 包先别急着看顶层端口。先确认它走的是哪条技术路线——逐像素标记、游程编码、还是分块并行。这三条路在资源、延迟、可扩展性上差得很远选错了后面全是返工。这份代码走的是游程run-length路线也是工业检测场景里最常见的一种前景稀疏、分辨率高、行内连续段多用游程把像素压缩一遍后面的等价表规模立刻降一到两个数量级。2.1 顶层数据通路为什么中间要插一层游程逐像素做 CCL 的思路很直白每个像素看左、上两个邻居4 连通或左上、上、右上三个邻居8 连通能并就并不能并就开新标号。问题是这要求你随时能读到上一行的标号一行 1920 个 16bit 标号就是 30Kb还得支持随机访问BRAM 端口根本不够用。更要命的是等价关系会跨行传播一条斜线能把整幅图的标号串成一条长链查找根的路径深度不可控。游程路线把问题换了个维度。先在行内把连续的前景像素压成[x0, x1]这样一段一条游程只需要记起止列和一个临时标号。游程和游程之间的连通判断只做区间重叠比较不涉及逐像素访问。上一行的游程列表按x0升序存在一小块 BRAM 里本行游程也是升序产生的两边用双指针归并扫一遍就够复杂度是 O(nm) 而不是 O(W×H)。代价是输出形态变了。逐像素方案可以做到输入输出同序、延迟固定游程方案在扫描完整幅图之前没办法确定每个游程的最终根标号因为后面出现的游程可能把前面两个等价类合并掉。所以中间一定有一层「临时标号 等价关系」最终标号要么靠片内状态机回写要么把等价表吐给后级。我一般会把代码包里的模块按这个顺序理一遍像素输入 (8bit 灰度 hsync/vsync/de) │ ├─ 二值化 (阈值可配输出 1bit) │ ├─ 行内游程编码 (run_length_encoder) │ └─ 输出: {y, x0, x1, tmp_label} │ ├─ 游程归并 等价表更新 (run_merge / union_find) │ └─ 输出: 等价对 (a, b) 或直接写 parent[] │ └─ 标号输出 (label_writer) ├─ 模式 A: 输出临时标号流等价表交给后级 └─ 模式 B: 片内缓存游程表帧末合并后回写模式 A 的延迟小、BRAM 省但后级必须能接受「同一区域在不同行上标号不同」这件事模式 B 用起来舒服可一旦分辨率上去游程表的存储量会很难看。以 1080p 为例假设每行平均 32 条游程1080 行就是 34560 条每条记x0(11) x1(11) label(16) 38bit接近 160KB片内 BRAM 根本放不下必须外挂存储。所以绝大多数能上板的实现都是模式 A把合并这件事推到帧末或者干脆推给 CPU。提示拿到包先搜一下顶层有没有frame_done或者eq_flush这类信号。有说明它至少考虑了帧末合并没有说明它只输出临时标号别指望直接拿到最终结果。2.2 游程编码模块行缓冲到底要不要边界怎么收很多人第一次写游程编码会本能地加一个整行 line buffer其实没必要。行内游程只依赖当前像素和前一个像素的状态一个 1bit 的run_active寄存器就够。真正需要缓存的是「上一行的游程列表」那是给归并模块用的不是给编码模块用的。核心逻辑就是一个上升沿开游程、下降沿闭游程的状态机// 行内游程编码de 有效期间逐像素推进 // H_ACTIVE 为行有效像素数x_cnt 为列计数 always (posedge clk or negedge rst_n) begin if (!rst_n) begin run_active 1b0; run_start {XW{1b0}}; run_valid 1b0; end else if (de) begin if (bin_pix !run_active) begin // 0 - 1开一条新游程 run_active 1b1; run_start x_cnt; end else if (!bin_pix run_active) begin // 1 - 0闭合游程并输出 run_active 1b0; run_valid 1b1; run_end x_cnt - 1b1; end else if (x_cnt H_ACTIVE - 1 run_active) begin // 行末强制闭合这是最容易被漏掉的一条分支 run_active 1b0; run_valid 1b1; run_end x_cnt; end end end三个地方值得盯着看。第一x_cnt - 1在x_cnt 0时会下溢虽然正常流程下!bin_pix run_active不可能在x_cnt 0成立游程至少有一个像素但复位后第一拍的残留状态会让它发生所以run_active必须在vsync或者de下降沿清一次。第二行末那条分支不能省一条游程顶到行尾时没有下降沿不强制闭合就会丢一段表现出来就是图像右边缘的区域少一块。第三run_valid只拉高一拍下游必须能接住或者改成握手。参数上只有两个要定XW $clog2(H_ACTIVE)1080p 下是 11YW $clog2(V_ACTIVE)也是 11。别图省事全用 16bit等价表和游程表都是按条数乘位宽算容量的省下来的每一位都是 BRAM。2.3 等价表并查集上硬件还是老老实实记等价对这是整个包里最见功力的地方。软件里并查集随便写路径压缩一加均摊复杂度接近常数。硬件里不行find是一次次串行跳 BRAM每次读有一到两拍延迟最坏情况下一條链能跳几十次时序和延迟都不可控。代码包里通常能看到两种实现。一种是真并查集parent[]存 BRAMfind用状态机迭代跳数上限写死成 8 或者 16超了就直接把当前节点当根——这会引入极低概率的错误合并但在实际图像里几乎不会触发因为等价链的深度通常不超过 4。另一种是等价对表遇到(a, b)冲突就往表里写一条帧末统一做一次离线合并好处是写路径简单、时序好收敛坏处是后处理跑不掉。并查集的查找状态机大概长这样// 迭代式 find最多 MAX_HOP 跳防止病态链拖死流水线 localparam MAX_HOP 8; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; end else case (state) S_IDLE: if (find_req) begin cur find_label; hop 0; state S_RD; // 发起第一次 BRAM 读 end S_RD: begin // BRAM 读延迟 1 拍这里等数据回来 par parent_dout; state S_CMP; end S_CMP: begin if (par cur || hop MAX_HOP - 1) begin root cur; // 到根或跳数耗尽强制截断 state S_DONE; end else begin cur par; hop hop 1; state S_RD; end end S_DONE: begin find_ack 1b1; state S_IDLE; end endcase endMAX_HOP是这里最关键的参数。设小了长链会被截断产生「本该合并却没合并」的错标设大了单次查找的延迟上去如果上游是背靠背产生游程的流水线很快就会被反压。我的经验值是 8配合「按秩合并」或者「小标号优先」的 union 策略实际触发截断的概率低到可以忽略。另外parent的 BRAM 用一个简单双口就够一口读一口写读优先模式要配对否则同一拍读写同一地址会读到旧值。parent的初始化也别忘。BRAM 上电内容是随机的parent[i] i这个自反关系必须在帧起始或者复位时刷一遍。深度 2048 的话刷一遍要 2048 拍1080p 一行 2200 拍放在行消隐里刚好但很多包图省事只在全局复位时刷结果第二帧开始就出鬼标号。3. 上板之前先把仿真跑穿Python 黄金模型与逐级比对RTL 写完直接上板看画面是最费时间的调试方式。连通域这东西出错的表现又特别隐蔽——标号错了一位画面上看就是某块区域颜色不对你根本判断不了是游程编码漏了、等价表溢出了、还是合并顺序有问题。合理的做法是在仿真阶段就把中间结果全部拉出来和一份 Python 参考模型逐级对齐。3.1 用 30 行 Python 造一份可信的黄金参考参考模型不需要快需要的是「绝对不会错」。所以别用现成的库函数直接出结果自己写一遍 BFS邻域定义和 RTL 严格对齐4 连通就是 4 个方向8 连通就是 8 个方向。import numpy as np from collections import deque NB8 [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)] NB4 [(-1,0),(0,-1),(0,1),(1,0)] def ccl_label(binary, conn8): 输入 HxW 的 0/1 数组输出 int32 标号图背景为 0 h, w binary.shape labels np.zeros((h, w), dtypenp.int32) nb NB8 if conn 8 else NB4 cur 0 for y in range(h): for x in range(w): if binary[y, x] and labels[y, x] 0: cur 1 labels[y, x] cur q deque([(y, x)]) while q: cy, cx q.popleft() for dy, dx in nb: ny, nx cy dy, cx dx if 0 ny h and 0 nx w \ and binary[ny, nx] and labels[ny, nx] 0: labels[ny, nx] cur q.append((ny, nx)) return labels比对标号的时候有个必须绕开的坑标号的具体数值没有意义只有分区关系有意义。你的 RTL 可能从 1 开始分配Python 也从 1 开始但两边扫描顺序不同同一个区域的编号大概率对不上。直接np.array_equal会得到满屏红。正确的比法是建一个双向映射检查「RTL 里标号相同的任意两个像素在 Python 里也相同」def same_partition(a, b): a/b 为两张标号图判断是否表达同一个分区 fwd, rev {}, {} for va, vb in zip(a.ravel(), b.ravel()): if va in fwd and fwd[va] ! vb: return False if vb in rev and rev[vb] ! va: return False fwd[va], rev[vb] vb, va return True这个函数一跑很多「看着像错了其实没错」的误报立刻消失省下的时间够你把真正的 bug 找出来。3.2 Testbench激励怎么造超时怎么兜激励不要只用随机噪声。随机图很难覆盖游程的极端情况比如整行全 1、整行全 0、单像素宽的对角线、贴边区域、棋盘格。我一般会准备一组固定场景加一组随机场景固定场景负责覆盖边界随机场景负责跑回归。initial begin // 从文件读入激励每行一个十六进制像素值 $readmemh(frame_1920x1080.hex, img_mem); // 逐行驱动 de / hsync行消隐和场消隐按真实时序留够 for (int y 0; y V_ACTIVE; y) begin for (int x 0; x H_ACTIVE; x) begin de 1b1; din img_mem[y*H_ACTIVE x]; (posedge clk); end de 1b0; repeat (H_BLANK) (posedge clk); end end超时保护是必须加的。CCL 的状态机一旦进了死循环等价表溢出后回绕、find 跳数没收敛仿真会一直跑下去你以为在算其实早卡死了。写一个计数器超过3 * H_TOTAL * V_TOTAL拍还没等到frame_done直接$fatal报出来比等半小时强。输出侧建议同时落两份文件一份是最终标号流一份是中间量——每条游程的{y, x0, x1, tmp_label}每个等价对(a, b)。这两份文件在 Python 里能直接和参考模型对比定位速度比看波形快一个量级。3.3 观测点波形看什么打印看什么波形不是给你从头看到尾的是给你在已经缩小范围之后确认假设的。真正高效的调试顺序是先比中间文件定位到是游程层错了还是合并层错了再开波形看那几拍。三个关键观测点run_valid拉高时run_start和run_end是否和 Python 算出来的一致eq_wr_en有效时写入的等价对有没有出现「自己和自己」这种无效对frame_done之后parent[]里的根数量是否等于 Python 的连通域个数。第三个尤其管用它把「标号分配错」和「合并漏了」这两种完全不同的故障直接分开了。4. 参数怎么定位宽、深度、BRAM 预算与时序收敛一份 CCL 代码能不能直接改成你项目要的样子取决于它的参数化程度。理想情况下改分辨率、改连通性、改最大标号数都只动parameter不动逻辑。实际拿到的包往往只参数化了一半剩下的写死在 case 或者位宽里得自己补。4.1 位宽每一位都是 BRAM坐标和标号的位宽是资源的大头尤其是游程表和等价表都是「条数 × 位宽」的乘积。用$clog2推别手写 16。参数计算式1920×1080 取值说明XW$clog2(H_ACTIVE)11列坐标最大 1920YW$clog2(V_ACTIVE)11行坐标最大 1080LBLW$clog2(MAX_LABEL)16标号65535 个区域封顶RUNWXW*2 LBLW38单条游程的存储宽度EQWLBLW*232一对等价关系的宽度LBLW这块要留余量。1080p 的缺陷图理论上最多能有H*W/2个独立区域棋盘格但实际上限受游程表深度约束MAX_LABEL MAX_RUN就够了。如果等价表深度是 2048LBLW 11理论上够但为了避免溢出回绕一般还是给 16。4.2 行缓冲和等价表的 BRAM 账真正占 BRAM 的是这几块按 1080p、单口 18Kb 块估算存储块深度 × 位宽BRAM 块数备注上一行游程表256 × 381深度按「一行最多游程数」设不是按像素宽度parent[]2048 × 162一条 18Kb 存不下需要拼接等价对 FIFO512 × 321溢出要能报警不能静默丢二值行缓存可选1920 / 361打包成 36bit 宽存深度只要 54「上一行游程表深度按一行最多游程数设」这句话值得单独说。很多人一看 1920 宽就设 1920 深白白浪费 7 倍 BRAM。实际一行最多能有多少条游程最坏情况是10101010...960 条。所以要么按 1024 设并接受极端图超限要么加溢出计数值告警。我一般设 1024同时在溢出时拉一个run_overflow状态位到寄存器软件读回来能看到。4.3 时序并查集是路径最长的那个模块100MHz 以下基本不用操心148.5MHz 的 1080p60 像素时钟就要认真算账了。最长的组合路径通常出现在find的比较分支和 BRAM 输出的直连上。两个改法把parent_dout打一拍再用接受多一拍延迟或者把find的迭代拆成多个周期状态机里本来就可以拆。跨时钟域是另一个雷区。像素时钟和配置总线时钟大概率不同源阈值、分辨率、连通性这些配置寄存器必须做同步别用两级触发器糊弄多 bit 信号。简单做法是配置寄存器只在vsync下降沿之后、de上升沿之前更新靠帧边界天然隔离。注意综合报告里的 BRAM 推断数对不上时先看是不是被推成了分布式 RAM。parent[]这种带异步读的数组在部分工具里会掉到 LUTRAM资源瞬间爆掉加个(* ram_style block *)属性按住它。5. 避坑记录五个让我返工的现场下面这几条都是实际踩出来的不是从手册上抄的。每条的排查顺序都是「先看现象、再定位原因、最后给改法」照这个顺序走能省不少时间。5.1 标号全对但比对全红分区一致性被当成数值一致性现象仿真跑完把 RTL 输出的标号图和 Python 结果叠在一起看区域形状完全一致肉眼找不出差别但逐像素比对报了几万个不一致。原因RTL 的标号分配顺序和 Python 的 BFS 顺序不同。Python 从左到右扫第一个连通域拿 1RTL 可能因为游程合并顺序第一个区域拿了 7、第二个拿了 3。数值不同不代表分区不同。解决比对函数换成等价类映射也就是 3.1 节那个same_partition。再进一步可以在 RTL 输出侧加一个「标号归一化」后处理把标号按首次出现顺序重排成 1、2、3……这样和 Python 直接逐像素比也不会误报代价是多一块小映射表。5.2 对角线上的两个像素被并成一个区域4 连通和 8 连通没对齐现象一张只有单像素宽对角线的测试图参考模型算出 N 个区域RTL 算出 N/2 个合并过头了。原因8 连通判断里游程区间比较要往外扩 1 像素也就是a0 - 1 b1 b0 - 1 a1。这个「扩 1」如果没做8 连通会退化成 4 连通反过来如果 4 连通的代码里误抄了扩 1 的写法4 连通就变成了 8 连通。两种错都会让对角线场景直接翻车而且只在斜线密集的图上才看得出来。解决把邻域定义做成一个parameter CONN_8 1扩 1 的量写成CONN_8 ? 1 : 0别在两处分支里各写一遍。另外注意a0 - 1在a0 0时会下溢成11h7FF比较结果直接反了。正确写法是先判断a0 0或者把坐标扩一位做有符号比较。5.3 第二帧开始标号乱飞BRAM 里的等价关系没清干净现象第一帧完全正常第二帧开始出现大量不该合并的区域第三帧更糟复位一下又好。原因parent[]在 BRAM 里掉电内容和上电内容都不受你控制。只在全局复位时刷一次parent[i] i之后每帧新分配的标号直接复用旧槽位旧的父子关系还留在里面find一查就跳到上一帧的根上去了。解决两种改法选一个。稳妥的是每帧在vsync期间刷一遍parent[]深度 2048 就是 2048 拍行消隐里完全够省资源的是加一个「帧版本号」每个槽位多存几位版本版本不匹配就当自己是根。第一种简单我一般先用第一种跑通再考虑优化。5.4 图像右下角总是少一块区域行末游程没闭合现象所有测试图里紧贴右边界的连通域要么丢了要么面积少一截往左挪一个像素就正常。原因2.2 节那段代码里的行末强制闭合分支被漏了。一条游程从行中间一直延伸到第 1919 列没有下降沿状态机就停在run_active 1这条游程永远不输出。解决在x_cnt H_ACTIVE - 1 de那一拍无条件闭合。另外在de下降沿也补一个兜底闭合防止上游时序异常导致的悬空状态。5.5 综合后时序不过但仿真全对find 的组合路径太长现象功能仿真、门级仿真都过综合报告里 setup slack 是负的而且违例路径集中在parent[]的读地址到比较器之间。原因find的迭代写成了组合逻辑parent[parent[cur]]这种嵌套在读延迟为 1 的 BRAM 上会展开成一条很长的路径。仿真器不关心延迟所以看不出来。解决把find改成 2.3 节那个多周期状态机每次读 BRAM 之间插一拍寄存器。如果还是紧把parent拆成两块 BRAM 做乒乓或者把MAX_HOP从 8 降到 4靠 union 时「小标号优先」来压链长。6. 进阶把标号流接进后级统计与软硬协同跑通标号只是第一步实际项目里真正要的是区域属性面积、外接矩形、质心。这些不需要等最终标号合并完可以在游程流上直接累加。一条游程的面积就是x1 - x0 1外接矩形的xmin/xmax直接取x0/x1的极值ymin/ymax就是游程所在的行号范围质心的sum_x用等差数列求和一次算完(x0 x1) * (x1 - x0 1) / 2。这些量全部用临时标号做索引存进一张属性表帧末合并等价类的时候顺手把属性也合并掉。// 游程流上的属性累加一条游程一拍不需要帧缓存 always (posedge clk) begin if (run_valid) begin area[run_lbl] area[run_lbl] (run_end - run_start 1b1); sumx[run_lbl] sumx[run_lbl] ((run_start run_end) * (run_end - run_start 1b1)) 1; xmin[run_lbl] (area[run_lbl] 0) ? run_start : min(xmin[run_lbl], run_start); xmax[run_lbl] (area[run_lbl] 0) ? run_end : max(xmax[run_lbl], run_end); ymin[run_lbl] (area[run_lbl] 0) ? run_y : min(ymin[run_lbl], run_y); ymax[run_lbl] (area[run_lbl] 0) ? run_y : max(ymax[run_lbl], run_y); end end属性表深度和parent[]一致就行位宽按最大面积算1080p 全图也就 200 万21bit 够用。乘以(x1-x01)那一步会溢出sumx给到 42bit 比较稳。再往前一步是软硬协同片内只做扫描和等价对记录帧末把等价对表通过 DMA 或者 AXI-Lite 吐给 CPU合并和属性汇总在软件里做。这样 RTL 的时序压力小很多换分辨率只要改软件侧的表大小灵活性高。代价是多一次数据搬运1920×1080 的图等价对通常也就几千条几 KB 的量PCIe 或者千兆网都吃得下。验证这块我有个固定习惯每次改完参数固定跑一组「全 0、全 1、棋盘格、单像素对角线、贴四边各一条线」五个场景再跑一百帧随机图做回归same_partition全过才算完。这套流程救过我一次——改MAX_HOP的时候把 8 写成 4功能仿真前四个场景全过只有对角线那个场景露了馅因为链深刚好是 5。从那以后我每次动MAX_HOP、MAX_RUN、LBLW这三个参数都强制把五个边界场景走一遍不再信「仿真跑过了」这句话。希望帮到你。本文还有配套的精品资源点击获取