
简介建筑墙壁损伤缺陷分割数据集的labelme格式说明文档以docx压缩包提供共1个文件、大小2.1MB面向计算机视觉与建筑安全检测领域的研究者、工程师及学生。该文档系统整理了7820张图片、20个缺陷类别的完整标注统计包括涂鸦、支座、潮湿点、锈蚀、剥落、泛碱、空洞区域、风化、残留模板、空腔、外露钢筋、伸缩缝、裂缝、排水、活动裂缝、接头胶带、岩窝、防护设备、水侵蚀混凝土和物体等并逐一给出每类的多边形框数量。图片规格为640x640对应json标注采用labelme5.5.0格式文档同时说明了如何查看编辑json以及自行转换为mask、yolo或coco格式用于语义分割或实例分割的方法。读者可据此快速评估该数据集是否适合自身的分割任务需求为后续模型训练与建筑健康监测应用提供准确的数据准备参考。已有101人学习适合需要高效筛选建筑墙面缺陷数据集的用户。1. 建筑墙壁损伤分割数据集7820 张 labelme 标注到底能干什么做建筑表观缺陷检测的人都知道数据集比模型金贵。这个名为 firc-dataset 的建筑墙壁损伤缺陷分割数据集一口气给了 7820 张 640x640 的 jpg 原图外加 7820 个 labelme 格式的 json 标注文件覆盖 20 个类别。注意它不直接给 mask也不给 COCO 或 YOLO 格式标注是以 polygon 多边形存进 json 的——所以它不是「拿来即训」的开箱数据集而是需要先过一遍格式转换关。但反过来看polygon 原始标注比编译好的 mask 灵活得多你可以自己决定哪些类别合并、哪些剔除再转成你训练框架需要的任何格式。这篇文章就围绕「labelme 格式怎么读、怎么转、怎么避开转换里的坑」展开适合要做语义分割或实例分割训练、拿建筑缺陷做算法落地的工程师也适合需要整理自有标注数据的同学。先把标注文件读透后面转换和训练才不走弯路。2. 读透 labelme 标注JSON 结构、坐标口径与 20 类数量分布2.1 一段 JSON 拆开看shapes 数组与 polygon 坐标labelme 本质上是把标注内容序列化成一个 JSON 字典任何语言都能直接读。拿这个数据集里任意一个 json 文件打开最核心的字段长得像下面这样{ version: 5.5.0, flags: {}, shapes: [ { label: Crack, points: [ [108.5, 87.0], [114.0, 91.5], [121.0, 96.0], [132.5, 88.0] ], group_id: null, shape_type: polygon, flags: {} }, { label: Spalling, points: [ [200.0, 180.0], [210.0, 178.5], [220.0, 190.0], [215.0, 205.0], [200.0, 195.0] ], group_id: null, shape_type: polygon, flags: {} } ], imagePath: IMG_20240701_001.jpg, imageData: null, imageHeight: 640, imageWidth: 640 }这里最关键的字段是shapes它是一个列表列表里的每个元素对应一个标注实例。每个实例的label是类别字符串points是多边形的顶点坐标序列每个点就是[x, y]数组存储为 float 类型而不是 int。shape_type标注为polygon表示这是闭合多边形——这也是整个数据集的统一标注规则。imageHeight和imageWidth是 640x640和原图分辨率一致转换格式时可以直接用这两个字段做归一化不要写死防止个别文件尺寸有出入。一个容易忽略的细节是imageData。如果这个字段有值里面的字符串就是整张图的 base64 编码通常体积很大如果为 null就需要靠imagePath配合原图 JPG 去读取。这个数据集的说明里只提供 jpg 和 json但我拆过太多 labelme 项目很多旧文件会带着 imageData一套下来白白占掉几十 MB。看文件时先检查这个字段处理起来更稳。2.2 20 个类别的数量分布缺陷大头与边缘类别数据集说明里给了每个类别的标注框数量这个分布直接决定了你后面做类别清洗和样本均衡时的策略。我把它们整理成一张表类别名标注数量说明Rust14665锈蚀缺陷大头Spalling9849剥落第二个大头Cavity9222空腔Weathering4621风化Efflorescence3956泛碱Crack3612裂缝Graffiti2197涂鸦ExposedRebars1993外露钢筋PEquipment1892防护设备Wetspot1657潮湿斑迹Drainage1649排水Hollowareas1538空洞区域Bearing1201支座JTape1076接头胶带Restformwork1019残留模板Rockpocket616岩窝EJoint506伸缩缝WConccor407水侵蚀混凝土ACrack423活动裂缝object13杂物几乎可忽略读这张表要抓住三点。第一Rust、Spalling、Cavity 三个类别占了全部标注的一大半模型学到最后大概率是被这几个类主导小类别容易被压掉。第二object只有 13 个标注基本是标注过程中随手框的杂物转换前建议直接剔除不然它会成为训练集里一个既难学又无意义的类别。第三像 Bearing、Drainage、PEquipment 这类其实是建筑构件而不是损伤如果你只做缺陷检测这几类要么从类别表里删除要么单独作为结构件上下文保留取决于你的任务定义这点在第 4 章展开讲。2.3 用脚本统计全量标注类别计数无需打开 labelme拿到 7820 个 json 后不要一个个拖进 labelme 看写个几行脚本做全量统计更靠谱。下面的脚本统计每个类别出现的标注次数以及这个类别出现在多少张图里——这两个口径不同前者是标注框总数后者是图数量如果某类标注很多但图很少说明单张图里这类目标密集import json import glob from collections import Counter, defaultdict data_dir D:/wall_defect/jsons json_files glob.glob(data_dir /**/*.json, recursiveTrue) label_count Counter() # 标注实例数 label_image_count defaultdict(int) # 出现该类别的图片数 for f in json_files: with open(f, encodingutf-8) as fp: data json.load(fp) seen_labels set() for shape in data[shapes]: label shape[label] label_count[label] 1 seen_labels.add(label) for label in seen_labels: label_image_count[label] 1 for label, cnt in label_count.most_common(): print(f{label}: {cnt} 个实例, 分布在 {label_image_count[label]} 张图)这段脚本逻辑很直白glob递归拿到所有 json 文件逐个读取遍历shapes计数。注意我用了一个seen_labels集合来避免同一张图的同类别重复统计图片数。跑出来的结果应该和数据集的官方数量对齐比如 Rust 应该是 14665。如果数字对不上大概率是你拿到的 json 目录里混了别的东西或者有的 json 是损坏的空文件。这类统计脚本我一般会保留下来做数据集的体检工具每次拿到新标注数据先跑一遍确认类别没有缺漏、数量没有异常再进入格式转换环节。3. 格式转换三条路线labelme JSON 转 mask、COCO 与 YOLO 分割3.1 转成 maskcv2.fillPoly 与 labelme.utils 两条路这个数据集不包含 mask所以要做语义分割训练第一步就是把 polygon 转成单通道的整数矩阵。我的建议是别直接用 labelme 官方脚本自己写一个带过滤逻辑的转换器因为真实数据里 shape_type 可能不全是 polygon还可能有 rectangle 混进来。常见做法是逐类别填充import json import cv2 import numpy as np def labelme_to_mask(json_path, label2id, img_h640, img_w640): with open(json_path, encodingutf-8) as fp: data json.load(fp) mask np.zeros((img_h, img_w), dtypenp.uint8) for shape in data[shapes]: # 过滤掉非多边形标注防止 fillPoly 报错 if shape[shape_type] ! polygon: continue label shape[label] if label not in label2id: continue # points 是 float 列表fillPoly 要求 int32 pts np.array(shape[points], dtypenp.int32) pts np.clip(pts, [0, 0], [img_w - 1, img_h - 1]) cls_id label2id[label] cv2.fillPoly(mask, [pts], colorint(cls_id)) return mask # 类别映射背景固定为 0类别从 1 开始 class_names [Rust, Spalling, Cavity, Crack] label2id {name: i 1 for i, name in enumerate(class_names)} mask labelme_to_mask(annotations/IMG_0001.json, label2id) np.save(masks/IMG_0001.npy, mask)代码里有三个关键参数要理解。第一是np.clip它对坐标做了边界裁剪把多边形顶点限制在 0 到 639 之间。为什么需要它因为画 polygon 的时候鼠标一个抖动就可能点在画布外labelme 会照单全收如果你不裁剪fillPoly画出来的多边形依然正常但矩阵赋值时不会越界——实际上更隐蔽的问题是归一化时坐标除以 640 会得到大于 1 的值后面 YOLO 训练直接报错。第二是colorint(cls_id)fillPoly 的 color 必须是整数如果你用的是 uint8 矩阵还要确保类别数不超过 255。第三是过滤shape_type这个数据集的规则是 polygon但你从别处合并的标注里可能混入矩形或圆不过滤就会把 rectangle 的标注点硬当 polygon 用画出来的形状完全错。如果不想自己写也可以用 labelme 库自带的shapes_to_label它内部就是遍历 shapes 逐个填充。但它的缺点是不过滤 shape_type并且要求label_name_to_value映射必须从 1 开始背景为 0。自己写多花十分钟换来的是对数据流转的完全掌控这笔账值得算。3.2 转 COCO 格式categories、images、annotations 三段组装COCO 格式是实例分割训练最通用的中间格式mmdetection、Detectron2、Torchvision 都能直接吃。它的 JSON 由三部分组成categories是类别表images是图片元信息annotations是每个标注实例的多边形、包围框和面积。组装逻辑如下import json import glob import numpy as np def polygon_area(pts): # 鞋带公式计算多边形面积 xs [p[0] for p in pts] ys [p[1] for p in pts] return 0.5 * abs( sum(xs[i] * ys[(i 1) % len(xs)] - xs[(i 1) % len(xs)] * ys[i] for i in range(len(xs))) ) json_files sorted(glob.glob(D:/wall_defect/jsons/*.json)) class_names [Rust, Spalling, Cavity, Crack] label2id {name: i 1 for i, name in enumerate(class_names)} coco { images: [], annotations: [], categories: [ {id: i 1, name: name, supercategory: defect} for i, name in enumerate(class_names) ], } ann_id 1 for img_id, json_path in enumerate(json_files): with open(json_path, encodingutf-8) as fp: data json.load(fp) h, w data[imageHeight], data[imageWidth] coco[images].append({ id: img_id, file_name: data[imagePath], width: w, height: h, }) for shape in data[shapes]: label shape[label] if label not in label2id: continue pts shape[points] # COCO segmentation 要求扁平列表长度是 2N seg_flat [float(coord) for point in pts for coord in point] xs [p[0] for p in pts] ys [p[1] for p in pts] x_min, y_min min(xs), min(ys) bbox [x_min, y_min, max(xs) - x_min, max(ys) - y_min] coco[annotations].append({ id: ann_id, image_id: img_id, category_id: label2id[label], segmentation: [seg_flat], area: polygon_area(pts), bbox: bbox, iscrowd: 0, }) ann_id 1 with open(wall_defect_coco.json, w, encodingutf-8) as fp: json.dump(coco, fp)这里有两个最常见的坑。一个是segmentation字段COCO 要求的是扁平化的坐标列表[x1, y1, x2, y2, ...]不是[[x1, y1], [x2, y2]]这种嵌套结构我见过不少人在这一步把格式写错导致后续训练时报 segmentation 长度不能整除 2 的错误。另一个是bbox必须是最小外接矩形的[x, y, w, h]用min(xs)和max(xs)来算不要想当然地取多边形的某个顶点当左上角。area用鞋带公式算几何面积即可COCO 的评估工具只关心它和 bbox 面积的比例关系不用过于精确。如果你的目标是把模型结果提交到 COCO 评测榜单面积误差会影响 AP 计算所以用几何面积而不是 bbox 面积。3.3 转 YOLO 分割格式多边形归一化与类别 ID 映射YOLOv8-seg 训练时的标签格式是每个图片一个 txt 文件每一行对应一个标注实例第一个数是类别 ID从 0 开始后面是所有多边形顶点坐标归一化到 0~1 之间。这个用 python 写也就几十行import json import glob import os import numpy as np def labelme_to_yolo(json_path, label2id, out_txt_path): with open(json_path, encodingutf-8) as fp: data json.load(fp) img_w data[imageWidth] img_h data[imageHeight] lines [] for shape in data[shapes]: if shape[shape_type] ! polygon: continue label shape[label] if label not in label2id: continue cls_id label2id[label] pts np.array(shape[points], dtypenp.float32) pts[:, 0] np.clip(pts[:, 0], 0, img_w - 1) / img_w pts[:, 1] np.clip(pts[:, 1], 0, img_h - 1) / img_h coords_str .join(f{p[0]:.6f} {p[1]:.6f} for p in pts) lines.append(f{cls_id} {coords_str}) with open(out_txt_path, w, encodingutf-8) as fp: fp.write(\n.join(lines)) json_files glob.glob(D:/wall_defect/jsons/*.json) # 注意 YOLO 类别 ID 从 0 开始和 COCO 的从 1 开始不同 class_names [Rust, Spalling, Cavity, Crack] label2id {name: i for i, name in enumerate(class_names)} for json_path in json_files: out_path json_path.replace(jsons, labels).replace(.json, .txt) labelme_to_yolo(json_path, label2id, out_path)这段代码里最值得说透的是归一化的细节。YOLO 要求坐标严格在 0~1 之间直接x / img_w有隐患——如果原坐标是 640.5归一化出来就是 1.00078YOLO 训练时读取标签不会立刻报错但 loss 计算会出奇怪的结果甚至 mAP 直接崩掉。所以我先clip再除保证所有坐标都落在合法区间。另外类别 ID 的口径问题很容易翻车COCO 的category_id从 1 开始YOLO 的 class ID 从 0 开始如果你复用同一份label2id映射表转出来的 YOLO 标签所有类别都会偏移一位。我一般会在工程目录里单独放一个classes.txtCOCO 和 YOLO 各自从它派生映射表避免两套逻辑互相污染。还要提醒的是一张图里同一个类别可能有多个实例比如一面墙上有四五条独立裂缝这时 txt 里就有多行 class ID 相同的记录YOLO 分割训练是支持这种多实例写法的不需要合并也不要试图把同一个类别的多个 polygon 拼成一个。4. 拿去训分割模型之前类别清洗、不均衡处理与 YOLOv8-seg 配置4.1 先做类别清洗把非缺陷类合并或剔除转换格式之前先冷静坐下来想清楚这 20 个类别里哪些真正是你的训练目标。这个数据集的类别定义里有不少不是墙壁损伤的东西Bearing 是支座、Drainage 是排水、PEquipment 是防护设备它们出现在墙面上很正常但如果你的模型任务定位是损伤缺陷检测把这些构件类别混进训练目标会让模型把注意力分到和缺陷无关的结构件上。常见做法是只保留损伤语义的类别。我的建议是这样清洗Crack 和 ACrack 合并成 Crack因为活动裂缝本质也是裂缝标注者可能根据现场裂缝是否在发展做了区分但模型在 640x640 上很难学出这个微妙差异合并能大幅增加裂缝样本量object 直接删除13 个标注连作为背景都嫌脏Graffiti 涂鸦算不算缺陷取决于项目需求如果是城市建筑外观检测它要保留如果是结构安全检测它反而是噪声Wetspot 潮湿斑迹和 WConccor 水侵蚀混凝土在视觉上有重叠建议合并或者只保留 Wetspot。清洗后我通常得到这样 12 类清洗后类别原类别映射处理后大概标注数RustRust14665SpallingSpalling9849CavityCavity Hollowareas10760EfflorescenceEfflorescence3956CrackCrack ACrack4035WeatheringWeathering4621GraffitiGraffiti2197ExposedRebarsExposedRebars1993WetspotWetspot WConccor2064RestformworkRestformwork JTape2095RockpocketRockpocket616EJointEJoint506这个清洗动作必须在转 mask 或 YOLO 之前做把映射表改掉就行千万别等到训练完发现某些类别的预测结果毫无业务意义再回头重新标数据。标注是多边形合并类别在代码层面就是映射表里多指一个 ID 的事成本极低效果却直接决定模型训练完能不能用。4.2 类别不均衡怎么压Rust 14665 对 ACrack 423这个数据集的不均衡是天然的Rust 14665 个标注ACrack 只有 423 个差了 34 倍多。如果不处理模型对少样本类别的召回会惨不忍睹尤其是小目标裂缝这类本来就难分割的对象。我先给一个简单的计算各类别损失权重的办法后面再讲配合数据增强的手段。import numpy as np counts { Rust: 14665, Spalling: 9849, Cavity: 9222, Crack: 4035, Efflorescence: 3956, # ... 按清洗后的类别统计 } total sum(counts.values()) num_classes len(counts) # 方案median frequency balancing中位数频率均衡 median_freq np.median(list(counts.values())) weights {k: median_freq / v for k, v in counts.items()} # 截断权重防止个别类别权重被拉得过大 weights {k: min(max(v, 0.5), 10.0) for k, v in weights.items()} for k, v in sorted(weights.items(), keylambda x: x[1], reverseTrue): print(f{k}: {v:.3f})这段代码背后是语义分割里常用的中位数频率均衡核心思路是用中位数除以每个类别的标注数标注越少的类别权重越高。截断到 0.5~10 是为了防止权重极端化——如果某个类别只有 13 个标注权重算出来会到几千训练时数值直接不稳定所以必须限幅。这个权重数组可以传进你的 loss 函数比如BCEWithLogitsLoss(pos_weight...)或者交叉熵的class_weight。数据增强层面我最推荐的是 Mosaic 和 Copy-Paste 的组合。Mosaic 会把四张图拼成一张变相提高了小类别出现在同一 batch 里的概率Copy-Paste 则是把稀少类别的多边形从原图裁出来贴到别的图上。对裂缝这种细长目标我还会加一个轻度旋转和随机擦除但擦除面积不要超过多边形面积的 20%不然裂缝断成两截反而学出错误语义。另外不要对整图做随机缩放到 416x416 这类操作——640 分辨率本来就紧张再降裂缝这种几个像素宽的目标基本就没了。4.3 组装 YOLOv8-seg 训练配置把数据划分成 train/val 后YOLOv8 分割训练需要一份简单的 yaml 和一条命令。数据目录结构我一般这么摆D:/wall_defect/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── wall_defect.yamlyaml 内容如下path: D:/wall_defect train: images/train val: images/val names: 0: Rust 1: Spalling 2: Cavity 3: Crack 4: Efflorescence 5: Weathering 6: Graffiti 7: ExposedRebars 8: Wetspot 9: Restformwork 10: Rockpocket 11: EJoint注意names的顺序必须和你生成 YOLO 标签时label2id的顺序完全一致否则训练时类别名和标签 ID 对不上验证集的混淆矩阵会乱掉。训练命令我用的是最基本的形态yolo tasksegment modetrain modelyolov8m-seg.pt \ datawall_defect.yaml \ epochs150 imgsz640 batch8 device0参数含义逐一说tasksegment指分割任务modelyolov8m-seg.pt是中型分割预训练权重如果显存只有 6G 可以换yolov8n-seg.pt如果你要做小裂缝检测想保留更多细节可以上yolov8l-seg.ptimgsz640和数据集原图分辨率一致这是最稳的配置batch8是一张卡上的批大小显存不够就降到 4但建议配合梯度累积保持等效 batchepochs150对 7820 张图的中型数据集是够的我一般观察 val 的 mask mAP50-95 在哪个 epoch 饱和再决定是否提前停。这个数据集只提供标注不保证任何预训练权重精度所以不要指望加载某个权重直接出效果老老实实完整训练流程跑一遍。5. 避坑手册从 labelme 安装到格式转换的五个翻车点5.1 labelme 5.5.0 装不上pyqt5-sip 版本冲突现象pip install labelme在 Python 3.9 以上环境经常报错提示ImportError: cannot import name sip from PyQt5或者pyqt5-sip依赖解析失败安装直接被中止。原因labelme 5.5.0 的依赖里对 PyQt5 的版本没有做严格上界限定而较新的 PyQt5 5.15.11 配套的 PyQt5-sip 在部分 Python 版本下导入方式变了导致 labelme 启动时找不到sip模块。这属于环境依赖的经典问题不是代码 bug。解决分两步走。先单独装一个兼容版本的 PyQt5-sippip install pyqt5-sip12然后再装 labelme。如果这一步仍然翻车就用国内镜像源装 PyQt5 指定版本pip install PyQt55.15.10 PyQt5-sip12.13.0 pip install labelme5.5.0 -i https://pypi.tuna.tsinghua.edu.cn/simple这也是我为什么建议用 labelme 纯做标注预览和二次编辑格式转换的脚本完全脱离 labelme 依赖——只读 json 文件不依赖任何 GUI 库装了能看不装也能跑。搜索热词里赫然就有labelme 无法安装 pyqt5和labelme 5.8.3说明这个问题不是个例真正干活的环节别和 GUI 绑定太深。5.2 json 里混有超大 imageData 字段现象数据集文件夹里 json 文件体积从几十 KB 到几十 MB 不等批量读取时磁盘 IO 明显变慢转换脚本跑一次要几十分钟。原因labelme 保存时如果没加--nodata参数会把原图 base64 编码后塞进imageData字段。一张 640x640 的 jpg 编码成 base64 后有几百 KB7820 个文件加起来就是几个 GB 级别的无效占用。这个数据集说明里强调只包含 jpg 和 json但我实测过很多 labelme 导出的数据老的 json 几乎都带这玩意。解决批量处理之前写个小脚本把imageData置空重写一遍能省一半以上空间import json import glob for f in glob.glob(D:/wall_defect/jsons/*.json): with open(f, encodingutf-8) as fp: data json.load(fp) if data.get(imageData): data[imageData] None with open(f, w, encodingutf-8) as fp: json.dump(data, fp) print(done)这个操作不丢任何标注信息因为读取图片始终走imagePath。从那以后我拿到任何 labelme 数据集都先跑一遍这个清理再开始格式转换读取速度快一个量级。5.3 polygon 顶点越界导致归一化结果大于 1现象YOLO 分割训练爆出Exception: line ... is not in correct format或invalid values in label file打开 txt 一看某些坐标是 1.0008 这样明显不合理的值。原因标注时多边形顶点可能落在画布外labelme 并不会帮你截断。比如在画布边缘点了一下x 坐标记录为 641 或 642除以 640 之后就大于 1。YOLO 对坐标区间严格校验有的版本直接终止训练。解决所有 polygon 坐标做先裁剪、再归一化这个操作被很多人忽略总觉得标注工具会管好边界实际上工具只管画图不管合法性。我给出的那段转 YOLO 代码里用np.clip(pts, 0, img_w-1)就是为了根治这个问题。注意裁剪时要保证多边形不退化如果整块 polygon 都在画布外裁剪后所有点重合这种情况直接丢弃这个形状。5.4 类别映射表顺序漂移导致标签张冠李戴现象训练完看混淆矩阵发现 Cark 的预测结果大量混进 Rust 的 GT 区域但两个类别在图上长得完全不像最初怀疑是模型没收敛换个随机种子重训依旧如此。原因这是个隐蔽的代码级错误。很多人喜欢用sorted(class_names)给类别排序生成映射表今天写转换脚本时这么干明天写另一份脚本时又对同一个列表排序如果两个脚本的class_names顺序不同——比如一个来自数据集说明一个来自自己对json文件的set去重——同一字符串被赋予了不同 ID转换出来的两份标签互相冲突自然张冠李戴。解决维护一份固定的classes.txt永远只从这个文件读类别顺序转换脚本和模型训练配置都引用它不允许在代码里用sorted()或set()现场生成类别表。CRITICAL 的是任何脚本之间共享映射表而不是各自推导。血泪经验这个坑的排查成本远高于修复成本因为现象看起来像模型问题实际是数据标签问题。5.5 转换后 mask 全是黑的或只有两类值现象cv2.imshow看检测图mask 矩阵里除了 0 就是 1所有类别挤在同一个像素值里多类别分割完全失效。原因两种情况。第一种是fillPoly的color参数用了字符串类别名而不是整数 IDnumpy 直接把字符串强转成 int 时报错或者赋成同一个值第二种是label2id映射从 0 开始而背景也是 0于是背景和第一个类别共用同一个像素值二值化后只剩 0 和 1 两类。解决强制规定背景是 0、类别 ID 从 1 开始并在一开始打印验证# 检查 mask 中出现了多少个不同的像素值 unique_vals np.unique(mask) print(fmask 像素类别数: {len(unique_vals)}) assert len(unique_vals) 21 # 20 类 背景 assert set(unique_vals) set(label2id.values())顺手写进转换脚本里每次转完一个文件就校验一次。像素类别数不对立刻停下查映射表推断逻辑不要等到跑完 7820 张再回头重来。6. 转换结果验证把 mask 叠回原图的十五分钟检查流程格式转换完成后不要直接开训先做一轮可视化验证这步能拦下绝大多数不知不觉的格式错误。方法很简单把 polyon 转出的 mask 叠加到原图上生成一张肉眼可判断的检查图人工扫一眼就知道标注有没有错位、有没有丢失。import cv2 import numpy as np img cv2.imread(D:/wall_defect/images/IMG_0001.jpg) mask np.load(D:/wall_defect/masks/IMG_0001.npy) # 随机给每个类别分配一个固定颜色便于辨认 np.random.seed(0) num_cls 20 colors np.random.randint(0, 255, (num_cls 1, 3), dtypenp.uint8) overlay img.copy() for cls_id in range(1, num_cls 1): overlay[mask cls_id] colors[cls_id] # 半透明叠加比直接覆盖更直观 weighted cv2.addWeighted(img, 0.7, overlay, 0.3, 0) check_img np.hstack([img, weighted]) cv2.imwrite(check_IMG_0001.jpg, check_img)检查时我重点看三类问题。第一看边缘贴合mask 描出来的形状应该和原图里的锈迹、剥落区域的视觉边缘基本一致如果大面积偏移说明pts解析或裁剪有问题第二看细碎噪声裂缝类别的 mask 应该是一条连续或断续的细线如果变成大面积色块说明 fillPoly 时把[x, y]顺序写反成了[y, x]导致多边形膨胀变形——这是典型的坐标轴混淆第三看类别色块分布如果一张图里所有缺陷都变成了同一个颜色说明类别 ID 映射有问题回到classes.txt检查顺序。我一般会写一个随机抽样脚本从 7820 张里抽出 50 张生成拼接好的检查图一次扫完。这套流程做多了就有手感了现在我拿到任何格式转换任务都强制走一遍先清imageData再统计类别分布转完抽样叠加可视化最后跑一个 5 epoch 的 mini 实验验证标签能不能被模型收敛。从那以后我在训练阶段碰到的绝大多数模型不收敛最后都被排查成数据标签问题而不是模型问题。做完格式转换和验证这个数据集才算真正变成能直接训练的资源建议下载后先按第 2 章的脚本跑一遍统计建立对数据分布的预期再开始转换希望帮到你。本文还有配套的精品资源点击获取