ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

欧盟车牌识别实战:534张VOC数据转YOLO格式训练全流程

欧盟车牌识别实战:534张VOC数据转YOLO格式训练全流程 简介534张欧盟车牌图像数据集面向车牌检测与OCR字符识别任务适合计算机视觉初学者、算法工程师及自动驾驶研发人员用于模型训练、算法验证与数据集制作流程练习。压缩包共1070个文件包含534张jpg原图与一一对应的534个xml标注文件VOC格式由VOTT工具标注另有1个txt说明和1个md文档辅助使用整体包体约428MB。标注框覆盖欧盟各国常见车牌样式可作为目标检测、车牌定位、字符分割等任务的直接训练数据。目前已有185人浏览学习配图包含白天/夜晚、不同行驶场景的监控视角能够帮助读者检验模型在光照变化与多角度条件下的鲁棒性。资源还保留VOTT标注过程的产物便于了解从图像采集到标签生成的完整链路。1. 做一个欧盟车牌识别项目这份534张的图像数据集够不够用接到一个跨境物流园的车牌识别需求摄像头拍到的几乎全部是欧盟牌照客户要求先检测车牌区域再送字符识别。搜了一圈现成数据集国内蓝牌为主的不匹配最后锁定了这份534张欧盟车牌图像数据集带VOC格式标签标注工具是微软的VOTT。第一反应是量太小——训练目标检测千张起步是常识。但拆开压缩包仔细看完标注质量之后我反而觉得它正适合当启动数据单类目标、标注完整、场景集中在道路和停车场跑出一条可用的检测基线完全够用剩下的问题是怎么把有限的样本用好。这篇按我拿到这个zip之后的完整处理链条讲VOC格式里哪些字段是坑、VOTT标注留下的隐藏毛病、怎么用脚本把534张图完整校验再转到YOLO格式直接开训最后落到训练参数和五条避坑经验。适合刚入目标检测想跑通全流程的人也适合在做车牌识别但没接触过欧盟车牌数据的人参考。2. VOC格式拆解与VOTT标注校验534个XML里藏着什么2.1 VOC数据集的目录结构与XML关键字段VOC格式最早是PASCAL VOC比赛定义的标注规范后来成了目标检测社区的事实标准之一。一个完整的VOC数据集通常包含三个目录JPEGImages放原始图像Annotations放与图像同名的XML标注文件ImageSets/Main放train.txt、val.txt这类划分清单。这套zip包是不是三个目录都有决定你拿到手后能不能直接开训。常见的社区打包习惯是只保留JPEGImages和AnnotationsImageSets让使用者自己生成所以先花两分钟看一眼目录结构别急着写训练脚本。XML本身是树状结构根节点annotation下挂着filename、path、source、size、segmented再往下是若干个object节点。object节点里的name就是类别名bndbox是标注框的绝对像素坐标xmin、ymin、xmax、ymax。读VOC标注本质上就是把这四个坐标和name提取出来其他字段比如difficult、truncated是辅助信息对现在的单类检测器没有影响。这个数据集用的是VOTT标注VOTT导出VOC时通常不会把difficult、truncated写全甚至直接缺省这都不要紧。真正要紧的是filename字段——VOTT在老版本里有可能把filename写成导出时的临时文件名而不是和图片一致的名字。我拿到手习惯先随机打开三个XML对一下filename和JPEGImages目录里的文件名单再往下走。如果发现对不上第3章的解压规划里就要加一步统一重命名否则后续转YOLO格式时会凭空多出一堆图片不存在的报错。2.2 VOTT标注导出VOC的五个隐藏毛病VOTT是微软的Visual Object Tagging Tool基于Electron界面友好适合手工框选目标。但它的导出结果有几个固定毛病做数据清洗时最好有心理准备。第一个是filename字段不可靠。老版本VOTT在导出VOC时filename可能带路径前缀、可能有扩展名、可能没有甚至可能和图片实际文件名不一致。不要迷信这个字段后面转换脚本要改成按XML文件名去匹配图片。第二个是size字段和实际图片尺寸可能对不上。VOTT记录的是标注时画布上的尺寸如果图片在打包前被批量压缩过XML里的width和height就成了过期值。用标注坐标除以这个过期尺寸做归一化得到的YOLO坐标会集体偏移。正确做法是转换时用OpenCV重新读一次图片以实际尺寸为准。第三个是框的本质是外接矩形。VOTT只提供水平和垂直的矩形框EU车牌如果斜着停放外接矩形会把车牌的邻居——散热格珊、保险杠、地面阴影——一起包进来。标注本身没有错但框的宽松程度会影响检测器对车牌边缘的回归精度。这个问题在可视化复查阶段会暴露得很明显。第四个是EXIF旋转导致坐标错位。VOTT读图走的是浏览器内核会自动应用EXIF方向信息但导出的坐标是相对显示方向的。如果原始图片带EXIF旋转标记而训练框架读图时用OpenCV不处理EXIF那么坐标会整体偏移90度或180度。从互联网渠道收集的车牌图大多是截图不带EXIF这个坑一般不会踩到但保险起见可以从534张里抽几十张验证尺寸方向。第五个是半标注帧。VOTT允许跳过某些帧不标注导出VOC时这些帧可能生成空object的XML也可能根本不生成XML。这两种情况都要在清洗阶段识别出来。空XML影响不大训练时对应图片没有标签文件而已但如果你按XML个数图片个数来核对数据完整性就会被它干扰。2.3 用Python脚本校验534张图的标注完整性下面这个脚本是我拿到VOC标注数据后必跑的第一段代码作用是找出所有有问题的XML并统计类别和目标数分布。它能一次性筛掉后面训练阶段80%的玄学报错。import xml.etree.ElementTree as ET from pathlib import Path from collections import Counter def check_voc(annot_dir: str, img_dir: str): xml_files sorted(Path(annot_dir).glob(*.xml)) class_names set() obj_count Counter() issues [] for xml_path in xml_files: try: root ET.parse(xml_path).getroot() except ET.ParseError as e: issues.append(f{xml_path.name}: XML语法错误 {e}) continue filename (root.findtext(filename) or ).strip() img_path Path(img_dir) / filename if not img_path.exists(): issues.append(f{xml_path.name}: 图片 {filename!r} 不存在) continue size root.find(size) img_w int(size.findtext(width)) img_h int(size.findtext(height)) objs root.findall(object) obj_count[len(objs)] 1 for obj in objs: name (obj.findtext(name) or ).strip() class_names.add(name) box obj.find(bndbox) xmin float(box.findtext(xmin)) ymin float(box.findtext(ymin)) xmax float(box.findtext(xmax)) ymax float(box.findtext(ymax)) if xmin xmax or ymin ymax: issues.append(f{xml_path.name}: 非法坐标 ({xmin},{ymin},{xmax},{ymax})) if xmin 0 or ymin 0 or xmax img_w or ymax img_h: issues.append(f{xml_path.name}: 坐标越界 vs {img_w}x{img_h}) return issues, class_names, obj_count issues, class_names, obj_count check_voc(Annotations, JPEGImages) for line in issues: print(line) print(出现过的类别:, class_names) print(每张图目标数量分布:, dict(sorted(obj_count.items())))逻辑说明先解析XML捕获语法错误然后用filename去JPEGImages目录找图找不直接说明文件名对不上接着检查bndbox的四个坐标是否构成合法矩形以及是否超出图像边界。最后把所有出现过的类别名收集起来打印每张图的目标数量分布。参数说明annot_dir和img_dir分别指向Annotations和JPEGImages目录按实际路径修改。正常运行完类别应该只有一种比如plate或license_plate每张图目标数量大多是1少量是2或0。如果出现目标数量为0的XML要么是VOTT的半标注帧要么是漏标直接删除该XML即可如果出现坐标越界需要在第4章转换时做裁剪而不是直接丢掉整张图。3. 从zip到可训练目录解压、统计与可视化复查3.1 解压命令与目录规划拿到了zip压缩包第一件事不是双击解压而是想清楚放在哪。强烈建议放在纯英文路径下不要放桌面、不要放我的文档这类带空格或中文的路径深度学习框架对中文路径的支持至今仍是灾难现场。Linux下用unzip命令解压mkdir -p EU_plate_dataset unzip -q ./534张欧盟车牌图像数据集(带VOC格式标签使用VOTT标注).zip -d EU_plate_dataset/ cd EU_plate_dataset find . -maxdepth 2 -type d | sort参数说明unzip的-q是静默模式不给它的话534个XML会刷屏-d指定解压目标目录。文件名带中文和括号bash里必须用引号包住。解压后先看一眼目录结构确认JPEGImages和Annotations两个目录是否存在图片扩展名是jpg还是png这决定后面的转换脚本怎么写。补充一个Linux下常见翻车点如果zip是在Windows上用国产压缩软件打的内部文件名可能是GBK编码解压出来全是乱码。遇到这种情况用unzip -O CP936重新解压或者用7-Zip在Windows端转成UTF-8再打包。另外把原始zip保留一份不要动备份是后续误删标注时的后悔药。目录规划我一般会补一个ImageSets/Main目录即使这份zip没带也自己建一个后面划分训练集和验证集时用得上。最终结构是这样EU_plate_dataset/ ├── JPEGImages/ # 534张图像 ├── Annotations/ # 534个VOC XML └── ImageSets/ └── Main/ # 自建放划分清单3.2 用统计脚本决定imgsz和增强策略534张图不是直接送进网络的先跑一段统计脚本搞清楚图像分辨率和标注框的尺寸分布。这一步得出的三个数直接决定后面训练时imgsz设多大、需不需要对小目标做特殊处理。import numpy as np import xml.etree.ElementTree as ET from pathlib import Path areas, ratios, img_sizes [], [], [] for xml_path in Path(Annotations).glob(*.xml): root ET.parse(xml_path).getroot() size root.find(size) img_w int(size.findtext(width)) img_h int(size.findtext(height)) img_sizes.append((img_w, img_h)) for obj in root.findall(object): box obj.find(bndbox) w float(box.findtext(xmax)) - float(box.findtext(xmin)) h float(box.findtext(ymax)) - float(box.findtext(ymin)) areas.append(w * h) ratios.append(w / h) areas np.array(areas) ratios np.array(ratios) print(f图像分辨率种类: {len(set(img_sizes))} 种最大 {max(img_sizes)}) print(f框面积中位数: {np.median(areas):.0f} px²5%分位 {np.percentile(areas, 5):.0f} px²) print(f框宽高比中位数: {np.median(ratios):.2f})逻辑说明遍历所有XML从size字段读图像分辨率从bndbox算标注框面积和宽高比最后输出三个关键统计量。参数说明框面积中位数如果只有几千像素说明车牌在图中是小目标训练时要把imgsz从默认的640升到960否则下采样后特征会丢光。宽高比中位数在2到4之间属正常EU车牌整体轮廓偏宽如果大量出现大于5的比值多半是标注框把车身也包进去了这类样本可视化复查时重点关照。图像分辨率种类越多说明图片来源越杂训练时letterbox的填充比例差异会很大要不要在增强阶段加随机缩放就看这个数字。3.3 可视化复查把标注框画到图上统计只能发现问题可视化才能看清问题。我用OpenCV把标注框画回原图拼成3×3网格每张图缩放到统一尺寸后贴在一起一张图就能检查九张的标注质量。import cv2 import numpy as np import xml.etree.ElementTree as ET from pathlib import Path def draw_boxes(img_path: Path, xml_path: Path): img cv2.imread(str(img_path)) root ET.parse(xml_path).getroot() for obj in root.findall(object): box obj.find(bndbox) xmin int(float(box.findtext(xmin))) ymin int(float(box.findtext(ymin))) xmax int(float(box.findtext(xmax))) ymax int(float(box.findtext(ymax))) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, obj.findtext(name), (xmin, max(ymin - 5, 0)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 1) return img xml_files sorted(Path(Annotations).glob(*.xml))[:9] tiles [] for xml_path in xml_files: img draw_boxes(Path(JPEGImages) / (xml_path.stem .jpg), xml_path) img cv2.resize(img, (420, 420)) tiles.append(img) col1 np.vstack(tiles[0:3]) col2 np.vstack(tiles[3:6]) col3 np.vstack(tiles[6:9]) canvas np.hstack([col1, col2, col3]) cv2.imwrite(check_grid.jpg, canvas)逻辑说明draw_boxes函数读取XML里的bndbox用绿色矩形画到图上并在框左上角写出类别名。三个3行拼成列再把三列拼成一张9宫格大图。画框是在resize之前做的所以缩放后框和图像的对应关系不会错位。看这张图时重点关注三件事框是否紧贴车牌边缘、框是否明显偏大或偏小、框是否偏离车牌位置。EU车牌左侧有蓝色条带框的左边线应该贴住蓝条右边线贴住最后一个字符上下各留少量余量。如果大量框都包进了散热格珊或保险杠说明VOTT外接矩形的问题比较普遍这批样本在转换完成后要单独拉出来考虑重新标注或直接删掉。注意参数里putText的ymin-5可能为负用max函数兜底否则文字会画到画布外。4. 把VOC转成YOLO格式转换脚本与四个边界坑4.1 为什么非转不可现代YOLO系训练框架的默认标签格式是txt每行一个目标字段为类别ID、归一化中心点x、归一化中心点y、归一化宽、归一化高。VOC的XML虽然信息更全但训练时每张图都要解析一遍XML还要做坐标换算效率低且容易出错。转换之后的数据集更通用换框架、做数据筛选、跑评估脚本都方便。转换的核心有两点一是坐标必须归一化到0到1之间和图像实际宽高挂钩这样不管输入分辨率怎么变标签都不用改二是以实际读到的图像尺寸为准不信任XML里的size字段。4.2 转换脚本与参数说明import os import cv2 import xml.etree.ElementTree as ET from pathlib import Path CLASSES [plate] # 类别ID按列表顺序分配plate0 def voc_to_yolo(xml_path: Path, img_dir: str, out_dir: str): root ET.parse(xml_path).getroot() filename (root.findtext(filename) or ).strip() img_path Path(img_dir) / filename # 如果filename对不上改用xml的stem去匹配jpg/png if not img_path.exists(): for ext in (.jpg, .jpeg, .png): candidate Path(img_dir) / (xml_path.stem ext) if candidate.exists(): img_path candidate break img cv2.imread(str(img_path)) if img is None: print(f[ERROR] 读不到图片 {img_path}跳过) return img_h, img_w img.shape[:2] lines [] for obj in root.findall(object): name (obj.findtext(name) or ).strip() if name not in CLASSES: print(f[WARN] {xml_path.stem}: 类别 {name!r} 不在CLASSES里跳过) continue cls_id CLASSES.index(name) box obj.find(bndbox) xmin float(box.findtext(xmin)) ymin float(box.findtext(ymin)) xmax float(box.findtext(xmax)) ymax float(box.findtext(ymax)) # 归一化并裁剪到[0,1]防止越界框 cx ((xmin xmax) / 2) / img_w cy ((ymin ymax) / 2) / img_h bw (xmax - xmin) / img_w bh (ymax - ymin) / img_h cx min(max(cx, 0.0), 1.0) cy min(max(cy, 0.0), 1.0) bw min(max(bw, 0.0), 1.0) bh min(max(bh, 0.0), 1.0) if bw 0.001 or bh 0.001: print(f[WARN] {xml_path.stem} 存在面积过小的框已跳过) continue lines.append(f{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) out_path Path(out_dir) / (img_path.stem .txt) out_path.write_text(\n.join(lines)) os.makedirs(labels, exist_okTrue) for xml_file in Path(Annotations).glob(*.xml): voc_to_yolo(xml_file, JPEGImages, labels) print(转换完成)逻辑说明先解析XML取filename找图片找不到就退回到用XML文件名加扩展名匹配兼容VOTT filename不可靠的问题。然后读取图片实际尺寸逐个object把bndbox坐标转成YOLO格式中间做一次clip防越界最后把大于0.001宽高的框写入txt。输出文件与图片同名扩展名是.txt。参数说明CLASSES列表的顺序就是类别ID目前只有一类plate如果以后要加另一类比如truck必须保持这个列表和data.yaml里的names一致。clip把坐标强制限制到0到1之间但要注意对于一个完全在图像外的错误框clip后会出现cx、cy都在边缘的情况这种框训练时会产生错误梯度所以后面跟了一个最小宽高过滤。4.3 四个边界坑坑一filename字段和实际文件名对不上。现象是转换脚本报读不到图片但文件明明就在JPEGImages里。原因是VOTT导出时filename写成了临时名。解决方法是像上面代码那样发现filename找不到图就用xml的stem重新匹配jpg、jpeg、png。这样处理之后大部分情况都能兜住。坑二XML里的size和实际图像尺寸不一致。现象是转换出来的txt坐标看起来正常但画回图上全部偏移框不在车牌上。原因是图片打包前被重新压缩过XML里的width和height没同步更新。解决方法是只在cv2.imread之后用shape取宽高完全无视XML的size字段。坑三标注框超出图像边界。现象是clip之后某些框挤在图像边缘训练时AP曲线在低IoU区间上不去。原因是VOTT允许标注框延伸到画布之外。解决方法是clip前先记录越界框的数量和文件名如果数量不多直接删掉这些box如果数量多clip后把那些中心点落在边缘1%区域的框也过滤掉。坑四空标签文件。现象是转换完发现labels目录下有些txt是0字节训练时对应图片被跳过或报警。原因是源XML里有object为空或所有非法框都被过滤。解决方法是转换结束后跑一条find命令统计空文件把空txt对应的XML和图片一起归档到backup目录不让它们参与训练。5. 训练这534张图的避坑指南与参数选择5.1 划分数据集与data.yaml534张图里分出训练集和验证集比例选8比2训练集约427张验证集约107张。数据集本身不大验证集不必追求多保持分布接近训练集更重要。划分要注意先打乱再切否则按文件名字典序切出来的验证集会全是同一个场景的连拍图评估结果虚高。import random import shutil from pathlib import Path random.seed(42) img_files sorted(Path(JPEGImages).glob(*.jpg)) random.shuffle(img_files) split_idx int(len(img_files) * 0.8) train_files img_files[:split_idx] val_files img_files[split_idx:] for split, files in [(train, train_files), (val, val_files)]: img_out Path(fimages/{split}) lab_out Path(flabels/{split}) img_out.mkdir(parentsTrue, exist_okTrue) lab_out.mkdir(parentsTrue, exist_okTrue) for img_path in files: shutil.copy(img_path, img_out / img_path.name) label_path Path(labels) / (img_path.stem .txt) if label_path.exists(): shutil.copy(label_path, lab_out / label_path.name) else: print(f[WARN] {img_path.name} 缺少标签文件)逻辑说明随机数种子固定为42保证可复现。先把图片列表打乱按8比2切分再把图片和对应的txt标签同步复制到images/train和images/val目录下。参数说明split_idx是切分点索引8比2的比例对单类且数据量小的场景比较合适。发现缺标签文件时打印警告不要静默跳过这通常意味着第4章的转换脚本漏处理了一些XML。接下来建data.yaml路径用绝对路径train和val指向上面生成的目录path: /你的绝对路径/EU_plate_dataset train: images/train val: images/val names: 0: plate训练命令我用Ultralytics的YOLOv8起步小模型在小数据上不容易过拟合效果好坏主要看数据清洗和增强参数模型容量反而不是第一位yolo detect train dataEU_plate.yaml modelyolov8n.pt \ epochs200 imgsz640 batch16 patience50 \ hsv_h0.02 hsv_s0.8 hsv_v0.6 \ flipud0.0 fliplr0.5 degrees2.0参数说明imgsz按第3章统计的结果来框面积中位数小就改960。hsv_s和hsv_v调大是为了增强对光线和车身反光的鲁棒性EU车牌的蓝色条带在不同光照下饱和度差异很大。flipud必须为0现实场景中几乎没有上下颠倒的车牌fliplr保留0.5检测阶段只回归框水平翻转不影响degrees设为2度车牌不会大角度倾斜但2度的随机旋转能模拟车辆轻微摇摆。5.2 小数据增强策略少样本下哪些增强有用534张图算小样本增强策略直接决定模型能不能收敛。YOLOv8默认的mosaic增强对这类数据是有效的它把四张图拼成一张模型被迫在小图上识别目标等价于做了尺度扰动。copy-paste增强对车牌意义不大车牌本身不会出现在车身背景之外的位置硬贴上去反而制造假样本。提升最明显的是光度畸变。EU车牌的判读依赖字符和背景的对比度阴天、逆光、夜间这三种光线条件蓝色条带会呈现完全不同的HSV分布。把hsv_s调到0.8、hsv_v调到0.6之后等于让模型见过更多虚拟光照条件实测对晴天模型直接跑到阴天场景的泛化有帮助。还有一个容易忽略的点534张图里如果存在大量连拍帧相邻帧之间几乎没有变化那么有效样本量可能只有三百多。建议划分前先按文件名或时间戳对相似帧做去重保留差异最大的否则验证集会虚高。5.3 五条踩坑记录现象一训练了150轮valloss在降但val mAP一直是0。原因是类别ID映射错误最常见的是data.yaml里的names顺序和第4章CLASSES列表顺序不一致或者标签txt第一列的类别号超出了names总数。解决方法是训练前随机抽10个txt打印首行肉眼确认class id为0再检查data.yaml里names[0]对应的是plate。现象二mAP0.5达到0.93但实拍视频里把蓝色路牌误检成车牌。原因是534张全是正样本模型学到的主要特征是蓝色矩形而不是蓝条字符纹理这个复合特征。解决方法是收集200到300张不含车牌的背景图加入验证集做误报检测如果背景图也被高置信度检出来就需要在训练集里混入一部分负样本图让模型学会区分。现象三同一个车牌近处能检出来远处极小框检不出来。原因是标注框面积太小YOLO下采样32倍后小框特征不足1像素。解决方法是把imgsz从640升到960并用SAHI切片推理做线上检测把1080p图切成4块分别推理再合并结果。现象四训练时loss曲线震荡中途出现NaN。原因是学习率偏高加上个别标签在clip之后宽度或高度变成0前向计算出现除零。解决方法是训练前用find命令找出0字节txt并删除把学习率从默认0.01降到0.003开启warmup。现象五验证集loss降不下去总是卡在某个平台期。原因是部分标注框过于宽松把车牌的散热格珊也包进来了模型反复在学车牌到底从哪里开始。解决方法是统计每张图的框面积占比把占比最高的5%样本单独抽出来人工复查框确实松的重新标注或直接删除量少的话删除比重标更快。6. 验证进阶从置信度阈值到OCR数据扩展6.1 用F1曲线选置信度阈值而不是只看mAPmAP是排序指标反映的是模型对正负样本的整体区分能力但线上系统需要的是一个固定的置信度阈值。这个阈值选得不好要么误检多要么漏检多。我一般会在训练结束后对验证集跑一遍预测收集每个预测框的置信度、是否命中GT然后画一条F1随阈值变化的曲线取F1最高点对应的置信度作为线上阈值。这一步很少有人做但它在落地时比mAP高几个点更管用。具体做法不复杂在Ultralytics的val输出里带上save_json参数会得到一个包含所有预测框置信度的JSON写十几行Python把结果按置信度从高到低排序遍历每个候选阈值计算精确率和召回率找到F1最大的点。这个过程花的十分钟省下来的是上线后反复调误检阈值的一个通宵。6.2 从检测走向识别EU车牌OCR的数据扩展思路检测模型输出的是车牌的位置生产系统还要识别出车牌上的字符才算闭环。EU车牌的OCR和国内蓝牌不同左侧蓝色条带里包含欧盟星标和国家代码右侧是白底黑字字符集包含数字和字母部分国家还有特殊字体。534张图只够支撑检测模型的训练字符识别需要单独的行级标注数据这个量级离可用还差得远。常用的低成本做法是合成车牌数据用OpenCV或PIL按EU车牌规范渲染字符加入随机背景、透视变换和光照扰动生成几千张合成图做OCR预训练再用真实数据微调。如果要投入这个方向建议先锁定目标国家德国D牌、法国F牌、意大利I牌的字符集和国家代码各不相同混合训练会互相干扰。检测模型先保证框得准OCR单独训练两个环节解耦后调参会容易很多。以前我拿到数据集第一件事就是解压、开训、盯loss后来被一个错位的标注框坑掉一整个下午才开始做静态校验这一步。现在养成习惯了任何zip格式的带标签数据集先花二十分钟做格式核对、统计和可视化再动手训练。省下的调试时间远比这二十分钟值。希望帮到你。本文还有配套的精品资源点击获取
返回列表