简介:面向车辆检测与识别初学者的一套 VC++ 工程实现,完整演示从图像载入到车型识别的全流程。系统基于 MFC 框架,通过载入背景与前景图像进行差分,依次执行二值化、开运算、去噪和填充等操作完成车辆前景提取,再借助轮廓提取获得车辆边界,最终实现车型分类判别。流程设计紧扣传统图像处理算法,场景典型,适合图像处理、模式识别课程设计以及相关方向入门实践。压缩包共 26 个文件,核心代码为 8 个 .h 头文件与 6 个 .cpp 源文件,另有 5 张 .bmp 样例图像可直接测试,辅以图标、资源脚本和 Visual Studio 工程配置,结构紧凑,便于打开即用和二次开发。整包仅 59KB,轻量易获取;已有 258 人浏览学习。通过该工程可掌握背景差分、形态学操作与轮廓分析在车辆识别任务中的串联应用,理解从预处理到特征决策的完整代码实现,是一份值得参考的实践示例。
1. 车型识别不是拍照识车这么简单:CarShapeIdentify 到底在解决什么
真正把车型识别推到一线的是停车场管理、保险定损初审、园区车辆登记这类场景——它们都要把“一辆车的外观图”自动转成“品牌+车系+年款”的结构化标签,而不是只判断“这里有一辆车”。CarShapeIdentify 这个方向看起来就是图像分类:给一张车侧或车头照片,输出“宝马 3 系 2020 款”之类的结论。但做过的人都知道,车型识别难的不是“认不出”,而是“认太死”——同一款车改款、贴膜、换轮毂、换个拍摄角度,结果可能完全不一样。本文按我自己的落地路径拆开讲:选什么网络、数据怎么备、参数怎么调、部署有哪些坑,以及怎么用混淆矩阵持续迭代。适合想把这个功能做成服务、而不是停在 notebook 演示的工程师。
2. 选型先于写代码:为什么车型识别常用分类网络而不是检测网络
2.1 分类网络与检测网络的分工:先搞清楚你的目标是“是什么”还是“在哪”
很多第一次做车型识别的人,上来就想去跑 YOLO,理由是“检测能顺便把车牌也一起做了”。这其实是把两个问题混在一起了。检测网络的输出是“目标位置 + 类别”,它得同时回归 bounding box 和分类概率,训练成本更高、对小类别差异的敏感度反而被位置损失稀释。而车型识别的核心诉求是“图里就是一辆车,我要知道它是什么型号”——这是一个典型的 fine-grained classification 问题,用分类网络做是更常见的从业方案。
我一般会先问一句:你的原始输入是单目标特写,还是整段监控视频?如果是停车场出入口的抓拍,车基本在画面中央,直接分类就行;如果是路侧摄像头,画面里可能同时有几辆车,那就得在分类前加一个检测裁剪的前置。常见做法是两段式:检测把所有车辆框出来,再逐框送进分类网络。CarShapeIdentify 这类以“识别”为主的项目,核心精力应该放在分类这一段,检测只是辅助。
2.2 EfficientNet 还是 ResNet:用 torchvision 跑通最小训练脚本
分类网络的主干选择上,我推荐从 EfficientNet-B0 或 ResNet-50 起步,而不是一上来就上 ViT。原因很实在:车型数据集通常不会大到 ImageNet 那个量级,ViT 在小数据上需要更精细的调参,而 EfficientNet 在参数量和精度之间平衡好,迁移学习的效果稳定。ResNet-50 的优势是生态成熟、任何部署框架都支持,踩坑少。如果你要部署到边缘盒子,优先 EfficientNet-B0;如果服务端算力充裕,ResNet-50 更省心。
下面是最小的训练入口,按文件夹组织数据即可:
# train_cls.py import torch import torch.nn as nn from torchvision import datasets, transforms from torch.utils.data import DataLoader import timm # 目录结构:data/train/宝马3系/*.jpg, data/train/奥迪A4/*.jpg # ImageFolder 会把一级子目录名当作分类标签,新增车型只需加一个文件夹 train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_ds = datasets.ImageFolder('data/train', train_tf) model = timm.create_model('efficientnet_b0', pretrained=True, num_classes=len(train_ds.classes)) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) criterion = nn.CrossEntropyLoss()这段代码里最重要的三个参数是scale、lr和weight_decay。scale=(0.7, 1.0) 的意思是裁剪区域占原图的 70% 到 100%,我刻意不把下限设得太低,因为车型识别依赖车身轮廓和比例关系,裁得太碎会把“车尾”变成“一块红色金属”,反而丢掉了全局形状特征。学习率 3e-4 是迁移学习的常见起点,ResNet 可以放宽到 1e-3,EfficientNet 建议就按这个来;weight_decay 1e-4 是对付过拟合的基本盘,如果你的训练集每类只有几百张,这个值可以提到 5e-4。
训练脚本本身就这么多,真正决定模型上限的不是网络结构,而是下一章的数据。很多人把 80% 的时间花在调网络,最后发现换数据增强带来的提升比换 backbone 大得多,这是车型识别项目里最常见的认知偏差。
3. 把图片变成训练集:数据清洗、标注与按车型归类的完整流程
3.1 数据从哪来:自采、离线图库还是公开数据集,先想清楚版权和噪声
车型识别的训练数据没有统一的开源大包可以一次搞定所有车型,常见的来源有三条路:一是自己采集,在停车场、园区出入口部署临时的抓拍设备,这是最干净但最慢的方式;二是购买或获取合规的车辆图库数据,覆盖角度全、带年款标注;三是公开数据集如 CompCars、Stanford Cars,但它们年份偏早,近年新款车型覆盖不足。
我个人的经验是:不要依赖单一来源,也别去爬那些带水印和涂抹痕迹的图。水印本身会成为模型学到的“特征”——我见过模型把某家二手车平台的水印位置当成判断依据,换一批图直接翻车。更实际的做法是:以自采数据为骨架,覆盖你业务里真实出现的角度和光线;用图库数据补充车型广度,但要接受它会引入背景噪声。原始图片必须经过清洗才能进训练集,下面的脚本是每次建数据集我都会跑的工序。
3.2 损坏检测与近似去重:两张几乎一样的图会让验证集虚高
# prepare_data.py from PIL import Image import hashlib, os from collections import defaultdict seen = defaultdict(str) duplicate_count = 0 for root, _, files in os.walk('raw_images'): for name in files: path = os.path.join(root, name) try: img = Image.open(path) img.verify() # verify 只检查文件头完整性,速度快 except Exception as e: print('broken:', path, repr(e)) continue with open(path, 'rb') as f: h = hashlib.md5(f.read()).hexdigest() if h in seen: duplicate_count += 1 print('duplicate:', path, 'same as', seen[h]) else: seen[h] = path print('duplicates found:', duplicate_count)这里做的是 MD5 精确去重,只能干掉完全相同或只改了文件名的重复图。实际数据里还有大量“同一辆车换了个压缩率”“同一张图被缩放后另存”的近似重复,MD5 抓不到。要处理这类情况,可以用 perceptual hash(感知哈希),把每张图缩到 8x8 灰度后算汉明距离,距离小于阈值就判为近似重复。注意:近似去重的阈值很玄学,设太严格会误删同款车的不同实拍图——同款车本来就长得像,你想去掉的是“同一张图的不同副本”,不是“同一车型的所有照片”,这两者区别巨大。
3.3 类别不平衡与数据增强参数:用采样策略而不是简单复制
车型识别的类别分布天然是长尾的。热门家用车一个型号可能有两万张图,冷门性能车可能只有两百张。如果直接按原始分布训练,模型会对头部车型过拟合,尾部车型的召回率惨不忍睹。常见做法有两个方向:一是用 WeightedRandomSampler 按类别样本数的倒数加权采样,让每个 epoch 里每类被抽到的概率接近均等;二是做类别上限截断,每类最多取 N 张,超出部分丢弃或只参与部分 epoch。
from torch.utils.data import WeightedRandomSampler # 统计每个类别的样本数,给样本数少的类更高的采样权重 class_counts = [sum(1 for _ in cls_files) for cls_files in train_ds.targets] # train_ds.targets 是每个样本对应的类别索引 weights = [1.0 / class_counts[t] for t in train_ds.targets] sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) loader = DataLoader(train_ds, batch_size=32, sampler=sampler, num_workers=4)用 WeightedRandomSampler 比简单复制少数类样本更稳,因为过采样会让模型把同一批图反复背下来,泛化到新角度时反而更差。数据增强在这里承担了“虚拟扩充”的职责:ColorJitter 的 brightness 和 contrast 我分别设为 0.3,这两个值针对车身颜色在不同光照下的偏移是够用的,但如果你的场景里有强烈的路灯黄光或地下车库的荧光灯,建议把 brightness 调到 0.4 并加一个随机色温扰动。augmentation 参数没有绝对最优,判断标准只有一个——在验证集上不只提升整体准确率,更要看尾部类别的 recall 有没有跟着涨。
4. 从模型到服务:车型识别系统部署成服务的几个关键决策
4.1 模型导出:从 PyTorch 到 ONNX 的转换与输入尺寸陷阱
训练完的模型不能直接拿 PyTorch 去上线——推理服务的资源占用和延迟都不可控,工业界常见做法是导出成 ONNX,再用 ONNX Runtime 或 TensorRT 加载。导出这一步有几个参数值得较真,稍不注意就会给后面埋雷。
# export_onnx.py import torch model = torch.load('best.pt', map_location='cpu') model.eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, 'model.onnx', input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}}, opset_version=12 )dynamic_axes 一定要加。如果不加,导出的模型 batch 维度被固定成 1,线上如果做了批量推理就会报维度错误。opset_version=12 是比较稳的版本,ONNX Runtime 1.10 以上都支持;没必要追新,opset 13 在旧版本推理引擎上可能不兼容。还有一点很容易被忽略:导出前务必确定输入尺寸。如果训练时用的是 224x224,推理时被业务方要求处理 1920x1080 原图,你必须在预处理里先 resize 到 224,而不是直接把大图塞进模型。模型对输入尺寸是敏感的,尺寸分布偏离训练分布太多,输出概率会明显变平,置信度全线下降。
4.2 推理服务的最小实现:FastAPI 封装与并发控制
导出 ONNX 之后,服务端封装我习惯用 FastAPI,因为同步代码写起来直接,异步支持也完整。下面这个例子是单文件化的最小服务,适合先跑通再拆模块。
# server.py import asyncio import numpy as np import onnxruntime as ort from fastapi import FastAPI, UploadFile from PIL import Image import torchvision.transforms as transforms app = FastAPI() sess = ort.InferenceSession('model.onnx', providers=['CPUExecutionProvider']) sem = asyncio.Semaphore(4) # 控制同时进入推理的请求数 tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) @app.post('/predict') async def predict(file: UploadFile): data = await file.read() img = Image.open(bytes(data)).convert('RGB') tensor = tf(img).unsqueeze(0).numpy() async with sem: # 用 run 同步调用,由 FastAPI 的线程池承接阻塞,避免事件循环被卡死 outputs = sess.run(['output'], {'input': tensor})[0] idx = int(np.argmax(outputs[0])) return {'class_id': idx, 'confidence': float(outputs[0][idx])}并发控制是这个服务里最容易翻车的地方。CPU 推理本身是阻塞操作,如果不加 Semaphore,ONNX Runtime 在 CPU 上会默认占满所有核,一旦请求量上来,延迟会从 30ms 飙到 300ms,而且每个请求互相拖慢。Semaphore(4) 的意思是允许 4 个推理任务同时执行,剩余请求排队等待。这个数字要根据你机器的物理核数和模型单次推理耗时来调——一个经验值:单次推理 20ms 的模型,4 核机器上 Semaphore 设为 2 就能支撑大约 100 QPS,设太大反而会因为线程切换损耗降低吞吐。生产环境我会加一层 Redis 做结果缓存:同一个车型在同一个摄像头下的识别结果短期内不应该变,cache 命中能省掉大量重复推理。
4.3 置信度阈值怎么定:不是越高越好
部署时还必须定一个置信度阈值,低于阈值的识别结果直接返回“未知车型”。这个阈值不该靠拍脑袋。常见做法是在验证集上画出 precision-recall 曲线,然后根据业务容忍度选点:停车场场景对误识别要求高,阈值可以拉到 0.9;保险定损初审希望减少漏报,0.7 也可以接受。我踩过的一个坑是:阈值设太高之后,模型对低质量图像(夜间、逆光、部分遮挡)的输出全部被拒掉,变成“未知车型”的比例高达 30%,用户直接投诉“系统瞎了”。后来我把逻辑改成两段式:置信度大于 0.9 直接返回,0.7 到 0.9 之间返回“疑似车型”并在界面上标注低置信度,小于 0.7 才返回未知。这样既保住了准确率,又给了人工介入的空间。
5. 车型识别的踩坑记录:五个最容易翻车的环节与排查方法
5.1 现象:同款车不同年款被混成一个类,验证集精度反而很高
最典型的现象是“宝马 3 系 2019 款”和“2020 款”被分到同一个类里,而验证集上准确率高达 98%。原因通常不在模型,而在标注——标注人员看车头中网细节,可能根本没注意到灯组内部结构的细微变化。解决方法是把年款差异大的车分开标注,并且在标注规范里写明“以灯组造型和保险杠线条为优先判断依据”。更省力的做法是业务上就不要求细分到年款,合并成“车系级”标签,这能显著降低标注成本和模型压力。
5.2 现象:黑色车和白色车的识别错误率高一倍
不是因为模型对颜色敏感,而是因为深色车在夜间或暗光下,车身轮廓和背景的对比度太低,模型可用的边缘信息变少。训练集里大多数图都是白天拍的浅色车,黑色车样本天然不足。解决方向有两个:一是采集时主动覆盖深色车、夜间场景,二是在数据增强里加入随机亮度下调。注意这里不能只在 RGB 空间调亮度,最好用 HSV 空间的 V 通道做扰动,这样更接近真实光照变化。如果发现某个车型总在夜间被误判成另一款,优先检查两个车型的夜间样本量和车身色分布。
5.3 现象:测试集上精度高,现场摄像头画面一进来就崩
这是最经典的训练集与部署环境脱节问题。图库数据大多是平视视角、干净背景,而现场摄像头是俯视或仰视,还有广角畸变。模型在训练分布之外的视角上,特征提取器的响应会变得不稳定。常见做法是 Fine-tune 前先做“域适应”——从目标摄像头采集一周的抓拍图,人工挑出有代表性的样本,混入训练集重新训练。如果畸变太严重,可以在预处理里做一次透视校正,把车身拉正再送进模型。我在一个项目里测试过:同样的模型,不做校正前的准确率 82%,用四点透视校正之后直接到 91%。
5.4 现象:验证集虚高,一到新数据就现原形
原因几乎总是数据泄漏。一种是近似重复图泄漏——同一辆车在训练集和验证集里各出现一次,模型实际上是在“背答案”;另一种是时间泄漏——用 2022 年的数据做验证集,但训练集里混入了 2023 年的图。排查方法很简单:按车辆唯一标识(车牌号或图片 MD5 聚类)划分数据集,确保同一辆车只出现在一个集合里。这个操作用精确去重还不够,要配合感知哈希做一次聚类,才能把“同一辆车不同角度”的照片归到同一组,再整组划分。
5.5 现象:模型对“改装车”的预测结果在两个车型之间反复横跳
改装轮毂、贴膜、加包围,这些变化会让车型特征被局部外观干扰。本质原因是模型学到了“轮毂样式”这种不该作为决定性依据的特征。我处理这个问题的思路是:在训练时对图片做局部遮挡增强,随机遮挡车身的下半部分,强迫模型更多依赖车头车尾的整体造型。效果上,这个操作对改装车的稳定性提升很明显,代价是正常样本的准确率下降约 0.5 到 1 个百分点,总体收益是正的。遮挡增强的强度和范围需要试,我会先从“遮挡高度 20%、宽度 30%”开始调。
6. 混淆矩阵之外:验证模型效果与增量更新的日常操作
6.1 用混淆矩阵找到永远在纠缠的车型对
整体准确率是最骗人的指标——如果头部车型占了 60% 的样本,你只优化头部,准确率也能做到 95%,但尾部车型几乎全废。我每次训练完必做的一件事是输出混淆矩阵,把最大的非对角项找出来。这一步的代码很短:
# confusion.py from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt # y_true / y_pred 由验证集推理得到 cm = confusion_matrix(y_true, y_pred, labels=class_names) sns.heatmap(cm, xticklabels=class_names, yticklabels=class_names, annot=True, fmt='d', cmap='Blues') plt.xticks(rotation=90) plt.tight_layout() plt.savefig('confusion_matrix.png')读矩阵的时候只盯最大的三对非对角项。比如“奥迪 A4L”和“奥迪 A6L”经常被混,说明模型在车长比例和侧面线条上的区分度不够,这时候我会补充这两款车的侧前 45 度角样本;如果“大众迈腾”和“帕萨特”混,那就是它们外观相似度确实太高,业务上可以直接把它们合并成一个业务类。混淆矩阵的作用不是“看看哪儿错了”,而是指导数据采集和标签体系设计——这是车型识别项目里最能直接提升效果的一步。
6.2 难例挖掘的日常操作:把高置信度错误样本捞回来
只靠混淆矩阵还不够,因为矩阵掩盖了单个样本的信息。我习惯在每次迭代后跑一遍全量验证集,筛选出“预测置信度大于 0.8 但预测错误”的样本,单独存到一个 hard_examples 目录里。这些样本是最有价值的标注素材——它们说明模型在非常确信的情况下犯了错,这类错误极难通过调阈值改善,只能靠加数据或加约束。我在实际操作中发现,这类样本里有相当一部分是图片里同时出现了两辆车,分类网络只看到了其中一辆的特征。这个现象提醒我:输入图片的裁剪质量比模型本身更能决定上限,两段式方案里检测框的 IoU 阈值该卡到 0.85 以上。
6.3 增量更新的节奏:小步快跑,但别把基座模型训歪
新车型上市、改款发布,都会让旧模型逐渐失效。增量更新的正确做法不是每天都在旧模型上接着训练,而是固定一个节奏:每周从线上捞误判样本,攒够 2000 到 3000 张新图后,连同上一版模型做一次微调。每次更新的学习率要降一个量级,比如从 3e-4 降到 3e-5,否则旧知识会被新数据冲刷掉。更新完必须回放一遍旧验证集,确认新增数据提升的同时没有把老车型搞坏——这个操作在业界叫“回归测试”,但我不喜欢这个正式说法,更习惯叫它“后悔药测试”:上线后发现老车型识别率掉了,再回滚模型可比事前多花十分钟跑测试痛苦多了。我现在的习惯是每次训练完都把混淆矩阵和难例样本数量记录到一个固定的 markdown 文件里,下次更新时直接对比,一眼就能看出迭代是变好还是变差。这套流程走下来,车型识别系统才算真正从“一个模型”变成了“一个可持续迭代的服务”,希望帮到你。
本文还有配套的精品资源,点击获取