检测数据集制作全流程收集、标注、VOC、COCO、YOLO 格式互转一次搞对目标检测项目里最容易被低估、又最容易致命的一步是数据集。模型效果不好第一件该查的永远是数据。而数据出问题往往不是图不够是格式不对、坐标越界、划分泄漏这些看不见的坑——它们不会报错只会让你的指标悄悄失真。这篇按做事的顺序走一遍想清楚要什么 → 收集清洗 → 标注 → 格式互转 → 划分 → 体检。每一步给出可直接用的脚本最后附一份体检清单。系列导航本篇是数据准备。往下看标注质量看《数据增强怎么配》看指标看《检测评估指标怎么看》看上线看《检测模型部署成 Web 服务》。一、先想清楚一份能用的数据集要满足什么动手之前先对着这 5 条检查。少一条后面都会以模型不行的形式还给你。要求说明不满足的后果图文一一对应每张图都有同名标签或无目标的空标签训练时静默跳过实际样本数比你以为的少坐标在范围内归一化坐标必须落在 0~1且宽高为正训练直接报错或学出乱框类别定义清晰类别名唯一、互斥、无重叠含义同一目标被标成两类模型学不会划分无泄漏同源数据同视频/同批次不跨集合val 指标虚高上线就崩有独立 test训练时绝不碰的那一份无法判断真实泛化最典型的翻车顺序随手标一批 → 直接训 → 看一个 mAP 就交付 → 现场发现漏检 → 回头查数据发现是划分泄漏。所以下面每一步都留一个可复核的产物。二、收集与清洗先减量再增量数据不是越多越好是越干净越好。先做三件减法去重复。同一张图被存了两份、或同一视频截了相邻帧会让训练集虚胖、val 虚高。用感知哈希找出近重复# pip install imagehash pillowimportglob,osfromPILimportImageimportimagehashdeffind_duplicates(img_dir,threshold6):hashes{}forpinglob.glob(os.path.join(img_dir,*)):try:himagehash.phash(Image.open(p).convert(RGB))exceptException:continueforprev,phinhashes.items():ifh-phthreshold:# 汉明距离越小越像print(疑似重复:,os.path.basename(p),-,os.path.basename(prev))hashes[p]h剔除损坏与异常。下载中断、格式损坏、纯色图都会混进来。检查可读性与尺寸importglobfromPILimportImagedefscan_broken(img_dir,min_side32):bad[]forpinglob.glob(f{img_dir}/**/*,recursiveTrue):try:imImage.open(p)im.verify()# 校验文件完整性imImage.open(p)# verify 后需重新打开w,him.sizeifwmin_sideorhmin_side:bad.append((p,f尺寸过小{w}x{h}))exceptExceptionase:bad.append((p,f无法打开:{e}))returnbad剔除无目标图。场景图不含任何待检目标在检测任务里通常是噪声。如果确实要当负样本保留少量即可别让它们淹没正样本。合规提醒爬取的图片要注意来源站点的服务条款与版权用于训练前先确认授权范围。三、标注工具与规范工具按格式和场景选工具输出格式适合LabelImgVOC XML / YOLO txt矩形框最快上手labelmeJSON多边形/矩形需要分割或复杂形状X-AnyLabeling多种支持自动标注想先用模型预标注再人工修Roboflow在线可导出多种团队协作、需要在线管理工具只是载体真正决定质量的是标注规范。开工前把这几条写下来多人协作时尤其重要边界框紧贴目标外沿不刻意留白、不切掉目标遮挡目标标可见部分还是标完整框必须统一推荐按可见部分并在规范里写明极小目标小于某像素数标不标定一个下限类别命名统一小写英文 下划线scratch、oil_stain避免中文与空格跨工具最容易出问题不确定的样本单独放unclear/不要硬标。四、三种格式VOC / COCO / YOLO三者的差别就三点存在哪、坐标怎么算、类别怎么表示。维度VOCCOCOYOLO存储每图一个 XML全库一个 JSON每图一个 txt坐标绝对像素xmin ymin xmax ymax绝对像素[x, y, w, h]归一化cx cy w h0~1类别名字name数字 id categories 表数字下标对应 names 顺序常用场景老数据集、LabelImg评测/COCO 系模型YOLO 系训练记住一句话VOC 和 COCO 都是绝对像素YOLO 是归一化。互转时最大的错就是忘了除以图像宽高。五、格式互转附脚本VOC → YOLOimportos,globimportxml.etree.ElementTreeasETfromPILimportImage CLASSES[defect,scratch]# 顺序必须与 data.yaml 的 names 一致cls_id{c:ifori,cinenumerate(CLASSES)}defvoc_to_yolo(xml_path,out_dir):rootET.parse(xml_path).getroot()wint(root.find(size/width).text)hint(root.find(size/height).text)lines[]forobjinroot.findall(object):nameobj.find(name).textifnamenotincls_id:continuebobj.find(bndbox)xmin,yminfloat(b.find(xmin).text),float(b.find(ymin).text)xmax,ymaxfloat(b.find(xmax).text),float(b.find(ymax).text)lines.append(%d %.6f %.6f %.6f %.6f%(cls_id[name],(xminxmax)/2/w,# cx 归一化(yminymax)/2/h,# cy 归一化(xmax-xmin)/w,# w 归一化(ymax-ymin)/h,# h 归一化))stemos.path.splitext(os.path.basename(xml_path))[0]withopen(os.path.join(out_dir,stem.txt),w,encodingutf-8)asf:f.write(\n.join(lines))forxinglob.glob(voc_annotations/*.xml):voc_to_yolo(x,labels)YOLO → VOC反向importglob,osfromPILimportImagedefyolo_to_voc(img_path,lbl_path,classes,out_xml):w,hImage.open(img_path).size objs[]ifos.path.exists(lbl_path):forlineinopen(lbl_path,encodingutf-8):pline.split()iflen(p)!5:continuecid,cx,cy,bw,bhint(p[0]),*map(float,p[1:])xminint((cx-bw/2)*w);yminint((cy-bh/2)*h)xmaxint((cxbw/2)*w);ymaxint((cybh/2)*h)objs.append((classes[cid],xmin,ymin,xmax,ymax))xml[annotation,f sizewidth{w}/widthheight{h}/height/size]forname,x1,y1,x2,y2inobjs:xml[ object,f name{name}/name,f bndboxxmin{x1}/xminymin{y1}/yminfxmax{x2}/xmaxymax{y2}/ymax/bndbox, /object]xml.append(/annotation)open(out_xml,w,encodingutf-8).write(\n.join(xml))COCO → YOLOCOCO 的坐标是绝对像素[x, y, w, h]且所有标注集中在一个 JSON 里需要按image_id归组importjson,osfromcollectionsimportdefaultdictdefcoco_to_yolo(coco_json,img_dir,out_dir,class_names):datajson.load(open(coco_json,encodingutf-8))id2name{c[id]:c[name]forcindata[categories]}name2idx{n:ifori,ninenumerate(class_names)}by_imgdefaultdict(list)foraindata[annotations]:by_img[a[image_id]].append(a)forimgindata[images]:w,himg[width],img[height]lines[]forainby_img.get(img[id],[]):x,y,bw,bha[bbox]# 绝对像素idxname2idx.get(id2name[a[category_id]])ifidxisNone:continuelines.append(%d %.6f %.6f %.6f %.6f%(idx,(xbw/2)/w,(ybh/2)/h,bw/w,bh/h))stemos.path.splitext(img[file_name])[0]open(os.path.join(out_dir,stem.txt),w,encodingutf-8).write(\n.join(lines))YOLO 训练用的data.yaml长这样names 的顺序必须和转换时的类别下标一致path:./datasettrain:images/trainval:images/valtest:images/testnames:0:defect1:scratch六、划分 train / val / test别让同源数据泄漏这是最隐蔽、也最致命的一步。如果同一段视频的相邻帧、或同一批拍摄的图被分到了 train 和 val 两边val 指标会虚高——因为模型在验证时见过几乎一样的图。正确做法是按组划分而不是按单张图随机分importrandomfromcollectionsimportdefaultdictdefgroup_split(stems,group_of,ratios(0.8,0.1,0.1),seed42):group_of: {stem: group_key}同一视频/同一批次给同一个 key。by_groupdefaultdict(list)forsinstems:by_group[group_of[s]].append(s)keyslist(by_group)random.Random(seed).shuffle(keys)# 固定种子划分可复现nlen(keys)n_tr,n_vaint(n*ratios[0]),int(n*ratios[1])tr,va,tekeys[:n_tr],keys[n_tr:n_trn_va],keys[n_trn_va:]picklambdaks:[sforkinksforsinby_group[k]]returnpick(tr),pick(va),pick(te)怎么定 group_key同一视频的帧 → 用视频名同一批次拍摄 → 用批次号同一张原图裁剪/增强出来的 → 用原图名。判断标准很简单“这两张图会不会让人一眼看出是同一个东西”——会就必须同组。七、数据集体检脚本标完之后跑一遍体检。这些问题都不会报错只会让指标失真importglob,osfromPILimportImagefromcollectionsimportCounterdefhealth_check(img_dir,lbl_dir,num_classes):imgs{os.path.splitext(os.path.basename(p))[0]:pforpinglob.glob(os.path.join(img_dir,*))}lbls{os.path.splitext(os.path.basename(p))[0]:pforpinglob.glob(os.path.join(lbl_dir,*.txt))}problems,dist[],Counter()forsteminimgs.keys()-lbls.keys():problems.append(有图无标签: stem)forsteminlbls.keys()-imgs.keys():problems.append(有标签无图: stem)forsteminimgs.keys()lbls.keys():w,hImage.open(imgs[stem]).sizefori,lineinenumerate(open(lbls[stem],encodingutf-8),1):pline.split()iflen(p)!5:problems.append(f{stem}第{i}行字段数{len(p)})continuecid,cx,cy,bw,bhint(p[0]),*map(float,p[1:])dist[cid]1ifcid0orcidnum_classes:problems.append(f{stem}第{i}行类别越界:{cid})ifnot(0cx1and0cy1and0bw1and0bh1):problems.append(f{stem}第{i}行坐标越界)returnproblems,dist体检要看五件事图文配对有没有有图无标签会被静默跳过或有标签无图坐标合法归一化是否在 0~1、宽高是否为正类别下标是否越界、是否和names对得上类别分布某一类只有几十个框时别指望它准划分泄漏抽查 val 里的图是否和 train 有同源。八、卡住时的排查顺序数据有问题时按这个顺序查别跳步图能打开吗—— 先scan_broken过一遍排除损坏文件标签有对应吗—— 跑health_check看图文配对坐标在范围内吗—— 体检脚本的坐标越界项类别对得上吗——names顺序 vs 标签里的下标划分泄漏了吗—— 抽查 val 与 train 是否同源可视化抽查—— 把框画到图上肉眼确认 10~20 张。最后一步别省。前五步是脚本能查的第六步是脚本查不出的标错了但格式没错——框偏了、标错类别、漏标目标只有画出来看才知道。最后一句数据集的质量上限就是模型效果的上限。与其花时间调参不如先把图文配对、坐标合法、划分无泄漏这三件事做扎实——它们花的时间最少收益最大。说明文中脚本为通用实现依赖pillow、imagehash去重格式定义VOC XML / COCO JSON / YOLO txt以各官方仓库与所用框架文档为准。类别命名、遮挡标注等规范为工程经验具体口径需结合自己的项目与团队统一。所有代码请先在小批量数据上验证再全量执行。