简介:自行车目标检测数据集面向计算机视觉目标检测任务,提供Pascal VOC与YOLO两种格式的标注文件,适配YOLO系列、SSD、Faster R-CNN等常用检测框架,也适合课程设计与算法对比实验。数据标注类别仅Bicycle,矩形框总数5562个,均由labelImg按统一规则绘制,标注目标明确、干扰少,可直接作为监督学习的训练样本;图片数量与VOC/YOLO标注一一对应,省去自行采集和标注的时间成本。资源包共2000个文件,其中1999个为XML标注文件,1个为说明txt,压缩后整体约809.22MB,便于本地存储和按需解压使用。目前已有383人学习下载,对于需要标准自行车数据集的开发者或研究者具有一定参考价值,可用于模型训练、精度验证及迁移学习场景。
1. 2400 张 VOC+YOLO 双格式自行车数据,值不值得直接送进训练?先过一遍数据体检
拿到这种发布者自己打包、以 rar 形式流传的检测数据集,最忌讳的一件事就是解压、看两张图、把 VOC 转成所谓 YOLO 格式、直接开训。2400 张图听着规模不大不小,但如果某个 xml 里藏着半个越界的框、某个 txt 的类别 id 和 yaml 对不上,YOLO 训练出来的 mAP 会一直很低,而你完全不知道往哪排查。版本数字 7 不用太在意,重点是这 2400 张图怎么安全地送进训练器。这篇笔记按我自己的落地习惯写:先把 rar 当黑匣子拆开做数据体检,再把 VOC 标注转成 YOLO 格式,补齐归一化、越界和类别映射这些细节,最后说清楚 2400 张这个体量下怎么切分、怎么配参数,以及我踩过的五个坑。适合正被 voc 数据集和 yolo 格式转换折腾的人,也适合要训练自行车检测模型、但不确定这份数据能不能用的朋友。
2. 解开 rar 先做数据体检:目录结构、图片完整性和标注对齐
2.1 解压前先确认三件事:编码、顶层目录、压缩包完整性
先别急着右键解压。历史经验是,Windows 自带解压对这种以中文文件名打包的 rar 很不友好。rar 内部文件名编码可能是旧的 CP936 或 CP437,Windows 解压出来经常是一串乱码,后面用 Python 遍历时路径全是乱码,你根本分不清谁是谁。我会优先用 unar 这类能自动检测编码的命令行工具,没有的话用 7z 也能救急:
unar -o ./bicycle_7 '自行车数据集7-VOC+YOLO格式2400张.rar'-o指定输出目录,避免解压出来的文件在压缩包旁边乱摊。如果系统里没有 unar,macOS 用brew install unar,Debian/Ubuntu 用apt install unar,Windows 装 7-Zip 后可以用7z x代替。用 unar 的好处不只是自动识别中文编码,它还自带一层文件名安全过滤,不会把路径里带..的恶意条目真的写成目录跳转。
解压完成后,先看一层目录就够,不用全展开:
find ./bicycle_7 -maxdepth 2 -type d | head -50 du -sh ./bicycle_7大概率会出现两种情况:要么顶层是一个同名文件夹,图片和 xml 混在一起;要么里面按 JPEGImages、Annotations、ImageSets 这种 VOC 标准目录分好了。前者需要自己规整目录,后者可以直接用。无论哪种,先记下真实的路径结构,后面所有脚本都建立在它之上。
2.2 统计三类文件数量:图片、xml、txt,一个都不能少
因为包名写“VOC+YOLO 格式”,理想情况下每张图对应一个 xml 和一个 txt。但现实往往不是这样:发布者可能只转了 80% 的 txt,或者在转换时漏了几个 xml。先做一次硬性数量统计:
find ./bicycle_7 -type f -iname '*.jpg' | wc -l find ./bicycle_7 -type f -iname '*.jpeg' | wc -l find ./bicycle_7 -type f -iname '*.png' | wc -l find ./bicycle_7 -type f -iname '*.xml' | wc -l find ./bicycle_7 -type f -iname '*.txt' | wc -l目标是图片 2400、xml 2400、txt 2400。如果图片确实是 2400 但 xml 只有 2380,说明有 20 张图没被标注,转格式时你要决定是补标还是丢弃。如果 txt 数比 xml 少,说明发布者的 YOLO 转换脚本半路断过,这种包不适合直接当“YOLO 格式”用,自己重转一遍更省心。
除了数量,还要看文件名是否成对。图片叫 1001.jpg,xml 叫 1001.xml,txt 却多了个 1002.txt,这种错位很常见。用 Python 拉一个集合减一下就清楚了:
from pathlib import Path base = Path('./bicycle_7') img_stems = {p.stem for p in base.rglob('*') if p.suffix.lower() in {'.jpg', '.jpeg', '.png'}} xml_stems = {p.stem for p in base.rglob('*') if p.suffix.lower() == '.xml'} txt_stems = {p.stem for p in base.rglob('*') if p.suffix.lower() == '.txt'} print('有图片无XML:', len(img_stems - xml_stems)) print('有XML无图片:', len(xml_stems - img_stems)) print('有图片无TXT:', len(img_stems - txt_stems))这里用rglob是为了递归找全所有子目录,不依赖“所有文件必须在一个文件夹”的假设。打印出来的差值如果不是 0,记下来,这是后面转格式时要手动处理的黑名单。
2.3 图片完整性扫描:先修图再修标注
这一步很多人会跳过,但图片才是训练的地基。一张下载一半、传输损坏的 jpg,标注做得再准也救不回来。用 PIL 从头到尾试读所有图片,比肉眼翻一遍目录可靠得多:
from pathlib import Path from PIL import Image base = Path('./bicycle_7') img_paths = [p for p in base.rglob('*') if p.suffix.lower() in {'.jpg', '.jpeg', '.png'}] bad = [] for p in img_paths: try: with Image.open(p) as im: im.load() if im.width < 32 or im.height < 32: bad.append((str(p), f'tiny:{im.width}x{im.height}')) except Exception as e: bad.append((str(p), repr(e))) print('图片总数:', len(img_paths)) print('异常图片:', len(bad)) for p, reason in bad[:20]: print(p, reason)PIL 在读取损坏文件或截断的 EXIF 数据时会抛异常,这比cv2.imread返回 None 但你不一定能察觉要严格。我一般不会把 cv2 读不出来的图直接删,先看看是不是特殊分辨率、超宽全景图或者带透明通道的 PNG,这类图可能只是格式特殊。小于 32×32 的图建议从训练集里剔除,YOLO 的 mosaic 增强会把这种小图放大到无语的程度,损失函数在它们身上全是噪声。
第二步是抽查 xml 里的目标数量分布。一个平均每张只有 1~2 个目标的 2400 张数据集,和一个平均每张 6 个目标的 2400 张,训练策略完全不同:
import xml.etree.ElementTree as ET from collections import Counter from pathlib import Path xml_paths = list(Path('./bicycle_7').rglob('*.xml')) obj_cnt = Counter() cls_cnt = Counter() for p in xml_paths: try: root = ET.parse(p).getroot() except ET.ParseError: print('XML解析失败:', p) continue objs = [obj for obj in root.iter('object')] obj_cnt[len(objs)] += 1 for obj in objs: cls_cnt[obj.find('name').text.strip()] += 1 print('每张目标数分布:', dict(sorted(obj_cnt.items()))) print('类别分布:', dict(cls_cnt))这一步的核心不是统计数字,而是让你在写转换脚本之前就知道这个包的真实类别集合。包名叫自行车数据集,但如果统计出来还有 person、car,那转格式时就得决定是保留多类还是只留 bicycle。这个问题越早暴露,后面越不返工。
3. VOC 标注转 YOLO 格式:XML 里的隐藏字段与归一化换算
3.1 先分清 VOC 里哪些字段参与训练,哪些可以直接丢掉
VOC 的 xml 除了 object 和 bndbox,还带了一堆跟现代 YOLO 训练无关的元数据。真实转换脚本里,绝大多数字段不会进入训练,但有几个必须当心。先看典型字段:
| 字段 | 转 YOLO 时是否使用 | 说明 |
|---|---|---|
| folder / filename | 用于对齐和命名 | 只用来确认 xml 对应哪张图 |
| size/width、size/height | 必须使用 | 是 bndbox 归一化的分母,填错则所有框位置全错 |
| object/name | 必须使用 | 决定类别 id,注意大小写和拼写 |
| object/truncated | 一般忽略 | 目标被截断,不代表框本身有问题 |
| object/difficult | 自行决定 | YOLO 没有难例通道,通常跳过 |
| object/bndbox | 必须使用 | 四个顶点坐标 |
一个典型坑:size 字段里可能没有 width 和 height,而只有 depth;或者 width 被写成字符串1920px。解析时要先 strip、再转 int,不能直接int()一把梭,不然一个异常就打断整个转换流程。我的习惯是解析失败时打印 xml 路径,而不是静默跳过两百个文件。
另一个容易翻车的地方是 difficult。VOC 里形态是<difficult>1</difficult>,表示这个目标特别难辨认,当年给评测用的。YOLO 没有这个语义,你如果把它当成正常样本做训练,等于故意教模型学那些连人都难认的框;如果直接删掉,训练集里又白丢了一批负样本。常见做法是过滤掉,同时让脚本记一个 count,让你知道一共有多少个 difficult 目标被放弃了。
3.2 将 XML 转为 YOLO 格式:坐标公式与容错处理
YOLO 格式的标注是文本文件,每行五个数字:类别 id、归一化中心点 x、归一化中心点 y、归一化宽、归一化高。换算公式看起来简单,但实现时容易被 int 截断和归一化边界坑到:
import xml.etree.ElementTree as ET def xml_to_yolo_line(root, class_map, clip=True): size = root.find('size') w = float(size.find('width').text.strip()) h = float(size.find('height').text.strip()) lines = [] for obj in root.iter('object'): name = obj.find('name').text.strip() if name not in class_map: continue # 不在映射表里的类别,按策略跳过 class_id = class_map[name] box = obj.find('bndbox') xmin = float(box.find('xmin').text.strip()) ymin = float(box.find('ymin').text.strip()) xmax = float(box.find('xmax').text.strip()) ymax = float(box.find('ymax').text.strip()) # 容错:如果两条边写反了,交换而不是直接报错 if xmin > xmax: xmin, xmax = xmax, xmin if ymin > ymax: ymin, ymax = ymax, ymin # 越界裁剪:某些标注工具会产生超出图片宽高的顶点 if clip: xmin = max(0.0, min(xmin, w)) xmax = max(0.0, min(xmax, w)) ymin = max(0.0, min(ymin, h)) ymax = max(0.0, min(ymax, h)) if xmax - xmin <= 1 or ymax - ymin <= 1: continue # 宽度或高度 1px 以下,视为坏框 x_center = ((xmin + xmax) / 2.0) / w y_center = ((ymin + ymax) / 2.0) / h box_w = (xmax - xmin) / w box_h = (ymax - ymin) / h # 归一化结果理论上都在 [0,1],但还是防一手浮点误差 x_center = min(0.999999, max(0.000001, x_center)) y_center = min(0.999999, max(0.000001, y_center)) box_w = min(0.999999, max(0.000001, box_w)) box_h = min(0.999999, max(0.000001, box_h)) lines.append(f'{class_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}') return lines这段代码的容错点有三个:xmin/xmax 写反了要交换而不是报错;坐标越界要裁剪回图片范围;小于 1px 的框直接丢弃。正常标注工具不太会出现前两类问题,但当你下载这种打包数据集时,什么工具都可能是不规范脚本生成的,宁可检查一遍也不要让坏样本混进训练。
调用脚本时,注意 class_map 的键必须和 xml 里 name 完全一致,包括空格和小写。bicycle、Bicycle、Bike 在 Python 字典里是三个不同键,映射表没写成同一个小写键,这些行会被静默跳过,最后 txt 内容少了很多你还不知道。常见做法是转换前先跑一遍全量 name 统计,然后把这个集合和 class_map 做 diff。
3.3 转完之后必须做的一步:把 txt 画回图片上
文本格式转换完成后,最有效的验证不是去看 txt 里的数字,而是把框画到原图上,挑几十张肉眼过一遍:
import cv2 from pathlib import Path def draw_yolo_box(img_path, txt_path, class_names, out_dir): img = cv2.imread(str(img_path)) if img is None: return False h, w = img.shape[:2] with open(txt_path, 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cid, xc, yc, bw, bh = map(float, parts) x1 = int((xc - bw / 2.0) * w) y1 = int((yc - bh / 2.0) * h) x2 = int((xc + bw / 2.0) * w) y2 = int((yc + bh / 2.0) * h) color = (0, 255, 0) if int(cid) == 0 else (0, 0, 255) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) label = class_names[int(cid)] if int(cid) < len(class_names) else str(cid) cv2.putText(img, label, (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) out = Path(out_dir) / (Path(img_path).stem + '_vis.jpg') cv2.imwrite(str(out), img) return True参数方面,color 区分类别是为了多类别时一眼看清有没有把自行车框到行人身上。cv2 画框用的坐标必须是 0 到 w、0 到 h 的整数,所以要把归一化坐标乘回去。如果画出来的框明显比目标大一圈,多半是 xmin/xmax 解析时串了列;如果框贴在图片边缘被裁掉,说明 xml 里本来就是越界框,这时候回看 3.2 的 clip 有没有生效。
验证样本挑 30~50 张就够,重点是挑多目标的、背景复杂的、以及转换脚本里出现过异常的那几张。一本正经把 2400 张全画出来,反而没人看。
4. 2400 张怎么切分和配参:训练集、验证集与 YOLOv8 起始配置
4.1 先统计类别分布,再定 class 映射
第 2 章统计出的类别分布,在这里决定你 yaml 文件里的 names 顺序。YOLOv8 训练自己的数据集时,names 列表顺序必须和标注 txt 里的类别 id 一一对应,不能因为包名叫自行车数据集就假设只有一类。
如果统计结果只有 bicycle 一个类,那 yaml 写起来最简单。如果出现了 person、car 这种相邻类,我的建议是保留它们而不是删掉:自行车检测场景里最常见的误检就是行人,保留 person 这一类,等于让模型在训练阶段见过“人不是自行车”的负样本,后面推理时误报会明显减少。代价只是多一个类别、多几十个框,对 2400 张数据集来说完全值得。
类别 id 一旦定下来,转换脚本里的 class_map 和 yaml 里的 names 必须同源。最常见的翻车就是两者顺序不一致,训练时类别名全乱了还看不出。
4.2 按固定种子做 7:2:1 切分,并保证训练验证类别都齐
2400 张中小数据集,切分比例我一般用 7:2:1,也就是训练 1680、验证 480、测试 240。这个比例下 val 集足够看出模型有没有过拟合。切分脚本要满足三点:随机种子固定、train 和 val 里类别都存在、图片和标注成对搬走:
import random import shutil from pathlib import Path src_imgs = sorted(Path('./bicycle_7/images').glob('*.jpg')) src_labs = Path('./bicycle_7/labels') # 只保留同时有 txt 且非空的干净样本 keep = [] for img in src_imgs: lab = src_labs / (img.stem + '.txt') if lab.exists() and lab.stat().st_size > 0: keep.append((img, lab)) random.seed(20240517) random.shuffle(keep) n = len(keep) n_train = int(n * 0.7) n_val = int(n * 0.2) for split, pairs in [ ('train', keep[:n_train]), ('val', keep[n_train:n_train + n_val]), ('test', keep[n_train + n_val:]), ]: out_img = Path(f'./data/{split}/images') out_lab = Path(f'./data/{split}/labels') out_img.mkdir(parents=True, exist_ok=True) out_lab.mkdir(parents=True, exist_ok=True) for img_path, lab_path in pairs: shutil.copy2(img_path, out_img / img_path.name) shutil.copy2(lab_path, out_lab / lab_path.name) print('train:', len(keep[:n_train]), 'val:', len(keep[n_train:n_train + n_val]), 'test:', len(keep[n_train + n_val:]))这里我用lab.stat().st_size > 0过滤掉空 txt,原因在第 5 章展开。随机种子固定后,每次跑结果一样,后续新增数据可以增量复现。如果 val 集太小导致某个类别只剩一张图,4.1 的类别统计就要在切分后每个 split 各跑一遍,而不是只看全量。
4.3 YOLOv8 的 dataset.yaml 写法,以及中小数据集的训练参数
切分完成后,YOLOv8 的训练入口需要一份 yaml:
path: ./data train: train/images val: val/images test: test/images names: 0: bicycle 1: personpath 指向这个 yaml 所在目录的某个路径。写成相对路径时,训练命令的工作目录必须固定;写成绝对路径则换机器要改。我一般把数据集和训练代码放在同一级目录,yaml 里用相对路径,省得每次迁移都改配置。names 的编号和标注 txt 的首列必须一致,这是 YOLO 系列从 v5 到 v8 不变的要求。
2400 张的量级,训练参数建议从一组偏保守的值开始:
| 参数 | 起始值 | 说明 |
|---|---|---|
| imgsz | 640 或 1280 | 原图分辨率不高时 640 够用 |
| batch | 16(V100 可 32) | 看显存,2GB 以下用 8 |
| epochs | 100 起 | 看 val loss 是否继续下降 |
| scale | 0.5 | 自行车尺度相对固定,不要拉到 0.9 |
| fliplr | 0.5 | 水平翻转对单侧车影有效,方向敏感场景慎开 |
| degrees | 0.0 | 强旋转增强对这种刚性物体没必要 |
| mosaic | 1.0 | 小数据集用 mosaic 提升显著,后期可降 0.5 |
再说一句和 yolo 损失函数相关的话:YOLOv8 的 box 损失用 CIoU 变体,它同时惩罚框的中心点距离、宽高比和重叠率。如果标注里的宽高和真实目标差半步,CIoU 的几何项就会持续给梯度,表现出来是训练 loss 下降正常,但 mAP 卡在某个值不动。这也是为什么我在第 3 章坚持说画框验证——格式转换的误差最终都会体现在损失函数上,但损失函数不会告诉你错在标注。
训练时显存不够,优先降 batch 而不是降 imgsz。imgsz 从 640 降到 416,小目标检测能力会先垮掉;batch 只要大于 8,对 mAP 影响有限。多卡环境每卡保持 16,总 batch 等于 16 乘卡数。
5. 双格式数据集的 5 个避坑点:从解压乱码到标注越界
5.1 rar 解压出来的中文路径变乱码,训练时文件找不到
现象:压缩包解压后,文件名变成æÂ¯çÂ这种乱码,cv2.imread 返回 None,dataloader 报 FileNotFoundError。
原因:rar 内部文件名是按旧的中文编码写入的,Windows 自带解压工具默认按当前系统代码页解,编码对不上就成了乱码。文件名一乱,后面所有按 stem 配对的逻辑全部失效。
解决:优先用 unar 或 7-Zip 解压,解压后立即把所有文件移动到纯英文路径下。我一般直接建D:\datasets\bicycle_7这种无空格、无中文、无括号的目录,宁可多打两行命令,不要赌 Python 和 OpenCV 对中文路径的支持。如果已经解压乱了,最快的补救是写一个 replace 脚本,把乱码前缀统一替换成纯英文编号,而不是手动改几百个文件名。
5.2 xml 里的 name 五花八门:Bicycle、bike、自行车
现象:类别统计出来不是一个 bicycle,而是七八种写法;class_map 没有匹配到的行被静默跳过,训练出来的模型完全不认识自行车。
原因:发布者标注时没有用统一类别规范,或者从多个公开数据集拼接而来,原始 name 字段保留了各自叫法。
解决:写转换脚本之前,先把所有 xml 的 name 列成一个集合,用规范化函数统一:转小写、strip 空白、再手工建 alias。一句话做法是name.strip().lower(),Bicycle和bicycle会合并,但bike和bicycle仍然要人工决定是否归为一类。拿不定时我的原则是从严合并:同一辆车出现在两个类里,宁可让类别多一点,也不要让标注框被静默吞掉。
5.3 bndbox 越界导致训练 loss 变 nan
现象:训练跑了几百步到几千步,loss 突然变 nan,重启后过一阵又复现。
原因:xml 里的 xmax 或 ymax 大于图片实际宽高,或者出现了负坐标。归一化公式本身不会崩,但后续数据增强、mosaic 拼接时这些越界框会制造非法区域,一个非法样本就能让梯度爆炸。
解决:转换脚本里 clip 参数必须默认打开。我在 3.2 已经给出裁剪代码,它在归一化之前把 xmin/xmax 限制在 [0, width],ymin/ymax 限制在 [0, height]。训练前也可以再扫一遍所有 txt,找出任意一行坐标减宽高一半后小于 0 或大于 1 的情况,把这些文件单独拎出来看是哪张图出的问题。
5.4 空 txt 文件混在 labels 里
现象:训练不报错,但 val 的混淆矩阵总行数对不上——预测量里明显有自行车,矩阵里却没有对应行;或者 recall 奇低。
原因:有些 xml 本身没有 object,转换脚本生成了空 txt;有些转换脚本在 bndbox 解析失败时也写了空文件。空 txt 的图片会被当成纯背景学习,如果这种图占到 20%,模型会被带偏。
解决:切分脚本里我特意写了st_size > 0过滤,把空文件剔除训练列表。如果你确定这些空文件代表“真没有目标”的纯负样本,可以单独保留做验证用,但不要混进 train 目录让 dataloader 随机收集。进阶做法是统计空 txt 数量和 xml 中 object 总数为 0 的数量是否一致,不一致说明有转换脚本吞了标注,这个包需要重新转一遍。
5.5 rar 解压中断或弹密码,别急着找 recovery 工具
现象:解压到 70% 报 CRC 错误,或者命令弹出 password 提示。
原因:rar 被分卷压缩,或者发布者加了访问密码;更常见的是压缩包在网盘搬运过程中损坏了。网上搜到的那些带着 recovery 字样的工具,看起来像后悔药,实际是另一个坑,来路不明的程序会让你多花时间不说,还可能把环境搞脏。
解决:第一步用rar t或unar -t测压缩包完整性,第二步回发布页看有没有密码说明或者分卷文件没下载全。一个需要靠猜密码才能解开的资源,投入时间成本已经超过自己标注两百张图,我一般直接放弃换渠道。数据集的坑永远在数据之外,这句话值得记下来。
6. 训练前 5 分钟体检:把整套巡检逻辑收进一个脚本
6.1 data_check.py:检测图片、配对标注、越界裁剪一条龙
到这一步,前面所有体检逻辑可以合并成一个脚本。训练前跑一遍,看到 all passed 再开训,依赖只有标准库和 Pillow:
from pathlib import Path from PIL import Image def check_dataset(data_root): imgs = [p for p in Path(data_root).rglob('*') if p.suffix.lower() in {'.jpg', '.jpeg', '.png'}] bad_images, orphan_imgs, empty_txts = [], [], [] clip_count = 0 for p in imgs: try: with Image.open(p) as im: im.load() except Exception: bad_images.append(str(p)) lab = p.with_suffix('.txt') if not lab.exists(): orphan_imgs.append(str(p)) continue if lab.stat().st_size == 0: empty_txts.append(str(p)) continue img_w, img_h = Image.open(p).size for line in lab.read_text(encoding='utf-8').splitlines(): parts = line.split() if len(parts) != 5: continue _, xc, yc, bw, bh = map(float, parts) x1 = (xc - bw / 2) * img_w y1 = (yc - bh / 2) * img_h x2 = (xc + bw / 2) * img_w y2 = (yc + bh / 2) * img_h if min(x1, y1, x2, y2) < 0 or x2 > img_w or y2 > img_h: clip_count += 1 print(f'images: {len(imgs)}') print(f'bad_images: {len(bad_images)}') print(f'orphan_imgs_no_txt: {len(orphan_imgs)}') print(f'empty_txt: {len(empty_txts)}') print(f'box_clipped: {clip_count}')把这段脚本放在数据集根目录运行,5 分钟内能拿到三个结论:图片质量有没有硬伤、标注缺失情况、越界框总量。empty_txt 和 orphan_imgs 是切分脚本要过滤的关键;clip_count 是决定回看转换脚本还是直接修 txt 的依据。
这个巡检脚本本质上是把前面散落的检查逻辑收拢成一个入口,不依赖检测框架,也不需要 GPU。数据量发展到几万张时,把循环换成多进程扫描即可,检查项不用改。它针对的就是这种几百到几千张、以压缩包形式拿到的中小数据集。
我后来给自己定了个规矩:任何数据集,先跑一次体检再谈训练。上次没这么做,直接拿 VOC 包开训,第二天验证 mAP 只有 0.3,排查半天才发现是 name 字段里混了 person 和 bicycle,一个类 id 错位,另一个类全成了背景正样本。以 2400 张的量级,这套流程从解压到开训最多一小时;反而是一张图不看直接开训的决策,最容易赔上后面一整个星期。希望帮到你。
本文还有配套的精品资源,点击获取