简介一套面向新能源汽车品牌与型号识别任务的VOC格式标注数据集覆盖特斯拉、北汽新能源、宝马、比亚迪秦/宋/唐、奇瑞、福特、江淮等常见品牌可用于目标检测、车辆分类及智慧交通场景的模型训练与算法验证。压缩包内共2000个XML标注文件整体大小约254MBXML文件记录了车辆目标的类别与边界框信息可直接接入LabelImg、Roboflow等标注流程或转换为YOLO等训练格式文件结构规整便于批量读取与数据划分。资源源自5391张正常采集的车辆图像覆盖多种道路与停车场景标注信息完整可直接用于模型微调、数据增广测试以及不同检测算法的效果对比省去自行拍摄和人工标注的时间成本。目前已有751人学习下载适合需要快速获取新能源汽车标注数据的算法工程师、学生及科研人员使用。1. 新能源汽车类型识别到底是个什么问题5391张图与VOC标注能干成什么不要小看这个压缩包标题里的信息密度新能源汽车类型识别说明任务不是简单的车/非车检测而是细粒度车型识别支持VOC格式标注意味着你拿到的是一份带XML框的可用数据集5391张正常采集的车辆信息则给出了数据规模和采集方式——不是网图爬虫而是真实场景拍摄。对做智慧停车、充电桩运营、二手车估值或保险定损的人来说这正好是启动车型识别功能的那批弹药一个能识别特斯拉、北汽新能源、宝马、比亚迪秦/宋/唐、奇瑞、福特、江淮等多类车型的检测模型训练好之后可以直接接到卡口或停车场摄像头的视频流里。不过先泼一盆冷水5391张对检测任务来说只算入门规模真正决定模型能不能用的在于标注质量、类别分布和你怎么划分数据集——这三件事正是这篇文章要逐个拆开讲清楚的。2. 拆开这个数据集类别盘点、VOC标注结构与数据划分2.1 先盘货把每个类别、每张图的目标数搞清楚拿到压缩包我的习惯是不要急着解压看图片先把Annotations目录下所有XML解析一遍统计每个类别出现了多少次、分布在多少张图里、每个框的宽高比是什么。这一步能发现很多问题类别拼写不一致、某些类图片数少得可怜、有些框坐标超出图像边界等。不要跳过这个步骤直接开训否则后面排错会花掉你几倍的时间。以下是一个可以直接跑的盘点脚本import glob import xml.etree.ElementTree as ET from collections import Counter xml_dir ./Annotations # VOC格式的XML目录 xml_list glob.glob(f{xml_dir}/*.xml) box_counter Counter() # 每个类别出现的框总数 img_counter Counter() # 每个类别出现的图片数 box_sizes [] # 所有框的宽高用于看目标尺度 for xml_path in xml_list: root ET.parse(xml_path).getroot() labels_in_img set() for obj in root.findall(object): name obj.find(name).text.strip() box obj.find(bndbox) x1 float(box.find(xmin).text) y1 float(box.find(ymin).text) x2 float(box.find(xmax).text) y2 float(box.find(ymax).text) if x2 x1 or y2 y1: # 非法框直接报警 print(f非法框: {xml_path} {name}) continue box_counter[name] 1 labels_in_img.add(name) box_sizes.append((x2 - x1, y2 - y1)) for name in labels_in_img: img_counter[name] 1 print(类别\t目标数\t图片数) for name, cnt in box_counter.most_common(): print(f{name}\t{cnt}\t{img_counter[name]})这段代码输出的第一张表是每个类别在全部标注里出现的目标总数和图片数。注意我用的是labels_in_img这个集合来统计图片数因为同一张图里同一个类别可能标了两辆车只算一次才符合有多少张图包含这个类的真实含义。第二张表是框宽高列表后面可以用来判断目标尺度如果绝大多数框的宽高在100像素以下说明是小目标场景训练时输入尺寸就不能盲目用640。跑完脚本你会对这5391张图到底能不能训有个明确判断类别数在12个左右平均每个类400多框其中尾部类别大概率只有几十框——这个分布就是后面所有策略的出发点。2.2 VOC标注格式拆解XML里每个字段都是什么VOC格式的核心是一个XML文件对应一张图片文件名相同、扩展名不同。例如NEV_000123.xml对应NEV_000123.jpg。打开一个典型的XML结构长这样annotation folderJPEGImages/folder filenameNEV_000123.jpg/filename size width1920/width height1080/height depth3/depth /size object name比亚迪秦/name poseFront/pose truncated0/truncated difficult0/difficult bndbox xmin512/xmin ymin388/ymin xmax1350/xmax ymax920/ymax /bndbox /object /annotationfilename对应JPEGImages里的图片名size里的width和height是转成YOLO格式时做归一化的分母必须按这个值来算不能按图片解码后的实际尺寸猜。每个object节点是一个标注目标name就是类别标签bndbox是像素级边界框xmin/ymin是左上角xmax/ymax是右下角。pose、truncated、difficult这三个字段在检测训练里经常被忽略但truncated和difficult值得多说一句truncated表示目标是否被图像边缘截断difficult表示该目标是否难以辨识原挑战赛语义是即使人眼也不易判断。很多转换脚本会无条件丢弃difficult1的框但我一般建议保留一部分当难例因为停车场场景里被柱子挡一半的车太常见了。还要注意一个XML里可能有多个object节点代表一张图里有多辆车。转换脚本如果只取第一个object等于把多车图里的其余车全扔了recall天然就低一截。这也是为什么我坚持先做2.1的盘点你能从类别和框数的比例里反推平均每张图几个目标心里有底。2.3 训练集与验证集划分别让同一辆车同时出现在两边拿到数据后最常见的问题是数据集没有自带训练验证划分文件或者只有一个train.txt没有val.txt。这种情况下自己划分是必须的。一个按比例随机划分的脚本很简单import glob, os, random random.seed(2025) # 固定种子保证复现 xml_list glob.glob(./Annotations/*.xml) ids [os.path.splitext(os.path.basename(p))[0] for p in xml_list] random.shuffle(ids) val_ratio 0.1 val_count int(len(ids) * val_ratio) train_ids, val_ids ids[val_count:], ids[:val_count] with open(train.txt, w) as f: f.write(\n.join(train_ids)) with open(val.txt, w) as f: f.write(\n.join(val_ids)) print(f训练 {len(train_ids)} 张, 验证 {len(val_ids)} 张)但这里有一个隐蔽的坑随机划分会把同一辆车的多个连续帧同时分进训练集和验证集。卡口或路边相机抓的图经常是几秒一帧这辆车在画面里停留的几秒钟就被拍成了好几张这几张高度相似。如果其中一部分进训练、一部分进验证模型相当于见过验证集里这辆车的具体长相val指标虚高拿到现场就露馅。遇到这种情况我的做法是先按文件名前缀做粗分组——很多采集任务会按设备ID_日期_片段号_帧号命名前缀相同的属于同一段连续画面没有这个规律就用车牌检测器把所有帧的车牌提出来按车牌聚类后整组划分。宁可验证集数量少一点也要保证验证集里出现的车训练集里绝对没有同款车。3. 从VOC到可推理的车型识别模型转换脚本与训练参数3.1 VOC转YOLO最小可用转换脚本与三个边界参数VOC格式本身不能直接喂给YOLO系列训练必须转成class x_center y_center width height的归一化txt格式每个txt文件名和对应图片一致。转换的核心是把像素坐标除以图像宽高得到0到1之间的相对坐标。下面是我常用的转换脚本可以直接放在数据集根目录跑import glob import os import xml.etree.ElementTree as ET CLASS_NAMES [ 特斯拉, 北汽新能源, 宝马, 世界, 比亚迪秦, 比亚迪宋, 比亚迪唐, 奇瑞, 易达, 福特, 江淮汽车, 江淮 ] # 务必和后续data.yaml的names一一对应 def voc2yolo(xml_path, out_dir): 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: return [] lines [] for obj in root.findall(object): name obj.find(name).text.strip() if name not in CLASS_NAMES: continue # 不在映射表里的类别直接跳过 diff int(obj.find(difficult).text or 0) if diff 1: continue # difficult1时可选丢弃这里默认丢 box obj.find(bndbox) x1 float(box.find(xmin).text) y1 float(box.find(ymin).text) x2 float(box.find(xmax).text) y2 float(box.find(ymax).text) x_center ((x1 x2) / 2) / img_w y_center ((y1 y2) / 2) / img_h w (x2 - x1) / img_w h (y2 - y1) / img_h # 裁剪到[0,1]防止坐标溢出 x_center max(0.0, min(1.0, x_center)) y_center max(0.0, min(1.0, y_center)) w max(0.0, min(1.0, w)) h max(0.0, min(1.0, h)) if w 0.001 or h 0.001: continue lines.append(f{CLASS_NAMES.index(name)} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) return lines xml_list glob.glob(./Annotations/*.xml) os.makedirs(./labels, exist_okTrue) for xml_path in xml_list: stem os.path.splitext(os.path.basename(xml_path))[0] lines voc2yolo(xml_path, ./labels) if lines: with open(f./labels/{stem}.txt, w) as f: f.write(\n.join(lines))这里有三个参数值得你根据实际数据调整。第一个是difficult字段的取舍我默认丢弃但如果你的部署场景大量存在遮挡车辆建议改成保留一半甚至全部把它们当作hard example第二个是坐标裁剪有些标注员会画出图片边界外的框不裁剪的话归一化坐标会大于1训练时损失直接算出一个NaN把整个训练搞崩第三个是类别映射表CLASS_NAMES的顺序必须和后面训练配置里的names序号完全一致否则第0类就会错位成第3类指标再高也是废的。3.2 训练配置backbone、输入尺寸、batch与epochs怎么定转换完成后写一个训练用的数据配置文件注意names顺序和上面的CLASS_NAMES保持一致path: ./dataset train: images/train val: images/val names: 0: 特斯拉 1: 北汽新能源 2: 宝马 3: 世界 4: 比亚迪秦 5: 比亚迪宋 6: 比亚迪唐 7: 奇瑞 8: 易达 9: 福特 10: 江淮汽车 11: 江淮关于世界和易达这两个名字我想多说一句它们不像标准车型名很可能是原始标注时输入法联想或方言缩写造成的脏标签。在第4章我会专门讲怎么核对和归一化但在配置文件里先原样保留是为了让类别数上下对齐不出错。训练命令我用的是YOLOv8的CLI这也是目前跑检测最省事的路径yolo detect train \ datanev_type.yaml \ modelyolov8s.pt \ imgsz640 \ epochs150 \ batch16 \ lr00.01 \ cos_lrTrue \ workers8 \ seed2025 \ projectruns/detect \ namenev_type参数这么定的理由modelyolov8s.pt是带COCO预训练权重的车辆几何结构、车灯、车窗这些底层特征已经被COCO里的car类学过了在这个基础上微调比随机初始化少花一半时间而且最终精度更高所以不要用yolov8s.yaml从头训。imgsz640是速度和精度的平衡点如果现场是大俯视镜头、车辆在画面里占比很小可以试1024但推理耗时会上浮。epochs设150不是死数5391张图这个规模基本够用重点是配合早停看val指标——连续30个epoch不涨就停省时间也防过拟合。batch16在单张24G显存上比较稳显存不够优先降batch而不是降imgszlr00.01配cos_lr是这个任务里最不容易翻车的组合如果loss震荡就降到0.005。3.3 验证与指标解读map50、map50-95和混淆矩阵怎么用训练完先跑一个标准验证yolo detect val \ modelruns/detect/nev_type/weights/best.pt \ datanev_type.yaml \ conf0.25 \ iou0.5输出里最核心的两个指标是box map50和box map50-95。对车型识别这种任务我建议主要盯着map50看因为它只要求框和真实框的IoU达到0.5就算命中更接近现场能不能把车框出来、类别认不认对的真实诉求map50-95是IoU从0.5到0.95按0.05步长取平均要求框得非常准才拿高分这在智慧停车场景里意义不大但在需要精确裁剪车标做二次识别的场景里很有参考价值。比指标更重要的是把混淆矩阵打出来重点看哪些类别互相串。从我的经验看比亚迪秦和特斯拉Model 3最容易互认——都是紧凑型轿车、侧面轮廓接近、车标又小检测头在低分辨率特征图上很难区分。遇到这种情况单靠调imgsz和epochs解决不了要在后面加一个车标区域的细粒度分类分支这也是第6章要展开的做法。4. 数据质量与正常采集图类别不平衡、重复标签与异常类目处理4.1 正常采集四个字决定了迁移和增强策略标题里正常采集这四个字比5391张更值钱。网络爬图拿到的车辆图片分辨率参差不齐、横幅水印遍地、还会有大量摆拍和渲染图模型学到的常是水印纹理而不是车本身正常采集的图来自真实相机光照、角度、遮挡分布和部署现场接近迁移成本低。但正常采集也有它的局限视角往往集中在正前和正后侧面样本偏少而且同一批采集车在相近时间段内拍摄光线条件可能高度一致——你拿到的可能是一大批大白天背光或阴天的图。所以训练时要补上对光照和角度的增强yolo detect train \ datanev_type.yaml \ modelyolov8s.pt \ imgsz640 \ epochs150 \ batch16 \ hsv_h0.015 \ hsv_s0.7 \ hsv_v0.5 \ degrees10 \ fliplr0.5 \ mosaic1.0hsv_h对色相做轻微扰动防止模型把品牌车漆颜色当成分类特征hsv_s和hsv_v分别对饱和度和明度做大范围扰动模拟清晨、黄昏、逆光和过曝degrees10对图像做小角度旋转缓解采集视角单一的问题mosaic1.0把四张图拼成一张训练既变相扩大batch内的场景多样性又让模型更适应一辆车被裁切到画面边缘的情况。这些参数对正常采集的数据特别有效因为这类数据本来就干净不主动加扰动模型很容易只记住采集现场的固定色调。4.2 类别不平衡与江淮汽车/江淮重复标签跑完2.1的盘点脚本后你大概率会看到这样一个分布特斯拉和比亚迪的几张车系各有几百上千框而江淮、北汽新能源、易达这些尾部类别可能只有几十框甚至出现江淮汽车和江淮同时存在的情况。后者我先给结论这几乎肯定是同一品牌在不同批次标注时写法不统一造成的正常情况一个数据集里不该同时存在这两个类。遇到疑似重复标签先别急着合并用下面的脚本把两个类的图片列表拉出来对比import glob import xml.etree.ElementTree as ET def find_images(label_name): imgs [] for xml_path in glob.glob(./Annotations/*.xml): root ET.parse(xml_path).getroot() for obj in root.findall(object): if obj.find(name).text.strip() label_name: imgs.append(xml_path.replace(Annotations, JPEGImages).replace(.xml, .jpg)) break return imgs a find_images(江淮汽车) b find_images(江淮) print(f江淮汽车: {len(a)} 张, 江淮: {len(b)} 张)然后从两组里各抽20张图放在一起看如果图片里的车确实是同一类就做标签归一化把所有江淮改成江淮汽车并重新生成XML这不是可有可无的洁癖——类别数多了尾类被进一步稀释本身就是精度下降的原因。对于真正的类别不平衡我的处理思路分三步小类图片数少于50的先用复制粘贴开旋转/翻转做离线扩充补到100张以上再在训练时给小类更高的采样权重如果补完还是不够就把该类从检测任务里拆出去只保留二分类是否为该品牌效果会比硬训一个12类检测器好得多。4.3 世界易达这类异常类目先抽样核对再归一化标题里可识别……世界……易达……这两个名字第一次看到的人多半会愣一下因为市面上没有叫世界或易达的主流新能源品牌。按我的经验这大概率是原始标注环节里产生的三类问题之一输入法联想把蔚来打成了未来再被OCRed成世界地方合作方用方言俗称标注品牌或者标注软件导入Excel时字段错位。无论哪种都不要直接在配置文件里删掉这个类——一旦删除这些图片里原本标注好的检测框就全浪费了。正确流程是先把该类的所有图片抽出来拼成网格图人工过一遍如果里面确实是一类你没见过的品牌车保留并给它补一个正确的品牌名如果内容混杂、噪声极大就整类删除并连同XML一起清理如果绝大多数是某个已知品牌的不同角度做标签归一化映射到已有类别。这个过程很枯燥但5391张图本身就不算大数据集每一张干净的有效样本都直接影响尾类精度值得花一两个小时。5. 车型识别避坑指南5个容易翻车的现场与排查方法5.1 坑XML里类别大小写与别名混用类别数凭空翻倍现象训练时配置文件里明明只写了12个类跑起来的类别数却有14个或者loss一直在降但val精度上不去。原因同一品牌在不同XML里被写成了BYD秦和比亚迪秦程序按字符串比较时把它们当成两个类数据被切成两份每个类的有效样本都少了。解决用2.1的Counter脚本把name字段全部打印出来人工核对所有名变体做一张别名映射表在转换脚本里统一归一化。这里最隐蔽的是空格和全角字符比如比亚迪 秦和比亚迪秦肉眼几乎分不清但程序严格区分。5.2 坑一图多车只标了主车recall上不去现象训练时map50mono能到0.85以上但拿到现场视频一测recall掉到0.6大量车辆根本没被框出来。原因原始标注为了省事只标了画面正中间那台车停在同一排的其他车全没标。模型训练时把那些未标注的车当背景等于反复告诉它这些车不是车自然学到漏检。解决用训练好的模型对全部5391张图跑一遍推理把高置信度但没有对应真实框的检测结果抽出来和原标注叠加对比确认是漏标就补框。如果漏标太多更快的办法是把这类多车场景图挑出来单独用半自动标注工具过一遍不要指望模型自己学会忽略标注错误。5.3 坑同品牌新旧款前脸差异大验证集有余、现场漏检现象验证集上特斯拉的精度很高但现场来了一辆改款后的Model 3直接认成比亚迪秦或者干脆漏掉。原因老旧款和改款之间的大灯、格栅、保险杠设计差异极大而数据集里往往只覆盖了其中一个世代。验证集里没有新款样本指标自然暴露不了。解决这个坑是纯数据问题只能补图。补图时注意别只搜正面照要覆盖45度角和尾部因为车型识别在卡口场景里往往只有车头或车尾单视角训练时正侧图太多反而会干扰部署效果。如果短期补不到足够的新款图至少把品牌识别和年款识别拆成两个任务品牌任务对新款更鲁棒。5.4 坑光照与色调分布不均衡模型学的是亮度而不是车现象训练集里大量白天强光下的图验证集指标正常但一上夜间的视频就全崩。原因正常采集的数据如果是同一时段拍的整体亮度集中在很窄的区间模型很容易偷懒用亮度特征做分类。这也是爬图数据在某种程度上的优势——网图的光照多样性反而更高。解决训练时把hsv_v调大到0.5以上并且在增强里加入随机gamma校正。如果夜间场景是你的主要部署场景最稳妥的办法不是靠增强硬撑而是用夜间图微调一轮哪怕只有两三百张效果也远好过堆增强参数。增强能兜底但补几个典型场景的图是最实在的后悔药。5.5 坑训练验证划分随意指标虚高却不自知现象val的map50高达0.97但同一个模型在另一条路的相机上测试精度断崖式下跌。原因就是2.3里说的同车入两集——同一辆车的多帧被随机分到训练和验证模型记住了车的个体特征而不是特斯拉这个类的共性。这种情况在卡口拍摄的连续帧数据里尤其严重因为同一辆车在几秒内会留下十几张几乎一样的图。解决划分前先按文件名前缀或车牌聚类做去重分组验证集里出现过的车训练集绝对不能出现如果聚类做不到就按时间连续段划分前90%的时间段进训练、后10%进验证用时间隔离来近似车辆隔离。指纹级别的同车泄漏是车型识别项目里最隐蔽的翻车点没有之一。6. 把车型ID升级为多属性输出让一个目标框同时给出品牌、车系与车身颜色检测模型训到能用之后你很快会发现部署现场的需求永远比这是比亚迪秦多一层用户想知道这是哪年款、什么颜色、是纯电还是混动。如果每增加一个属性就重新训一个检测器成本太高也不现实。常见做法是在现有检测模型的特征图上并行加几个分类头共享同一个backbone每张图只做一次前向推理同时输出多个属性。示意如下# 多任务头的简化示意 class MultiAttrHead(nn.Module): def __init__(self, feat_dim, num_brand, num_model, num_color): super().__init__() self.brand_head nn.Linear(feat_dim, num_brand) self.model_head nn.Linear(feat_dim, num_model) self.color_head nn.Linear(feat_dim, num_color) def forward(self, x): return { brand: self.brand_head(x), # 品牌ID model: self.model_head(x), # 车系ID color: self.color_head(x), # 车身颜色ID }训练数据从哪来还是这份VOC数据集。把每个bbox从原图里裁剪出来作为图块品牌标签直接用原XML的name车系标签可以在品牌下做细分比如比亚迪品牌下再分秦、宋、唐颜色标签可以先把所有图块转到HSV空间做聚类粗标再人工确认一批能省一半标注时间。损失函数用三个分支的交叉熵直接相加类别少的属性分支可以单独加权。这个多任务方案在推理时几乎不增加耗时却让一套模型同时回答什么品牌、什么型号、什么颜色三个问题现场竞争力完全不一样。我最早做这类项目时只训了品牌ID结果客户在验收时追着问比亚迪宋是哪个宋、什么颜色最后返工加了车系和颜色分支。所以第一版结构设计就把多属性输出留好这个习惯希望帮到你。本文还有配套的精品资源点击获取