简介面向需要训练盲道、人行道缺陷检测模型的算法工程师与科研人员这份YOLO格式数据集可直接用于目标检测项目解决缺陷样本稀缺、标注成本高的问题。类别为defect单类缺陷标注训练集约3400张图片、验证集约750张图片均配有对应txt标签文件数据按YOLOV5文件夹结构保存无需额外转换即可接入训练流程。压缩包共2000个文件以1999个txt标签文件为主体另附1个Python可视化脚本总大小232.39MB。随包附带的show.py脚本支持随机传入图片绘制边界框并自动保存便于快速直观核验标注质量与检测效果。目前已有1154人学习下载适合刚接触YOLO的初学者快速上手也适合需要获取缺陷检测基准数据的实战开发者。1. 盲道、人行道缺陷检测的瓶颈为什么数据集划分和class文件比模型结构更值得先管做盲道、人行道缺陷图像目标检测的人最初往往把心思放在“哪个YOLO版本更强”上真正上手才发现卡住进度的几乎全是数据问题目标小、背景杂、类别边界模糊加上标注格式不统一模型训练要么直接不收敛要么推理时错漏百出。标题里这份“包含划分好的数据集、类别class文件、数据可视化脚本”的方案本质是把“能直接开训”这件事标准化——数据集拿到手classes.txt 顺序对不对、划分比例是否合理、标签框是否贴准目标都能靠脚本快速确认。这篇笔记面向正准备把盲道缺陷识别落地的从业者按数据组织、格式转换、训练前检查、常见踩坑和训练后验证的顺序把这条链路完整走一遍。2. 一份能直接开训的YOLO盲道缺陷数据集目录结构、class文件与类别定义2.1 交付物里该有的四类文件images、labels、classes.txt与data.yaml我拿到任何“划分好的YOLO数据集”第一件事就是看目录结构。规范的交付物一定长成下面这样而不是把几百张图片和一堆txt散落在同一个文件夹里tactile_defect_dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ ├── classes.txt ├── data.yaml └── visualize_dataset.pyimages 与 labels 按 train/val/test 三个子集严格对应图片和标签文件同名不同后缀这是训练能跑起来的前提。classes.txt 就是标题里说的“类别class文件”它只有一列一行一个类名行号从 0 开始对应标注 ID比如第 1 行是 0 号类、第 2 行是 1 号类。data.yaml 里 names 的顺序必须和 classes.txt 完全一致否则训练出来的模型在推理时会把类别名张冠李戴。文件不多但每一份都牵一发动全身。labels 里的每个 txt 都要验证内部格式是否满足 YOLO 的要求每行五个值分别是类别ID、归一化中心x、归一化中心y、归一化宽、归一化高。我见过不止一次有人把 VOC 的“左上角右下角”坐标直接塞进去训练时 loss 直接飞到几十那种情况就是先怀疑数据格式而不是怀疑模型。2.2 缺陷类别怎么定从盲道砖破损到占道异物命名规则与ID一致性类别定义是这个项目里最需要“先小人后君子”的环节。盲道、人行道上的缺陷形态差异很大如果类别设得过细比如把“共享单车占压”和“轿车占压”拆成两个类很容易出现某类只有几十个样本模型根本学不出来如果设得太粗比如把所有路面问题都叫“破损”标注员之间又会产生大量分歧。我一般建议先按这五类起步后面根据实际场景再扩类别IDclasses.txt 中的类名标注标准典型场景0damaged_tactile盲道砖本身出现破碎、缺失、明显拱起盲道砖缺角、整块碎裂1cracked_sidewalk人行道面层出现线状裂缝长度超过30cm水泥或砖面裂缝2occupied_tactile盲道被车辆、杂物、堆物占压失去引导功能共享单车、电瓶车压盲道3sunken_cover井盖或检修盖下沉、凸起、缺失井盖高低差、无盖黑洞4raised_edge路缘石破损、错台、严重松动路缘石崩角、断开命名规则上有一条硬标准不要用中文不要带空格。YOLO 系列对 class 名以空格分隔解析命名里有空格会在训练和推理时报各种莫名其妙的错中文在部分可视化工具里会乱码排查起来很痛苦。另外每个类之间必须互斥。比如一块盲道砖既有裂缝又缺了一块到底标 cracked 还是 damaged我会在标注规范里定一条判断优先级如果目标范围内缺失面积超过 20%标为 damaged_tactile否则标为 cracked_sidewalk。把这条规则写进标注说明里比标注员自行揣摩靠谱得多。2.3 用脚本核对划分比例与类别均衡别等训练完了才发现只有一张图有裂缝数据拿到手后不要急着训练先写一段脚本把家底盘清楚。下面这个脚本会统计每个子集的图片数、标签数以及每个类别的实例数能直接暴露“某个类只出现在 train 而 val 里一个都没有”这类灾难性问题import os from collections import Counter def inspect_dataset(rootdataset): classes open(os.path.join(root, classes.txt), encodingutf-8).read().splitlines() print(类别列表:, classes) for split in [train, val, test]: img_dir os.path.join(root, images, split) lbl_dir os.path.join(root, labels, split) if not os.path.isdir(img_dir): print(f[警告] {split} 缺少图片目录) continue imgs [f for f in os.listdir(img_dir) if f.lower().endswith((.jpg, .jpeg, .png))] txts [f for f in os.listdir(lbl_dir) if f.endswith(.txt)] counter Counter() empty_txt 0 for t in txts: full os.path.join(lbl_dir, t) with open(full, encodingutf-8) as f: lines [ln.strip() for ln in f if ln.strip()] if not lines: empty_txt 1 for ln in lines: cls_id int(ln.split()[0]) if 0 cls_id len(classes): counter[classes[cls_id]] 1 else: print(f[错误] {t} 中的类别ID {cls_id} 超出范围) print(f {split} ) print(f图片数: {len(imgs)}, 标签文件数: {len(txts)}, 空标签数: {empty_txt}) print(f各类别实例数: {dict(counter)}) # 检查图片和标签是否一一对应 img_base {os.path.splitext(f)[0] for f in imgs} txt_base {os.path.splitext(f)[0] for f in txts} only_img img_base - txt_base only_txt txt_base - img_base if only_img: print(f[警告] {len(only_img)} 张图片没有对应标签例如: {list(only_img)[:3]}) if only_txt: print(f[警告] {len(only_txt)} 个标签文件没有对应图片例如: {list(only_txt)[:3]}) inspect_dataset()脚本逻辑很简单但很实用先读 classes.txt 构建合法类别列表然后分别统计每个子集的图片数、标签文件数、空标签数量以及每个类别的实例数最后做一次同名文件集合差集检查。输出里出现“图片没有对应标签”或“标签没有对应图片”时不要抱着侥幸心态跳过这类错位数据一旦进入训练轻则 loss 异常重则让部分样本在加载时报错中断。空标签文件也要保留并单独处理有些版本的 YOLO 会把空文件视为背景样本有些则直接报错统一规则后能少很多噩梦。3. 把自采图像整理成YOLO格式标注、转换与划分脚本3.1 目标检测常用标注工具怎么选盲道缺陷适合用哪个自己采集了盲道和人行道的图片后第一步是打标注。目标检测常用标注工具我基本都试过按出图格式可以分成三类LabelImg 直接存 VOC XML 或 YOLO txtLabelme 存 JSON支持多边形X-AnyLabeling 这类新工具支持半自动标注和多种格式导出。对于盲道缺陷这类目标我的选型建议是裂缝和破损用多边形标注工具因为裂缝是长条形且方向任意用矩形框会包进大量背景盲道砖破损边界相对方正直接用矩形框效率更高。但要注意一个现实约束标题里既然指向的是常规 YOLO 目标检测最终标签一定是轴对齐矩形框。多边形标注导出的 JSON 最终还是要转成“中心点宽高”的 txt转的时候如果直接取多边形的最小外接矩形斜向裂缝的框会包含大量非目标区域这会让模型学到“背景裂缝”的组合特征。所以如果你手里已经有很多 Labelme 的 JSON转换时对长条目标要谨慎必要的时候要回到原图手工调整矩形框。这是很多人转换完才发现精度上不去的隐藏原因。3.2 从VOC XML转换到YOLO txt踩过坐标归一化的坑才写这个脚本无论用哪个工具清洗数据时最常遇到的旧格式是 VOC XML。下面的脚本把 XML 中的bndbox转换成 YOLO 格式的归一化坐标并把类别名映射成 IDimport os import glob import xml.etree.ElementTree as ET # 类名到ID的映射顺序必须与 classes.txt 一致 CLASS_MAP { damaged_tactile: 0, cracked_sidewalk: 1, occupied_tactile: 2, sunken_cover: 3, raised_edge: 4, } def xml_to_yolo(xml_path, out_txt_path): tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) if img_w 0 or img_h 0: print(f[跳过] {xml_path} 宽高为0) return lines [] for obj in root.findall(object): name obj.find(name).text if name not in CLASS_MAP: print(f[跳过] {xml_path} 中的未知类别: {name}) continue box obj.find(bndbox) xmin float(box.find(xmin).text) ymin float(box.find(ymin).text) xmax float(box.find(xmax).text) ymax float(box.find(ymax).text) # 脏数据保护 if xmax xmin or ymax ymin: print(f[跳过] {xml_path} 中存在反向坐标) continue cx (xmin xmax) / 2 / img_w cy (ymin ymax) / 2 / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h lines.append(f{CLASS_MAP[name]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) with open(out_txt_path, w, encodingutf-8) as f: f.write(\n.join(lines)) if __name__ __main__: os.makedirs(labels, exist_okTrue) for xml_file in glob.glob(Annotations/*.xml): base os.path.splitext(os.path.basename(xml_file))[0] xml_to_yolo(xml_file, os.path.join(labels, base .txt)) print(已转换:, xml_file)这段脚本里最容易出问题的就是归一化计算。YOLO 要求中心坐标和宽高都是相对图片宽高的比例值所以 xmin/xmax 之和要先除以 2 再除以图片宽而不是先除以宽再加起来除以2两者结果虽然一样但后一种写法容易在 xmax 小于 xmin 时产生负数中心点。脚本开头对图片宽高为 0 的情况做了保护因为某些标注工具导出异常图片时 size 节点可能是空值反向坐标的过滤也要保留这类脏框进入训练至少会导致那一批样本的 loss 贡献异常严重时会让训练中断。另外一个 XML 里可能混入 classes.txt 之外的类别这里选择跳过而不是报错毕竟脏数据不能浪费整张图但跳过的信息要打印出来方便事后统计到底有多少框被丢弃。3.3 按场景分组划分train/val/test避免同一段盲道同时出现在训练和验证里数据划分是标题里“划分好的数据集”的核心。很多人随手用random.shuffle按单张图片划分结果一段连续拍摄的盲道视频抽出 30 帧其中 20 帧进了 train、10 帧进了 val画面几乎一模一样。模型在验证集上表现极好一换到陌生路段就露馅这种数据泄漏在目标检测里非常隐蔽。我按场景分组来划分把属于同一拍摄片段、同一路段的图片归为一组然后整组分配到某个子集。实现时不需要额外记录分组信息直接利用文件名的前缀作为分组键import os import random import shutil from collections import defaultdict random.seed(42) IMG_DIR images_all # 未划分的原始图片目录 LBL_DIR labels_all # 未划分的标签目录 OUT_ROOT dataset RATIOS {train: 0.8, val: 0.1, test: 0.1} def scene_key(filename): # 约定文件名格式为 场景标识_帧号.jpg例如 20250317_renminlu_001.jpg # 取最后一个下划线之前的部分作为场景标识 return filename.rsplit(_, 1)[0] groups defaultdict(list) for name in os.listdir(IMG_DIR): if not name.lower().endswith((.jpg, .jpeg, .png)): continue base, ext os.path.splitext(name) txt base .txt if not os.path.exists(os.path.join(LBL_DIR, txt)): print(f[跳过] 缺少标签文件: {name}) continue groups[scene_key(name)].append(name) group_items list(groups.values()) random.shuffle(group_items) total sum(len(g) for g in group_items) train_n int(total * RATIOS[train]) val_n int(total * RATIOS[val]) train_files, val_files, test_files [], [], [] cnt 0 for g in group_items: if cnt train_n: train_files.extend(g) cnt len(g) elif cnt train_n val_n: val_files.extend(g) cnt len(g) else: test_files.extend(g) for split_name, file_list in [(train, train_files), (val, val_files), (test, test_files)]: img_out os.path.join(OUT_ROOT, images, split_name) lbl_out os.path.join(OUT_ROOT, labels, split_name) os.makedirs(img_out, exist_okTrue) os.makedirs(lbl_out, exist_okTrue) for f in file_list: base, ext os.path.splitext(f) shutil.copy(os.path.join(IMG_DIR, f), os.path.join(img_out, f)) shutil.copy(os.path.join(LBL_DIR, base .txt), os.path.join(lbl_out, base .txt)) print(f{split_name}: {len(file_list)} 张图片)这个脚本有两个关键参数random.seed(42)固定随机种子保证每次划分结果一致否则你换台机器跑一遍train/val 内容就变了后面复现实验结果会非常痛苦scene_key的分组规则依赖文件名命名规范所以采集图片时一定要养成“场景标识帧号”的命名习惯而不是交给相机生成一堆毫无规律的 ID。如果原始数据没有命名规律退而求其次的做法是按拍摄时间戳聚合并设置相邻 2 秒内的帧为同一组。划分完成后回到 2.3 节的检查脚本再跑一遍确认三个子集的类别分布比例不要差太远。4. 数据可视化脚本与训练前检查小目标检测的命门在标注质量4.1 逐图画出标签框扫一眼就知道有没有漏标和错位盲道缺陷目标小、对比度低光看数字统计发现不了问题。我训练前的习惯是先用可视化脚本把所有训练图画一遍框翻一遍图。下面这个脚本读取 YOLO txt 标签并把矩形框叠加到原图上import os import cv2 def draw_yolo_labels(img_path, label_path, classes, save_path): img cv2.imread(img_path) if img is None: print([错误] 无法读取图片:, img_path) return h, w img.shape[:2] with open(label_path, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) ! 5: print(f[警告] {label_path} 中存在异常行: {line.strip()}) continue cls_id int(parts[0]) cx, cy, bw, bh map(float, parts[1:]) x1 int((cx - bw / 2) * w) y1 int((cy - bh / 2) * h) x2 int((cx bw / 2) * w) y2 int((cy bh / 2) * h) color (0, 255, 0) if cls_id in (0, 2) else (0, 0, 255) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) label classes[cls_id] if 0 cls_id len(classes) else fID_{cls_id} cv2.putText(img, label, (max(0, x1), max(0, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) os.makedirs(os.path.dirname(save_path), exist_okTrue) cv2.imwrite(save_path, img) print(已保存:, save_path) if __name__ __main__: classes open(classes.txt, encodingutf-8).read().splitlines() lbl_root labels/train img_root images/train for txt_name in os.listdir(lbl_root): base, _ os.path.splitext(txt_name) img_path os.path.join(img_root, base .jpg) save_path os.path.join(debug_vis, train, base .jpg) if os.path.exists(img_path): draw_yolo_labels(img_path, os.path.join(lbl_root, txt_name), classes, save_path) else: print([警告] 找不到对应图片:, img_path)这段脚本里我刻意对不同类别用了不同颜色盲道破损和占道是绿色其他缺陷是红色这样一眼就能看出类别标签是否合理。缩放坐标时要注意YOLO txt 里存储的是相对值必须乘回图片的实际宽高如果你把宽高搞反窄框会变成横跨整张图的宽框在缩略图里非常显眼。索引越界的cls_id也要做成显眼的ID_xx形式而不是让程序崩掉因为一次跑完几百张图再统一修数据比一张一张停更高效。4.2 类别数量与目标尺寸分布判断该用640还是1280输入可视化翻图靠眼睛尺寸分布要靠脚本量化。小目标检测的命门就在这里盲道砖破损在 1080p 原图里可能只有 40×40 像素缩放到 640 输入后就只剩十几个像素模型很难学。先跑下面这个分布统计脚本再决定输入分辨率import os from collections import Counter def analyze_labels(label_rootlabels): class_cnt Counter() widths, heights [], [] small_cnt, total 0, 0 for split in os.listdir(label_root): split_dir os.path.join(label_root, split) if not os.path.isdir(split_dir): continue for txt in os.listdir(split_dir): with open(os.path.join(split_dir, txt), encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) ! 5: continue cls_id int(parts[0]) w float(parts[3]) h float(parts[4]) class_cnt[cls_id] 1 widths.append(w) heights.append(h) total 1 if w 0.05 or h 0.05: small_cnt 1 if total 0: print(没有任何目标先检查标签目录) return print(总目标数:, total) print(各类别目标数:, sorted(class_cnt.items())) print(小目标占比(宽或高相对原图小于5%):, f{small_cnt / total:.2%}) print(目标宽度均值:, f{sum(widths) / len(widths):.4f}) print(目标高度均值:, f{sum(heights) / len(heights):.4f}) analyze_labels()5% 这个阈值是我自己常用的经验值如果某个类的大量目标宽或高小于原图的 5%说明这是一个典型的小目标问题。此时再套用 YOLOv8 默认的 640×640 输入很可能出现大量漏检。应对策略有两条路一是把训练输入提升到 1280×1280但这会让显存占用和训练时间成倍增长需要你评估手里的显卡是否扛得住二是保持 640 输入但给模型增加更细的检测层P2 层或者用 SAHI 这类切图推理工具在预测阶段把大图切块。这两种做法我都试过数据量少时先上大分辨率往往更直接但推理时也要跟着换否则训练和部署口径不一致线上效果会打折扣。4.3 把数据接到YOLOv8训练自己的数据集最小配置与参数建议数据检查和清洗都完成之后就可以把数据集接到 YOLOv8 里训练了。data.yaml 是训练入口# dataset/data.yaml path: dataset # 相对当前路径或绝对路径 train: images/train val: images/val test: images/test nc: 5 names: 0: damaged_tactile 1: cracked_sidewalk 2: occupied_tactile 3: sunken_cover 4: raised_edge这里最容易犯的错是path写成相对路径后又把 data.yaml 挪到别的位置导致训练时报“image not found”。先跑yolo detect train datadataset/data.yaml modelyolov8n.pt ...之前强烈建议打印一下yolo taskdetect modeval datadataset/data.yaml能不能正常加载验证集这一步能提前暴露路径问题。训练命令我按“小目标多、数据量中等”的场景推荐这样设置yolo detect train \ datadataset/data.yaml \ modelyolov8n.pt \ imgsz1280 \ batch8 \ epochs100 \ patience15 \ ampTrue \ projectruns/tactile \ nameexp1参数说明imgsz1280是针对盲道缺陷小目标较多的选择如果显存不够可以把 batch 降为 4 或 6并保持ampTrue用混合精度来摊平显存压力patience15是早停参数连续 15 轮验证指标没有提升就停止避免盲目烧时间modelyolov8n.pt这行会联网下载预训练权重第一次跑需要预留几分钟下载时间下载完成后权重会缓存在本地之后再跑就不会重复下载。如果连下载这一步都觉得麻烦可以直接写一个不存在的权重路径比如modelyolov8n.pt让程序报错它会提示你正确的获取方式但这不是高效的做法。数据量很小的时候从yolov8n起步而不是一上来就选yolov8x因为大模型在小数据集上更容易过拟合泛化能力反而不如小模型。5. 盲道与缺陷检测数据处理中的5个常见坑现象、原因、解决5.1 EXIF旋转让标注框集体错位现象训练前可视化检查时部分图片的框整体偏移比如目标在画面左下角框却画在右上角而且只有用手机竖拍的照片出问题。原因手机和部分相机拍摄的 JPEG 会在 EXIF 信息里写一个 Orientation 字段标注工具比如 LabelImg读取时有的版本会自动应用旋转而 OpenCV 的cv2.imread并不会读取 EXIF 旋转直接按原始像素加载。两边对同一张图的“宽高”理解不一致标签里的坐标自然就全错位了。解决统一在预处理阶段把图片转正并清掉 EXIF。我用 Python 的PIL.ImageOps.exif_transpose批量处理一遍再覆盖保存为无 EXIF 的 JPEG。注意处理完要重新生成标签或者先旋转图片再重新标注不要只旋转图片而保留旧标签。from PIL import Image, ImageOps import glob for path in glob.glob(raw_images/*.jpg): img Image.open(path) img ImageOps.exif_transpose(img) img.save(path, JPEG, exifb)5.2 归一化坐标溢出训练时loss直接不收敛现象模型训练 loss 第一天就在高位震荡怎么调学习率都下不去查看某个标签文件发现里面有类似0 1.2345 -0.0032 0.5678 0.0001这种明显异常的值。原因转换脚本写得不够稳健比如直接把 VOC 的 xmax/xmin 拿来除以宽没有先检查数值合法性或者图片本身被裁剪过但标注框坐标没有同步更新导致 xmax 大于图片宽度。解决在转换和加载两条链路都做边界检查。转换时对0 cx 1、0 cy 1、0 w 1、0 h 1做断言越界就打印并跳过该框。训练前再用脚本把所有标签扫一遍遇到越界直接报警。这些脏框清理干净之前不要开训练否则浪费的是一整轮实验周期。5.3 某类样本太少模型微调后能力反而崩了现象数据集里“夜间井盖缺失”这类样本只有三四十个训练完成后其他类表现正常唯独这一类几乎不检出甚至把正常的井盖都误报成缺失。原因类别极度不均衡时模型的损失函数被大样本类别主导小样本类别学不到稳定的特征。如果这个类别的图片还集中在某一两个场景里模型实际学到的是“那片路段的纹理”而不是缺陷本身。这就是典型的“目标检测模型微调崩了”场景。解决优先做类别重加权YOLOv8 的 loss 配置里可以调整各类别的权重让少样本类别贡献更高其次做针对性增强对少样本类别所在的图片做随机裁剪、旋转、亮度抖动把三四十张扩充到一两百张。如果数据实在少把模型换成更小的版本或者直接砍掉这个类别先用五类跑通再迭代也比硬塞一个学不出来的类强。5.4 混淆矩阵总和不是1先别怀疑脚本算错现象训练结束后可视化混淆矩阵的每一行加起来不是 100%甚至有人发现某些元素大于 1。网上搜“yolo混淆矩阵总合不唯一”会看到不少人在问是不是代码 bug。原因YOLO 输出的混淆矩阵记录的是样本个数不是概率。每一行的分母是“该类的真实目标数量”但其实矩阵中还包含背景负样本和其他类的预测样本行内列数只截取了出现过的类别因此行和看起来“不唯一”。这是展示口径的问题不是算法错误。解决看混淆矩阵时关注对角线相对占比和误报最严重的两个类别就够了。想真正算准某个类的召回率用训练输出里的 per-class 指标表那里有基于匹配结果的精确召回数值。不要为了“让混淆矩阵一行加起来等于1”而去改测试时的置信度阈值那属于自欺欺人。5.5 训练到一半BN崩溃loss变成nan现象训练正常跑了几十个 epoch突然某一步 loss 变成 nan继续训练也一直 nan模型权重彻底报废。原因YOLO 的检测头里带有 BatchNorm 层当 batch size 很小比如 2 或 4且学习率偏大时BN 层统计的均值和方差在单个 batch 上波动过大数值不稳定积累到一定程度后梯度爆炸loss 直接溢出。解决最有效的一招是调低初始学习率比如从默认 0.01 降到 0.001让 BN 层在大约前 3 个 epoch 的 warmup 阶段平稳收敛。另外增大 batch 能显著缓解 BN 崩溃但显存紧张时可以用梯度累积YOLO 的batch配置配合优化器参数做等效增大。真崩了也不要慌加载最近的best.pt或last.pt把学习率调低后从中断点继续训练大部分情况都能恢复。6. 用可视化脚本验证模型的输出把真值框和预测框叠在一张图上看差距训练结束后先别急着看 mAP我习惯做一次“真值与预测同框对比”。单看准确率数字很容易被平均值掩盖比如所有类别的 mAP 到了 0.75但最关键的盲道砖破损这个类可能只有 0.3。把验证集的预测框和真值框画在同一张图上能快速确认漏检集中在哪些场景、哪些尺寸。import cv2 from ultralytics import YOLO model YOLO(runs/tactile/exp1/weights/best.pt) classes open(classes.txt, encodingutf-8).read().splitlines() def draw_gt_and_pred(img_path, label_path, save_path): img cv2.imread(img_path) h, w img.shape[:2] # 画真值框绿色 if label_path and os.path.exists(label_path): with open(label_path, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) ! 5: continue cls_id, cx, cy, bw, bh int(parts[0]), *map(float, parts[1:]) x1, y1 int((cx - bw / 2) * w), int((cy - bh / 2) * h) x2, y2 int((cx bw / 2) * w), int((cy bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, fGT:{classes[cls_id]}, (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2) # 画预测框红色带置信度 results model.predict(img_path, imgsz1280, conf0.25, verboseFalse) for r in results: for box in r.boxes: x1, y1, x2, y2 map(int, box.xyxy[0]) conf float(box.conf[0]) cls_id int(box.cls[0]) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2) cv2.putText(img, f{classes[cls_id]} {conf:.2f}, (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2) os.makedirs(os.path.dirname(save_path), exist_okTrue) cv2.imwrite(save_path, img)跑完几十张验证图后重点关注两类图一是“有 GT 框但没有红框”的图这是漏检要统计漏检目标是不是都偏小、对比度低或处于阴影中二是“有红框但没有绿框”的图这是误检要看是不是把井盖边缘误判成裂缝。我自己的习惯是每轮实验结束固定跑这个脚本把最差的 20 张图截出来归因多数时候问题不在模型而在数据标漏了、标偏了、类别定义没对齐。先把标签修一轮再重训比盲目换模型架构更省时间。希望这套流程能帮你把盲道、人行道缺陷检测从“跑通”推进到“敢上线”。本文还有配套的精品资源点击获取