简介:这份建筑墙壁损伤缺陷分割数据集面向计算机视觉与机器学习方向的研究者、算法工程师及建筑安全检测从业者,用于训练和评估图像分割模型,实现对剥落、锈蚀、裂缝、排水等墙体病害的自动识别与分类。资源包共1个docx文件,约2.1MB,文档内整理了数据集说明与标注信息,核心数据为7820张640×640像素的jpg图片及一一对应的json标注文件,采用labelme多边形框标注,覆盖涂鸦、支座、潮湿斑迹、锈蚀、剥落、泛碱、空洞区域、风化、残留模板、空腔、外露钢筋、伸缩缝、裂缝、排水、活动裂缝、接头胶带、岩窝、防护设备、水侵蚀混凝土和物体共20个类别,其中锈蚀、剥落、空腔等类别标注框数较多。目前已有102人学习。读者可据此构建语义或实例分割流程,自行将json转为mask、YOLO或COCO格式,用于建筑健康监测与自动化巡检研究。
1. 建筑墙壁缺陷分割数据集:7820 张 labelme 标注到底能跑出什么
手里拿到一份标注好的建筑墙壁损伤数据集,第一反应往往不是“太好了”,而是“这 20 类到底能不能训起来”。这份资源给的是 7820 张 640×640 的 jpg 图片,配 7820 个 labelme 格式的 json,标注方式是 polygon 多边形,覆盖 Graffiti、Rust、Spalling、Crack、ExposedRebars 等 20 个类别。它解决的是建筑外立面巡检里最费人力的那一步——把“哪里有缺陷、缺陷边界在哪”变成可训练的像素级监督信号。适合做语义分割、实例分割的算法工程师,也适合做建筑健康监测、无人机巡检、边缘端缺陷识别的团队。但要注意,它只给图片和 json,不给 mask,也不保证任何模型精度,转换和清洗得自己来。
2. labelme 格式拆解:json 里到底存了什么,为什么不能直接喂给分割网络
2.1 一个 json 文件的内部结构
labelme 的 json 不是图片,也不是 mask,它是一份“矢量标注记录”。每张图对应一个同名 json,里面最关键的字段是shapes和imageData。shapes是一个列表,每个元素代表一个多边形,包含label、points、shape_type、flags。points是像素坐标点对,shape_type通常是polygon。imageData在多数导出设置下是空的,因为图片单独存成 jpg。真正决定类别的是label字符串,比如"Crack"、"Rust"、"Spalling"。
{ "version": "5.5.0", "flags": {}, "shapes": [ { "label": "Crack", "points": [[120.5, 88.2], [180.3, 95.7], [176.1, 130.4]], "group_id": null, "shape_type": "polygon", "flags": {} } ], "imagePath": "wall_0001.jpg", "imageData": null, "imageHeight": 640, "imageWidth": 640 }逻辑说明:points的顺序决定多边形闭合方向,labelme 默认按点序连成闭合区域。imageHeight和imageWidth必须和 jpg 实际尺寸一致,否则转 mask 时会整体偏移。参数说明:version是 5.5.0,和资源说明一致;shape_type若出现rectangle或circle,转换脚本要单独处理,但这份数据集标注规则写的是 polygon,理论上全是多边形。
2.2 20 类标注的分布意味着什么
类别数量不是均匀的。Rust 有 14665 个多边形,Spalling 有 9849 个,Cavity 有 9222 个,而 object 只有 13 个,ACrack 只有 423 个,EJoint 只有 506 个。这种长尾分布直接决定训练策略:如果按普通交叉熵训 20 类,object 和 ACrack 大概率被淹没。常见做法是先把类别按频次分三档——高频(Rust、Spalling、Cavity)、中频(Crack、Weathering、Efflorescence、Graffiti、ExposedRebars、PEquipment、Wetspot、Drainage、Hollowareas、Bearing、JTape、Restformwork)、低频(Rockpocket、EJoint、ACrack、WConccor、object)。低频类要么过采样,要么在 loss 里给更高权重,要么先合并成“其他缺陷”再逐步细分。
import json, os from collections import Counter label_counter = Counter() json_dir = "path/to/jsons" for fname in os.listdir(json_dir): if not fname.endswith(".json"): continue with open(os.path.join(json_dir, fname), "r", encoding="utf-8") as f: data = json.load(f) for shape in data["shapes"]: label_counter[shape["label"]] += 1 for label, count in label_counter.most_common(): print(f"{label}: {count}")逻辑说明:这段脚本用来复核资源说明里的类别框数是否和实际 json 一致。参数说明:json_dir换成你解压后的 json 目录;如果某些 json 的shapes为空,说明该图可能只有背景,训练时要决定是否保留。跑完如果发现某个类别数量对不上,优先检查是否有 json 损坏或 label 拼写不一致,比如ExposedRebars和Exposedrebar会被当成两类。
2.3 为什么必须自己转 mask 或 yolo 格式
labelme 的 json 是矢量,分割网络要的是栅格 mask 或检测框。语义分割需要每张图一个单通道 png,像素值对应类别 id;实例分割需要 mask 加类别;yolo 格式需要归一化后的多边形点或框。资源说明里明确写了“json 数据集需自己转成 mask 或者 yolo 格式或者 coco 格式”,这不是偷懒,是因为不同框架对输入格式要求不同。常见做法是用 labelme 自带的labelme2voc.py或labelme2coco.py,但这两个脚本对中文路径和空 shapes 处理不够稳,我一般会自己写转换。
# 用 labelme 自带脚本转 VOC 格式,生成 mask png python labelme2voc.py data_annotated data_dataset_voc --labels labels.txt --noviz逻辑说明:data_annotated是放 jpg 和 json 的目录,data_dataset_voc是输出目录,labels.txt每行一个类别名,顺序决定 mask 像素值。参数说明:--noviz跳过可视化,省时间;如果报KeyError,多半是某个 json 的 label 不在 labels.txt 里,需要先补全类别列表。转完后SegmentationClass里是 png,SegmentationObject里是实例 mask,但这份数据集只有 polygon 没有 group_id,实例区分要靠连通域自己拆。
3. 从 json 到训练集:转换脚本、类别映射与 640×640 的边界处理
3.1 写一个可控的 json 转 mask 脚本
自带脚本能用,但遇到 20 类长尾和空标注时容易翻车。我一般用下面这个脚本,显式控制类别顺序、忽略空 shapes、并检查图片尺寸。
import json, os import numpy as np from PIL import Image, ImageDraw # 类别顺序必须固定,训练和推理要一致 CLASSES = [ "Graffiti", "Bearing", "Wetspot", "Rust", "Spalling", "Efflorescence", "Hollowareas", "Weathering", "Restformwork", "Cavity", "ExposedRebars", "EJoint", "Crack", "Drainage", "ACrack", "JTape", "Rockpocket", "PEquipment", "WConccor", "object" ] CLASS_TO_ID = {name: idx + 1 for idx, name in enumerate(CLASSES)} # 0 留给背景 def json_to_mask(json_path, jpg_path, out_path): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) img = Image.open(jpg_path) w, h = img.size mask = Image.new("L", (w, h), 0) draw = ImageDraw.Draw(mask) for shape in data["shapes"]: label = shape["label"] if label not in CLASS_TO_ID: continue points = [tuple(p) for p in shape["points"]] if len(points) < 3: continue draw.polygon(points, fill=CLASS_TO_ID[label]) mask.save(out_path) # 批量处理 src_dir = "path/to/data" out_dir = "path/to/masks" os.makedirs(out_dir, exist_ok=True) for fname in os.listdir(src_dir): if not fname.endswith(".json"): continue base = fname[:-5] json_path = os.path.join(src_dir, fname) jpg_path = os.path.join(src_dir, base + ".jpg") out_path = os.path.join(out_dir, base + ".png") if os.path.exists(jpg_path): json_to_mask(json_path, jpg_path, out_path)逻辑说明:CLASS_TO_ID从 1 开始,0 是背景,这是语义分割的通用约定。draw.polygon按点序填充,重叠区域后画的会覆盖先画的,如果两个缺陷重叠,需要决定优先级。参数说明:src_dir是 jpg 和 json 混放目录;out_dir输出 png mask;如果图片尺寸不是 640×640,mask 会按实际尺寸生成,训练时再统一 resize。
3.2 类别映射表与 id 对照
| 类别名 | 中文含义 | 建议 id | 频次档 |
|---|---|---|---|
| Graffiti | 涂鸦 | 1 | 中 |
| Bearing | 支座 | 2 | 中 |
| Wetspot | 潮湿斑迹 | 3 | 中 |
| Rust | 锈蚀 | 4 | 高 |
| Spalling | 剥落 | 5 | 高 |
| Efflorescence | 泛碱 | 6 | 中 |
| Hollowareas | 空洞区域 | 7 | 中 |
| Weathering | 风化 | 8 | 中 |
| Restformwork | 残留模板 | 9 | 中 |
| Cavity | 空腔 | 10 | 高 |
| ExposedRebars | 外露钢筋 | 11 | 中 |
| EJoint | 伸缩缝 | 12 | 低 |
| Crack | 裂缝 | 13 | 中 |
| Drainage | 排水 | 14 | 中 |
| ACrack | 活动裂缝 | 15 | 低 |
| JTape | 接头胶带 | 16 | 中 |
| Rockpocket | 岩窝 | 17 | 低 |
| PEquipment | 防护设备 | 18 | 中 |
| WConccor | 水侵蚀混凝土 | 19 | 低 |
| object | 物体 | 20 | 低 |
这张表建议直接存成labels.txt,每行一个类别名,顺序和 id 一致。训练时读这个文件生成类别权重,推理时按 id 映射回中文。注意object只有 13 个标注,如果做 20 类分割,它几乎学不到东西,常见做法是训练时先把它归到背景或“其他”,等模型稳定后再单独微调。
3.3 640×640 分辨率下的多边形边界坑
640×640 不算大,裂缝这种细长目标在 polygon 里可能只有几个像素宽。转 mask 时如果直接用draw.polygon,细裂缝可能因为点太少而断裂。我一般会在转换前做一次点密度检查:如果某个多边形的外接框面积小于 16 像素,或者点数少于 4,就标记为“可疑标注”,单独输出列表人工复核。另一个坑是坐标越界:labelme 里手动标注时可能把点拖到图片外,draw.polygon不会报错但会裁掉,导致 mask 边缘缺失。转换前加一句points = [(min(max(x,0),w-1), min(max(y,0),h-1)) for x,y in points]能挡掉大部分。
# 坐标裁剪与可疑标注记录 suspect = [] for shape in data["shapes"]: points = shape["points"] xs = [p[0] for p in points] ys = [p[1] for p in points] if max(xs) < 0 or min(xs) > w or max(ys) < 0 or min(ys) > h: suspect.append((fname, shape["label"], "out_of_bound")) continue if (max(xs)-min(xs)) * (max(ys)-min(ys)) < 16: suspect.append((fname, shape["label"], "too_small"))逻辑说明:这段代码不修改原 json,只做质检。参数说明:too_small阈值 16 是经验值,裂缝类可以放宽到 9,但再小就建议直接忽略,否则训练时噪声大于信号。
4. 避坑与排查:20 类长尾、空标注和格式转换的 5 个血泪经验
4.1 现象:训练 loss 不降,Rust 和 Spalling 的 IoU 很高,但 ACrack 和 object 几乎为 0
原因:类别频次差距太大,Rust 有 14665 个多边形,object 只有 13 个,普通交叉熵会被高频类主导。解决:在 loss 里用类别权重,权重按频次倒数开方,或者用 focal loss。更稳的做法是先训 10 类高频+中频,把 backbone 训稳,再解冻最后几层微调低频类。我一般会保留一个“其他缺陷”类,把 ACrack、EJoint、Rockpocket、WConccor、object 先合并,等模型能稳定分割裂缝和剥落后再拆开。
4.2 现象:转 mask 后某些图全黑,但 json 里明明有 shapes
原因:json 里的 label 拼写和labels.txt不一致,比如ExposedRebars写成Exposedrebar,或者WConccor写成WConccor带空格。解决:转换前先跑一遍全量 label 统计,和标准 20 类做差集。差集里的 label 要么修正 json,要么在脚本里做映射。注意 labelme 保存时不会自动 trim 空格,手动改过 json 的尤其容易出这个问题。
4.3 现象:mask 和原图对不上,整体偏移几个像素
原因:json 里的imageWidth、imageHeight和 jpg 实际尺寸不一致,或者 jpg 被外部工具重新保存过。解决:转换时不要信 json 里的尺寸,直接用Image.open(jpg).size。如果发现大量尺寸不一致,说明图片被批量 resize 过但 json 没同步,这种图建议直接剔除,否则训练时 resize 会引入额外偏移。
4.4 现象:用 labelme2coco.py 转 coco 格式时报polygon相关错误
原因:labelme 5.5.0 的 json 里shape_type可能是polygon,但某些版本导出时会出现rectangle或circle,coco 转换脚本对非 polygon 支持不完整。解决:先统一 shape_type。用脚本遍历所有 json,把rectangle的四个角转成 polygon 点序,circle按外接多边形近似。这份数据集标注规则写的是 polygon,但人工标注时可能误用矩形工具,跑一遍检查能省很多事。
4.5 现象:训练时显存爆了,但图片只有 640×640
原因:20 类语义分割如果直接输出 20 通道,加上 batch size 和数据增强,显存占用比想象中大。解决:先确认模型输出通道是 21(20 类+背景),不是 20。如果显存不够,优先降 batch size 到 4 或 2,用梯度累积补回来。另外,mask 用uint8存,不要用int64,否则 dataloader 会多吃 8 倍内存。我一般会在 Dataset 里把 mask 转成torch.long之前先确认它是uint8。
5. 进阶用法:用 640×640 小图做滑窗推理与类别权重验证
这份数据集图片是 640×640,但实际建筑巡检图往往更大。一个实用技巧是:用这 7820 张图训一个 640×640 的分割模型,推理时对高分辨率图做滑窗,窗口 640×640,重叠 128 像素,每个窗口单独推理后拼回全图。这样既能利用现有数据,又能覆盖大图。滑窗推理的关键是重叠区融合,常见做法是取最大概率或加权平均,我一般用高斯权重,中心高、边缘低,减少拼接缝。
import torch import torch.nn.functional as F def sliding_window_inference(model, image, window=640, stride=512, num_classes=21): # image: 1x3xHxW, 已归一化 _, _, H, W = image.shape output = torch.zeros(1, num_classes, H, W, device=image.device) count = torch.zeros(1, 1, H, W, device=image.device) weight = torch.ones(1, 1, window, window, device=image.device) # 高斯权重,边缘衰减 for i in range(window): for j in range(window): weight[0,0,i,j] = torch.exp(-((i-window/2)**2 + (j-window/2)**2) / (2*(window/4)**2)) for y in range(0, H, stride): for x in range(0, W, stride): y1, y2 = y, min(y+window, H) x1, x2 = x, min(x+window, W) patch = image[:, :, y1:y2, x1:x2] if patch.shape[2] < window or patch.shape[3] < window: patch = F.pad(patch, (0, window-patch.shape[3], 0, window-patch.shape[2])) with torch.no_grad(): pred = model(patch) pred = F.softmax(pred, dim=1) output[:, :, y1:y2, x1:x2] += pred[:, :, :y2-y1, :x2-x1] * weight[:, :, :y2-y1, :x2-x1] count[:, :, y1:y2, x1:x2] += weight[:, :, :y2-y1, :x2-x1] return output / count.clamp(min=1e-6)逻辑说明:stride=512表示重叠 128 像素,weight是高斯核,中心权重高,边缘低,拼回时不会出现明显接缝。参数说明:window固定 640,和训练分辨率一致;num_classes=21对应 20 类加背景;如果显存允许,可以一次跑多个窗口,但要注意output和count的显存占用是原图的num_classes倍,大图建议分块处理。
另一个进阶点是类别权重验证。训练前先用上面统计的频次算一组权重,训练后再用混淆矩阵看哪些类被误判。我一般会固定一个验证集,每训 5 个 epoch 跑一次 per-class IoU,如果某个低频类连续 3 次为 0,就把它合并到相近类,比如 ACrack 合并到 Crack,Rockpocket 合并到 Cavity。这样比硬训 20 类更实际。
从那以后我每次拿到 labelme 格式的数据集,都先跑一遍 label 统计、尺寸检查和空 shapes 检查,再开始写转换脚本。这三步花不了十分钟,但能挡掉后面几天的玄学调参。希望帮到你。
本文还有配套的精品资源,点击获取