墙面病害检测这个方向,我前前后后折腾了大半年,最头疼的其实不是模型选型,而是数据。模型现在开源的一大把,随便挑一个都能跑,但真正决定上限的永远是喂进去的数据长什么样。这次我想认真聊一聊我们做的一个建筑物墙面混凝土缺陷检测数据集——4024张图、5个类别、VOC和YOLO两种标注格式双份输出,整个从采集、清洗、标注到格式转换、训练验证的过程。如果你是刚接触这个领域,或者正准备自己攒数据训练缺陷检测模型,这篇文章应该能帮你少走不少弯路。
先说下这次数据集解决的痛点:混凝土墙面上的裂纹、空洞、腐蚀这类缺陷,不像目标检测通用数据集里那些猫猫狗狗,它们往往颜色对比度低、边界模糊、光照变化强,而且缺陷尺寸跨度极大——一条细裂纹可能只有几个像素宽,一个腐蚀区域可能占据大半面墙。早期我们试用过几个公开数据集,不是类别太少就是标注质量参差不齐,更麻烦的是格式混乱,训练前光转格式就折腾了一周。所以这次我们干脆自己动手,从现场采集到标注规范全部重新定义,最终产出了这么一套东西,希望能给同样被数据折磨的人一点参考。
1. 这5个类别是怎么定下来的:裂纹、空洞、腐蚀之外的现实考量
很多人拿到数据第一反应是标注,但我想先说类别定义。类别定义错了,后面全白干,返工成本极高。
我们在项目早期其实走过一段弯路。最初需求方给的清单是“裂缝、蜂窝麻面、露筋、空鼓、剥落”这几个土木工程术语,听着很专业,但真到标注的时候就发现完蛋了——很多缺陷在照片里是重叠伴生的,比如钢筋锈蚀会膨胀,膨胀把混凝土撑裂,裂缝边上一定范围又有锈迹污染,这时候你让标注员判断这张图是“露筋”还是“裂缝”还是“锈蚀”,三个人能标出三个版本。
后来我们做了一轮类别合并和边界定义,把5类定为:
- 裂纹:线状开裂,粗细不限,但必须有连续性。
- 空洞:表面可见的孔洞、凹陷,包括蜂窝麻面中较集中的孔洞区域。
- 腐蚀:混凝土表面或钢筋区域的锈蚀、颜色发黄发褐、酥松脱落等。
- 剥落:表层混凝土成片脱落,露出内部骨料或钢筋。
- 渗析:墙面上的白色析出物、水渍痕迹,这类往往预示着内部渗水或化学侵蚀。
注意我把很多传统分类里的“露筋”合并到“腐蚀”里了,因为露筋通常是伴随剥落和腐蚀出现的,单独设类会导致同一个目标被重复框选。而“渗析”是我们额外加的,因为在南方潮湿地区这类现象太常见了,而且它确实能反映结构病害的早期信号。
给标注员的边界规则是这样的:
- 裂纹框只框主裂纹,分支裂缝如果与主裂缝间隔小于5个像素,并入主框;如果分支较长且独立,单独标注。
- 空洞区域的框必须完全包裹孔洞边缘,允许包含少量周围完好区域,但不得超过孔洞直径的10%。
- 腐蚀和剥落同时出现时,按“剥落优先”原则,只标剥落框。
- 渗析区域如果有明显分界线,按渗析区域标;如果水渍边缘模糊,框取颜色最深的核心区域。
这个规则表我们打印出来贴在标注工位上,每次QA争议都对着表看,三个月下来标注一致性从最初的62%提升到了88%,算是我觉得这次项目里最值的一笔投资。
2. 采集与初筛:不是拍了照片就能用
数据来源我们分为三个渠道:委托现场拍摄、公开建筑图片补充、以及我们自己拿手机在几个改造工地的随手记录。三个渠道比例大概是6:3:1。
现场拍摄用的是普通工程相机加手机,没上什么专业设备。但有一个关键细节:必须离墙面足够近。很多人拍建筑外观喜欢拍全景,那种图对检测没什么用。我们要求的拍摄距离是0.5米到2米之间,保证缺陷区域在画面中占比不低于10%。加上光线条件,阴天和晴天反差很大,所以我们刻意在不同时段、不同天气下补拍,让模型见过足够多的光照变化。
采集回来的原始照片一共7000多张,但真正进入标注流程的只有4024张。筛掉的是什么?
- 过曝或严重欠曝的:细节完全丢失,标注出来模型也学不到东西。
- 视角过于倾斜的:墙体透视形变严重,框出来的缺陷位置会偏移。
- 缺陷占比太小的:那种远远拍一整面楼,裂缝只有一条线,这种图标了等于没标。
- 同一场景重复度过高的:我们保留最多3张不同角度,防止模型过拟合单一场景。
另外做了一件很多数据集没做的事:困难样本的定向补充。我们发现第一轮初筛后,裂纹类别占比超过60%,渗析不到5%,类别极度不均衡。于是又专门去拍了一批墙面渗析和空洞的近景照片,最后把渗析类别补到了接近800张。最终五个类别的数量分布大概是:
| 类别 | 图片数(约) | 实例数(约) | 占比 |
|---|---|---|---|
| 裂纹 | 1120 | 1860 | 27.8% |
| 空洞 | 690 | 980 | 17.2% |
| 腐蚀 | 880 | 1450 | 21.9% |
| 剥落 | 640 | 820 | 15.9% |
| 渗析 | 694 | 760 | 17.2% |
总数4024张,这个数量级不算大,但对缺陷检测来说够用了,尤其是配合数据增强之后。实例数比图片数多,是因为很多图里不止一处缺陷。
3. VOC和YOLO标注格式互转:你以为的简单其实暗坑不少
网上很多教程说VOC转YOLO就是"解析XML然后除以宽高",真的只写两行代码就能跑,但跑完你会发现问题一堆。
先说明这两个格式的核心区别:
- VOC格式用XML存信息,每个目标由
<bndbox>下的xmin, ymin, xmax, ymax表示,是绝对像素坐标。 - YOLO格式用txt存信息,每行是
类别id 中心x 中心y 宽 高,这四个值全部是相对图像的归一化坐标(0到1之间)。
从VOC转YOLO的公式看起来确实简单:
x_center = (xmin + xmax) / 2 / width y_center = (ymin + ymax) / 2 / height w = (xmax - xmin) / width h = (ymax - ymin) / height但我实际转的时候踩了三个坑:
坑一:PASCAL VOC的坐标是1-indexed,YOLO是0-indexed。严谨的VOC标注工具,xmin最小是1而不是0,转换时最好先把所有坐标减1再做归一化,否则框会整体偏移一个像素。对目标检测来说单个像素偏移问题不大,但强迫症和数据洁癖患者会很难受。
坑二:图片的实际宽高和XML里记录的宽高可能不一致。我们从采集设备导出的部分照片被自动旋转过,EXIF里有方向信息,但XML里写的是旋转前或旋转后的值,一旦对不上,转换后的框全部错位。解决方案简单粗暴:转格式前先统一重新保存一遍所有图片,把EXIF剥掉,确保实际像素尺寸和XML里的一致。
坑三:YOLO格式的类别id必须是从0开始的连续整数。如果你在VOC里用的是类似"crack1, hole2"这种带数字的名字,解析时会得到乱七八糟的id。
我建议不管你是从哪个工具导出的标注,都写一段校验脚本,转换后把YOLO标注画回到图片上人工抽查。下面这个是我用的核验代码,读取txt框并画出来保存为预览图:
import cv2 import numpy as np # 读取YOLO标注并可视化 def visualize_yolo(img_path, txt_path, class_names): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path, 'r') as f: for line in f.readlines(): parts = line.strip().split() if len(parts) != 5: continue cls_id = int(parts[0]) x_center = float(parts[1]) * w y_center = float(parts[2]) * h box_w = float(parts[3]) * w box_h = float(parts[4]) * h x1 = int(x_center - box_w / 2) y1 = int(y_center - box_h / 2) x2 = int(x_center + box_w / 2) y2 = int(y_center + box_h / 2) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2) cv2.putText(img, class_names[cls_id], (x1, max(y1 - 5, 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 0), 1) cv2.imwrite('check_' + txt_path.replace('.txt', '.jpg'), img) if __name__ == '__main__': # 示例:检查单张 visualize_yolo('data/images/001.jpg', 'data/labels/001.txt', ['crack', 'hole', 'corrosion', 'spall', 'efflorescence'])这段代码不需要装额外库,有opencv就行。跑完后到目录里随机拉几十张图,肉眼扫一遍有没有框偏、框错、漏框,基本就能发现格式问题。
4. 训练YOLO模型的实操记录:数据集划分与参数选择
数据准备好了,接下来是训练。我们首选YOLOv8,实话说是因为生态成熟、文档全、社区踩坑案例多,做工程的人没时间天天去看新版论文。
数据集目录结构建议这样组织:
dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ ├── data.yaml其中data.yaml内容如下:
path: ./dataset train: images/train val: images/val test: images/test nc: 5 names: ['crack', 'hole', 'corrosion', 'spall', 'efflorescence']有人问为什么不用全部4024张都训练?当然要留出验证集和测试集。我们按6:2:2划分,并且划分的时候做了分层采样——保证每个类别在训练、验证、测试里的比例和总体比例基本一致。随机划分的坑在于,如果你某种缺陷只集中在某个拍摄地点,随机划分很容易让验证集里全是那个地点的图,造成验证分数虚高或虚低。我们按拍摄地点做了分组,同一个地点的图片尽量只出现在同一个集合里,避免模型“认场地”不“认缺陷”。
训练命令我用的是:
yolo train model=yolov8m.pt data=data.yaml epochs=200 imgsz=640 batch=16 optimizer=AdamW lr0=0.001模型选的是yolov8m而不是s,理由是缺陷目标小、长宽比极端(裂纹就是细长条),大一点的模型特征提取能力更强。实测下来m比s的mAP50高大概4个点,推理速度快那几十毫秒对墙面检测场景完全无所谓。
训练过程要关注两条曲线的差距:train_loss和val_loss。如果train_loss一直降、val_loss先降后升,那就是过拟合信号,赶紧调低epoch或者加正则。我们第一轮跑了400个epoch,到260轮已经明显过拟合了,后来收敛到200轮。
还有个容易忽略的点:数据增强参数。YOLO自带的增强会自动做旋转、缩放、翻转,但墙面缺陷检测里,一个上下翻转是没问题的,因为天花板和地面都有可能出现,但90度旋转要慎重——方向性太强,比如竖向裂纹旋转后变成横向,语义没变,但如果你未来部署时的图片方向是固定的,没必要引入太多无谓旋转。我在yolo的augment参数里把degrees从默认的0.0调到了5.0,只允许小角度旋转。
5. 评估结果与模型陷阱:mAP很高,但现场一测就露馅
训练完,常规指标是这样的:
| 类别 | mAP50 | mAP50-95 |
|---|---|---|
| 裂纹 | 0.782 | 0.468 |
| 空洞 | 0.843 | 0.546 |
| 腐蚀 | 0.791 | 0.503 |
| 剥落 | 0.823 | 0.517 |
| 渗析 | 0.863 | 0.561 |
| 总体 | 0.820 | 0.519 |
数字看着还行,但放到现场一个遮阳棚下的阴影区域,裂纹的召回直接掉到0.4。为什么?因为测试集里阴影占比很少,模型没充分见过这类样本。
另外我们还发现一个特别典型的问题:对模糊图像,模型的预测框会抖。给同一张图做5次推理(开TTA),每次框的位置都可能偏移几个像素,置信度也有波动。这是因为缺陷边缘本身是渐变过渡的,不像人、车有清晰轮廓。这个问题目前没有特别好的根治方案,我们能做的是在部署端做多帧融合——对连续视频帧的检测结果做非极大值抑制,同一位置连续出现3帧以上才输出,误报率能下降一截。
评估还有一个坑:别只看mAP@0.5。我在项目后期发现,裂纹这种细长目标,框和真实框的IoU经常在0.4到0.6之间晃荡,对mAP50而言算是正确检测,但mAP50-95上不去,意味着框的定位精度很差。如果要定位,就得上一些后处理,比如对裂纹做形态学细化,把矩形框转成polygon。如果你只做“有没有问题”的定性判断,那mAP50够用。
6. 实际部署:从离线检测到实时上报
模型训练完,最终要落到使用场景。我们把检测服务封装成一个HTTP接口,用Flask起服务,模型用YOLO的export导出为ONNX,再用onnxruntime推理,单帧处理时间在CPU上大概300ms,GPU上30ms左右。墙面巡检是慢慢扫描的,不需要特别高的帧率,所以CPU完全够用。
当时设计的时候考虑过一个路线:直接把YOLO的PyTorch模型部署。但生产环境装一套PyTorch太重,而且版本管理麻烦。ONNX导出很简单:
yolo export model=best.pt format=onnx opset=12但我导出后踩了一个小坑:YOLOv8默认导出是动态batch还是固定batch=1都行,但onnxruntime下如果输入尺寸不是640x640,推理时间会明显变长。我们的照片都是1920x1080,检测前要resize,这个resize也消耗时间。后来我把输入分辨率改成1280x1280,裂纹检出的召回提升了大约3.2个百分点,代价是单帧推理时间翻倍。权衡后,在实际巡检时我们先用640x640快速初筛,发现疑似缺陷后对缺陷区域裁剪放大再做一次细检,这样速度和精度都保住了。
关于部署环境,我强烈建议用Docker固定环境。我们吃过一次亏:某台机器上onnxruntime安装的是CPU版,跑起来慢得离谱,排查半天才发现是环境变量问题。
7. 关于这个数据集和后续扩展的一些想法
4024张图说多不多,说少不少,够用来做原型验证,但如果想要模型在工业场景里更耐用,这个数据量还是要继续涨。我个人的规划是:下一步用半监督学习的方式,用当前模型去给未标注的现场视频自动打伪标签,再由人工修正,把数据量扩到1万张级别。已经有几篇工作证实,这种方式在缺陷检测上的收益比单纯加数据增强更大。
另外,这套数据的扩展方向有几个:
- 增加缺陷的分割标注:检测框只能告诉你“这里有缺陷”,但修复工程量需要的是缺陷面积,这时候要segmentation模型。我们正在选一部分图像做像素级标注,做成半分割数据集。
- 增加多尺度检测:无人机拍摄的高度变化导致同一缺陷在不同图片里尺度可能差10倍以上。专门拍一批远景图,训练多尺度鲁棒性。
- 增加视频时序信息:静态图片检测容易漏掉细微裂纹,视频连续帧可以提供更多线索。我们在测试用SlowFast加2D检测器的组合,但目前效果尚未超过简单多帧投票。
最后再说点实在的体会。做缺陷检测最难的其实不是模型,而是对“缺陷到底是什么”的认知统一。不同的人看同一张墙面照片,裂纹和划痕的边界、腐蚀和污渍的边界,永远存在争议。我们最后能达成88%的一致性,靠的是那页标注规则。所以如果你也想做一个类似的数据集,我真心建议先花两周时间把标注规范写透、标50张试行版、再全员评审,这比后面反复返工省力得多。
数据格式方面,VOC和YOLO双格式看起来只是两种存储方式,但在实际工程里,VOC适合做人工检查和转换脚本,YOLO适合直接喂给训练框架,两边各留一份,团队协作会舒服很多。我们这次就是把VOC作为“主格式”,所有人工修改都改XML,然后通过脚本同步生成YOLO版本,再辅以上面那个可视化核验脚本,到现在还没有出现过训练时框错位的低级事故。这也算是我这次项目最想分享的实操经验了。