简介:一份基于FPGA的二值图像连通域标记算法实现工程,面向从事实时图像处理、硬件加速的FPGA开发者。该算法仅需缓存若干行图像数据,在固定行延时内输出结果,无需外部SRAM或DDR,适合高帧率视频流场景。压缩包共592个文件,约1.82MB,包含VHDL/Verilog源码、Tcl/SH/Bat脚本、约束与IP核生成文件等,覆盖工程构建、仿真、综合到实现的多类辅助资源,便于重新生成工程或移植。已有1644人学习下载。资源内部源码结构完整,脚本及IP配置齐备,可直接参考算法架构与行缓存管理方式,对理解FPGA实时图像处理设计很有帮助。 拿到一个"连通域代码.zip"这类压缩包,很多人的第一反应是解压、看代码、复制粘贴,然后跑不通,最后丢进回收站。我刚开始接触连通域分析时也这么干过,直到后来需要自己在OpenCV里做缺陷检测,才回头把这部分基础真正啃透。这篇内容就说一件事:从一个zip包出发,把连通域处理从"看得懂代码"变成"用得明白工具",覆盖资源包结构、核心算法、实战跑通和排错经验,适合正在学图像处理基础、或者工作中要处理目标计数/区域分割的开发者参考。
1. 解包后的第一件事:这个zip里到底装了什么
先别急着点开main.cpp或者demo.py。压缩包解压之后,第一件事是看目录结构。常见的连接域代码zip包会有下面几类文件。
- 源码文件:通常包含两到三个版本,比如
connected_components.cpp、connected_components.py、还有可能带一个OpenCV版实现或者纯C语言实现。 - 样例图片:
samples/目录下面一般放着几张二值图,比如shapes.png、text_sheet.png,这些是拿来跑通流程用的。 - 说明文档:可能是
README.txt或者一个简短的markdown文件,作者会写明依赖的OpenCV版本、编译命令、输入输出格式。 - 工具脚本:有的包还会带
batch_process.py,作用是批量处理文件夹里的所有图片,把每个连通域单独裁剪出来保存。
我建议按这个顺序处理:先读README,再跑样例图,然后从核心函数读起。不要一上来就看main函数里的参数解析,那是最后才需要关心的东西。
值得留意的一点是:很多zip包里同时存在多个语言的实现版本,但它们的算法思想是同一套。C++版本侧重性能,Python版本侧重视觉化调试。如果你只是为了理解原理,从Python版本入手更快;如果是为了落地到生产环境,C++版本更值得读。还有一部分包里会带一个不需要OpenCV的纯C语言实现,那种版本适合嵌入式平台,但输入输出格式往往是自己定义的,数据结构也更原始,小白建议先在OpenCV版本上跑通,再去看C版本。
另外,zip包解压后如果出现乱码文件名,通常是编码问题,不影响源码阅读,直接手动改名即可。还有z01这类分卷压缩的情况,看到.z01单独拿不出来,就得确认所有分卷都下载完整,然后用支持分卷解压的工具(比如7-Zip)选中第一个主分卷进行解压,千万别直接改后缀,改坏了文件头后面全都白折腾。
2. 连通域到底在算什么:邻接关系与标号思路
连通域(Connected Components),简单说就是把一张二值图里"连在一起的白色像素"打上同一个编号。比如一张图里有两个圆、一个三角形,跑完算法后,三个区域分别得到标号1、2、3,背景是0。这个看似简单的操作,却是计数、尺寸筛选、区域定位、字符分割等功能的基石。
关键在一个概念上:怎么定义"连在一起"。常见有两种规则:
- 4邻接:只考虑上下左右四个方向,适合处理有明显的横向/纵向连接的结构,文本行分割常用。
- 8邻接:把对角线也算作连通,即一个像素周围8个位置中任意一个为目标像素就算同一区域。多数场景用8邻接,尤其是自然图像里的物体,往往不是横平竖直排布的。
选择时主要看应用。做OCR字符切分,用4邻接更稳,避免字符之间因为细小斜线被错误粘到一起;做零件计数或缺陷检测,用8邻接更常见,否则一个毛刺就可能把一个零件劈成两个区域,计数直接翻倍。
有了邻接规则,问题就变成了:怎么快速扫描一遍图像,把所有相同区域的像素归到一起。核心思路有两种流派,代码包里通常也对应两套实现。
2.1 两遍扫描法:标号、找等价、重标号
第一遍扫描,从上到下、从左到右遍历图像。对每个目标像素,观察它左边和上边(4邻接时)或者左上、上、右上、左(8邻接时)这些已经扫描过的邻居,规则是:
- 如果所有邻居都不是目标像素:给这个像素一个新的标号。
- 如果只有一个邻居是目标像素:把这个邻居的标号赋给当前像素。
- 如果有多个邻居都是目标像素:取其中最小标号,并记录这些标号之间的等价关系。
这里就引入了Union-Find(并查集)数据结构,专门用来管理"这些标号其实是同一个区域"这种关系。第一遍扫描结束后,还要做一次统一的等价合并,把所有互相等价的标号归并成最终标号,然后再对图像做第二遍扫描,把每个像素的临时标号替换成最终标号。
Union-Find是这套算法的灵魂,两个核心操作是Find(找根标号)和Union(合并两个等价标号)。路径压缩优化可以让查找接近常数复杂度,这也是为什么两遍扫描法能在毫秒级处理百万像素图像的原因。
2.2 种子填充法:从种子出发,像水漫一样扩散
另一种思路是,在图像中找到一个尚未访问的目标像素作为种子,然后利用栈或队列,向外扩散访问它所有连通的邻居,每访问一个就标记为同一个区域号,直到周围再没有未访问的目标像素,一个连通域就提取完了。这个过程类似在迷宫里从一个点出发把所有可达的格子都走到。
种子填充法好处是直观,而且可以边扫描边提取特征,不需要第二遍重标号。缺点是如果图像特别大、连通域特别多,递归写法容易爆栈,循环配合显式栈或队列才能扛住大数据量。代码包里如果同时提供这两种实现,建议两个都跑一遍,用一张大尺寸二值图对比耗时,你会直观感受到数据结构和算法优化对性能的影响。
3. 核心代码逐段拆解:从临时标号到最终输出
这一节直接看代码。下面这段是两遍扫描法的核心逻辑,我用的是C++风格,配合OpenCV的Mat结构,重点是展示思路,而不是纠结语法细节。
cv::Mat connectedComponentsLabel(const cv::Mat& binary, int connectivity = 8) { CV_Assert(binary.type() == CV_8UC1); int rows = binary.rows, cols = binary.cols; cv::Mat label(rows, cols, CV_32SC1, cv::Scalar(0)); // 用一个向量模拟并查集 std::vector<int> parent(1, 0); // 下标从1开始,0保留给背景 auto findRoot = [&](int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; // 路径压缩 x = parent[x]; } return x; }; auto unionSet = [&](int a, int b) { int ra = findRoot(a), rb = findRoot(b); if (ra != rb) parent[rb] = ra; }; int nextLabel = 1; // 第一遍扫描 for (int y = 0; y < rows; ++y) { const uchar* rowPtr = binary.ptr<uchar>(y); int* labelPtr = label.ptr<int>(y); for (int x = 0; x < cols; ++x) { if (rowPtr[x] == 0) continue; int left = 0, up = 0, upLeft = 0, upRight = 0; if (connectivity == 8) { if (x > 0) left = label.at<int>(y, x - 1); if (y > 0) up = label.at<int>(y - 1, x); if (x > 0 && y > 0) upLeft = label.at<int>(y - 1, x - 1); if (x < cols - 1 && y > 0) upRight = label.at<int>(y - 1, x + 1); } else { if (x > 0) left = label.at<int>(y, x - 1); if (y > 0) up = label.at<int>(y - 1, x); } int minLabel = INT_MAX; if (left > 0) minLabel = std::min(minLabel, left); if (up > 0) minLabel = std::min(minLabel, up); if (upLeft > 0) minLabel = std::min(minLabel, upLeft); if (upRight > 0) minLabel = std::min(minLabel, upRight); if (minLabel == INT_MAX) { // 没有任何已扫描邻居,创建新标号 parent.push_back(nextLabel); labelPtr[x] = nextLabel++; } else { labelPtr[x] = minLabel; // 记录等价关系 if (left > 0 && left != minLabel) unionSet(left, minLabel); if (up > 0 && up != minLabel) unionSet(up, minLabel); if (upLeft > 0 && upLeft != minLabel) unionSet(upLeft, minLabel); if (upRight > 0 && upRight != minLabel) unionSet(upRight, minLabel); } } } // 第二遍扫描:合并等价标号 std::vector<int> remap(nextLabel, 0); int finalLabel = 1; for (int i = 1; i < nextLabel; ++i) { int root = findRoot(i); if (remap[root] == 0) remap[root] = finalLabel++; remap[i] = remap[root]; } cv::Mat output(rows, cols, CV_32SC1, cv::Scalar(0)); for (int y = 0; y < rows; ++y) { const int* srcPtr = label.ptr<int>(y); int* dstPtr = output.ptr<int>(y); for (int x = 0; x < cols; ++x) { dstPtr[x] = remap[srcPtr[x]]; } } return output; }这段代码有几个细节值得单独说。
一是parent数组下表从1开始,0保留给背景。这样label == 0天然就是背景,省去单独判断。工程上这种约定很常见,能少写一堆分支。
二是第二遍扫描里的remap数组。它干的事是:把所有临时标号映射到等价的根标号,然后再把根标号压缩成连续的1、2、3……这样输出的最终标号是紧凑的,不存在跳号的情况。有些实现省略了这步压缩,最后输出可能出现标号1、5、9这样的稀疏编号,做伪彩色可视化时很麻烦,统计区域数量也不方便。
三是代码里connectivity参数只控制了四个邻居的选取,但Union-Find的逻辑对4邻接和8邻接完全通用。实际使用中如果把4邻接改成8邻接,你会发现原本独立的区域被合并了,这正是邻接规则对结果影响的直观体现。
如果包里还提供了Python版本,一般会用cv2.connectedComponents这个官方API,那是OpenCV自己封装的实现,内部做了很多优化。但你在真正搞懂上面的两遍扫描之前,不建议直接用官方API,因为一旦输出结果不符合预期,你需要理解它为什么会给你这个结果,否则排查bug时会无从下手。
4. 在真实图片上跑通流程:输入处理、特征提取与效果评估
代码读明白之后,重头戏是拿真实图片去跑。我强烈建议一开始不要直接用自己的业务图,先用简单的人造图形——比如画几个圆和矩形——验证标号结果,确认算法没问题,再换真实场景。
4.1 先造一张测试图,把流程走通
import cv2 import numpy as np img = np.zeros((300, 400), dtype=np.uint8) cv2.circle(img, (80, 80), 30, 255, -1) cv2.circle(img, (180, 100), 40, 255, -1) cv2.rectangle(img, (260, 50), (340, 130), 255, -1) num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(img, connectivity=8) print("连通域数量(含背景):", num_labels) print("各区域统计:\n", stats)跑完会看到num_labels为4(背景+3个图形),stats里每一行是一个区域的外接左上角x、y、宽、高和面积。注意OpenCV的实现默认把背景标号放在0号,所以真正的前景连通域从标号1开始,统计的时候往往要跳过第0行。
这里有一个无数次被踩的坑:输入图像一定是二值图,但二值图不等于只有0和255。如果你的图像是灰度图,直接扔进connectedComponents,算法会把所有非零像素都当作前景处理。比如一张灰度图背景是30灰度值,目标区域是200灰度值,那么背景也会被当成巨大的连通域,结果完全乱掉。所以跑之前必须做二值化,或者调用时明确用cv2.threshold/cv2.adaptiveThreshold处理好。
4.2 提取每个连通域的特征
拿到标号图之后,最常用的几个操作是:
- 按面积过滤:去掉噪点。比如只保留面积大于50的连通域。
- 画外接矩形:用
stats里的x、y、width、height在原图上画框,用于定位目标。 - 计算质心:
centroids直接给出每个区域的质心坐标,可以做目标的中心点追踪。 - 提取Region of Interest:遍历每个连通域的坐标范围,用Numpy切片取出子区域,保存或送入分类器。
下面是按面积过滤的示例代码:
filtered_count = 0 for i in range(1, num_labels): area = stats[i, cv2.CC_STAT_AREA] if area < 50: continue filtered_count += 1 x, y, w, h = stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_TOP], \ stats[i, cv2.CC_STAT_WIDTH], stats[i, cv2.CC_STAT_HEIGHT] cv2.rectangle(img_color, (x, y), (x + w, y + h), (0, 255, 0), 2)这套逻辑在零件计数、细胞计数、瑕疵检测里都能直接套用。核心思路就一个:先用面积把噪声小区域剔掉,然后再做后续处理,能省下大量不必要的算法时间。
4.3 输出结果与人工校验
一个我习惯用的校验方法是:把标号图映射成伪彩色,每个区域用随机颜色上色,背景保持黑色,然后在窗口里可视化,用眼睛确认"该连的连在一起、不该连的被分开"。这一步看着低级,但对排查参数问题特别有效。比如你发现图中两个紧挨着的零件被并成了一个区域,第一反应应该是检查二值化阈值是不是太低,或者是不是应该从8邻接改成4邻接;如果发现一个零件被拆成了多个碎片,则该检查阈值是不是太高,导致内部灰度稍低的部分被当成了背景。
还有一种常见的校验手段是:统计每个连通域的像素面积,和实际预期对比。比如你拍的是标准圆形芯片,已知芯片像素面积在800左右,如果结果大面积区域面积只有400,那大概率是二值化把芯片内部的高亮反光切掉了,而不是连通域算法有问题。记住一个原则:连通域算法是"忠实执行者",它不会撒谎,如果结果不对,90%是输入的二值图有问题。
5. 代码包里常见的坑:从编译失败到性能瓶颈
这类zip包最容易让新手崩溃的,不是算法本身,而是各种环境问题和隐性的实现细节。我整理几个高频问题,能帮你少走弯路。
5.1 编译报错:OpenCV版本和头文件路径
代码包里写的是#include <opencv2/opencv.hpp>,但你的机器上如果只装了opencv-python,没有C++版OpenCV,那g++编译时当然找不到头文件。这是环境问题,不是代码问题。C++跑通前先确认:
pkg-config --modversion opencv4如果有输出,那编译命令一般是:
g++ main.cpp -o connected $(pkg-config --cflags --libs opencv4)如果没有输出,说明C++版OpenCV未安装。这时候两个选择:装C++版,或者直接用Python版跑通再回来读C++代码。我个人建议新手走后面这条路,因为Python桩代码少、出结果快,重点是学算法而不是折腾构建工具链。
5.2 输出结果全黑/全白:类型和范围问题
有时候你跑完代码,用imwrite保存标号图,结果看到一张全黑的图。别慌,大概率是保存类型问题。标号图的通道值是1、2、3这些整数,而8位PNG显示的像素范围是0到255,标号值1在灰度显示时几乎就是黑色。处理方法:
- 可视化时先把标号图归一化到0到255范围再显示;
- 或者用伪彩色映射
cv2.applyColorMap; - 需要保存标号图做后续处理时,用PNG格式而不是JPG,JPG有损压缩会破坏标号值。
5.3 性能优化:什么时候该换算法
两遍扫描已经很快了,但如果你处理的是4K视频帧,每帧图像像素数超过800万,连通域计算还是会成为瓶颈。这时候有几个优化方向。
- 降采样:在不影响分割精度的前提下,把图像缩小到原来的一半,性能提升接近4倍。
- 只ROI处理:用目标检测先框出感兴趣区域,只在小区域内算连通域,避免全图扫描。
- 改用并行实现:两遍扫描中的第一遍可以按行分块并行,但要注意等价关系的合并需要同步处理,复杂度会上升不少。
- 用GPU加速:OpenCV的CUDA版本有
cv::cuda::connectedComponents,适合实时场景。
还有一个小技巧:如果你只需要统计区域面积而不需要完整标号图,很多实现可以在第一遍扫描时顺便累积每个标号的像素计数,最后合并等价类时再做面积合并,这样就省掉了第二遍扫描的完整重标记,内存和耗时都能降下来。代码包里如果没做这个优化,你可以自己加上,改动量不大。
5.4 处理"文本行中字符粘连"这类特殊场景
实际做OCR预处理时,字符之间往往有细微粘连,8邻接会让两个字符连成一个连通域。这时候我的做法是:先用形态学腐蚀把细小的连接断开,再跑连通域;或者改用4邻接;再或者先做一次垂直方向投影,把列方向上有空隙的文本段切分到不同区域,再分别跑连通域。没有万能参数,三种方案都要试,看哪个在测试集上稳定。
6. 扩展思路:从连通域到区域属性分析
跑通基础代码之后,你还可以顺手增加几个很实用的功能模块,这些在代码包里大多不会写全,但对真实项目很重要。
- 每个连通域的最小外接旋转矩形:
cv2.minAreaRect,适合识别有一定旋转角度的目标,还能输出角度值用于姿态估计。 - 轮廓点集与凸包:
cv2.findContours配合cv2.convexHull,计算凸包面积和区域面积的比值,这个比值可以判断目标形状是否规则,在零件缺陷检测里很常用。 - 孔洞检测:如果区域内有黑色孔洞(比如字母"O"的中心),先在区域内做一次反向二值化,再在内部跑连通域,统计孔洞数量就能区分字母"O"和数字"0"的某些印刷体变体。
- 相邻区域的邻接关系:构造区域邻接图,可以用在版面分析里,比如判断一段文字里哪些字符属于同一单词。
这些功能看起来琐碎,但都是基于同一张标号图就能完成的。标号图给了一个极其高效的结构:每个像素都知道自己属于哪个区域,区域属性可以直接从像素集合统计出来,不需要每次重新扫描整张图像。
我个人在实际项目里,最常用的是"面积+外接矩形+质心"三件套。比如做检测目标数量统计时,质心坐标可以直接代入跟踪算法做轨迹关联;做缺陷检测时,外接矩形宽高比是个很好的筛选特征,能快速剔除长条状的伪缺陷(比如划痕)和圆形真实缺陷(比如气泡)。
最后再分享一个小技巧:不要只把连通域当做一个孤立算法,它非常适合和形态学操作配合使用。先做开运算去掉小噪点,再做闭运算填充区域内部的小孔,最后跑连通域,往往能比直接对原始二值图跑算法得到干净得多的结果。这三者配合起来,处理大多数工业图像中的目标分割任务都够用了。如果后面遇到了更复杂的情况,比如重叠目标的区域分割,那时候再往分水岭算法方向研究也不迟。
本文还有配套的精品资源,点击获取