移动端车牌识别实战:YOLOv5+PlateNet模型部署与Android优化

📅 2026/8/2 7:22:35
移动端车牌识别实战:YOLOv5+PlateNet模型部署与Android优化
1. 项目概述与核心价值最近在做一个移动端的车牌识别项目核心需求是把车牌检测和识别功能塞进Android手机里并且要能实时跑起来。这听起来像是把大象装进冰箱但实际做下来发现只要选对工具、优化好流程在现在的手机上实现流畅的实时识别是完全可行的。这个项目主要面向两类开发者一是想给自家App比如停车场管理、违章取证、门禁系统增加车牌识别功能的Android工程师二是对移动端AI模型部署和优化感兴趣想找个具体项目练手的朋友。整个方案的核心是YOLOv5负责从摄像头画面里快速、准确地框出车牌位置然后交给一个轻量化的PlateNet模型去识别框内的字符。实测在主流性能的Android手机上处理一帧640x480的图像能在100毫秒以内完成达到10FPS以上的“实时”标准问题不大。下面我就把从模型选型、训练、到Android端集成、优化的完整流程和踩过的坑毫无保留地分享出来。2. 技术方案选型与整体设计思路2.1 为什么是YOLOv5 PlateNet的组合在移动端做目标检测和识别首要考虑的不是精度天花板有多高而是在有限的算力和内存下如何取得速度与精度的最佳平衡。经过一番调研和对比我最终敲定了YOLOv5s小型号作为检测器搭配一个自研的轻量级PlateNet作为识别器。选择YOLOv5s做车牌检测理由很充分。首先YOLO系列的单阶段检测架构天生就比两阶段的Faster R-CNN快非常适合实时场景。YOLOv5相比前代在易用性、训练速度和推理效率上又有提升。其中的“s”small版本模型体积仅约14MBFP32在COCO数据集上也能达到不错的mAP对于车牌这种尺寸相对固定、特征明显的目标完全够用。更重要的是YOLOv5的PyTorch实现生态完善导出为移动端友好的格式如ONNX、TFLite的工具体验顺畅社区里针对移动端的优化案例也多踩坑时容易找到解决方案。车牌识别部分没直接用现成的CRNN或LPRNet而是参考其思想设计了一个更轻的PlateNet。这是因为车牌字符数量固定通常是7位字符集有限数字字母少量汉字场景相对可控。一个深度适度的CNN配合CTC或简单的序列建模就能取得很好效果。PlateNet的核心是一个基于MobileNetV2或ShuffleNetV2 backbone的卷积网络接上双向LSTM或GRU进行序列特征建模最后用CTC解码得到字符串。整个识别模型可以压缩到5MB以下在CPU上跑一次前向传播也就十几毫秒。2.2 Android端技术栈与整体流程设计在Android端我们的目标是构建一个高效、稳定的处理流水线。整体架构可以分解为以下几个核心模块图像采集与预处理模块使用CameraXAPI获取摄像头预览帧。CameraX是Jetpack组件生命周期管理省心兼容性好。获取到的图像可能是YUV或RGBA格式需要根据模型输入要求通常是RGB进行转换和缩放如缩放到640x640。推理引擎模块这是性能的核心。我们使用TensorFlow Lite作为推理框架。选择TFLite是因为它在Android上的官方支持最完善性能优化如XNNPACK后端、GPU/神经处理单元委托做得最好。我们将训练好的YOLOv5s和PlateNet模型分别转换为.tflite格式。业务逻辑与后处理模块检测后处理YOLOv5输出的是密集的预测张量需要经过非极大值抑制NMS过滤掉重叠的、低置信度的框得到最终的车牌检测框。识别预处理将检测到的车牌区域从原图中裁剪出来并进行透视校正如果车牌倾斜、灰度化、二值化等操作归一化后送入PlateNet。识别后处理将PlateNet输出的序列结果可能是CTC路径或字符概率矩阵解码成最终的车牌号码字符串。结果展示模块将检测框和识别结果实时绘制到TextureView或SurfaceView上完成UI反馈。整个流程设计的关键在于“流水线化”和“异步化”。不能让图像采集、推理、绘制全部阻塞在主线程。我的做法是使用一个生产者-消费者模式CameraX的分析器ImageAnalysis.Analyzer作为生产者将图像放入一个容量为1的缓冲队列另开一个工作线程作为消费者从队列取图执行检测-裁剪-识别的完整流程最后将结果通过Handler或LiveData发送回主线程更新UI。这样可以避免掉帧保证预览的流畅性。3. 模型训练与优化实战3.1 车牌检测模型YOLOv5s的训练细节车牌检测模型的训练质量直接决定了后续识别步骤的输入是否可靠。我的数据集主要来源于公开数据集如CCPD以及自己收集的一部分总共约2万张带标注的车牌图片。数据标注使用LabelImg格式为YOLO格式归一化的中心点坐标和宽高。训练关键配置与技巧数据增强YOLOv5内置了强大的数据增强Mosaic、MixUp、随机仿射变换等。对于车牌检测我特别加强了随机旋转小角度和亮度/对比度调整以模拟不同光照和拍摄角度的场景。但要注意Mosaic增强可能会把车牌切碎对于小目标车牌有时需要适当降低Mosaic使用的概率。锚框Anchor聚类YOLOv5会针对你的数据集自动聚类生成先验锚框。由于车牌基本都是长宽比很大的矩形例如比例约为3:1自动聚类出的锚框会更贴合这种形状比用默认的COCO锚框效果要好。损失函数与超参数主要调整了hyp.scratch.yaml中的一些超参数。稍微增大了obj目标存在性损失的权重因为图像中车牌区域占比很小需要让模型更关注小目标。学习率采用余弦退火调度配合Warmup。一个关键技巧——在训练时我将图片分辨率设置为640x640。但在推理时尤其是移动端输入分辨率可以根据性能需求适当降低比如416x416或320x320这能显著提升速度但会轻微损失小尺寸车牌的检测能力需要权衡。注意训练完成后不要直接用PyTorch的.pt模型。务必使用YOLOv5提供的export.py脚本将模型导出为ONNX格式。在导出时可以指定--dynamic参数以适应不同尺寸的输入并启用--simplify进行模型简化。3.2 车牌识别模型PlateNet的设计与训练PlateNet是一个序列识别模型。我的网络结构如下特征提取Backbone采用MobileNetV2的倒残差结构但深度 multiplier 设置为0.5或0.75进一步降低计算量。输入为归一化后的单通道灰度车牌图像尺寸固定为(H, W) (48, 168)对应高48宽168适应车牌长宽比。序列建模层将Backbone输出的特征图在高度维度上压平通过全局平均池化或直接reshape得到一个特征序列。然后使用两层双向GRU隐藏单元数64或128来学习序列上下文信息。GRU比LSTM参数少速度更快在这个任务上精度相差无几。输出层接一个全连接层将GRU每个时间步的输出映射到字符类别数包括空白符。字符集我定义为“0123456789ABCDEFGHJKLMNPQRSTUVWXYZ京沪津渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新”共约70个类。损失函数使用CTC Loss。这是处理序列识别任务的标准选择它不需要对齐输入和输出序列完美适配车牌识别。训练要点数据生成与增强识别模型的数据可以从检测模型的训练集中裁剪出来。更高效的方法是使用车牌生成算法合成数据可以轻松获得数十万张带标签的样本。对于真实数据增强手段包括轻微的弹性扭曲、模糊、添加噪点、模拟运动模糊等以提升模型鲁棒性。解码推理时将模型输出的概率矩阵通过CTC贪婪解码或束搜索Beam Search即可得到最终字符串。贪婪解码速度极快束搜索beam width3或5能稍微提升精度但耗时增加。3.3 模型转换与量化TFLite这是移动端部署前的临门一脚对性能影响巨大。格式转换使用tf.lite.TFLiteConverter将PyTorch导出的ONNX模型需先用onnx-tf或onnx2tf工具转为TensorFlow SavedModel格式转换为TFLite模型。更推荐使用YOLOv5官方支持的export.py直接导出为TFLite格式需要安装相关依赖。python export.py --weights yolov5s.pt --include tflite --img 640动态范围量化这是提升速度、减小模型体积的利器且精度损失通常很小。TFLite支持将FP32权重量化为INT8而激活值仍保持FP32。converter tf.lite.TFLiteConverter.from_saved_model(saved_model_dir) converter.optimizations [tf.lite.Optimize.DEFAULT] # 默认优化包含量化 tflite_model converter.convert()对于PlateNet可以尝试全整数量化要求所有输入输出也是INT8这能获得最大加速尤其有利于支持整数加速的硬件如Hexagon DSP。但这需要准备一个有代表性的校准数据集来统计激活值的范围。def representative_dataset_gen(): # 提供一批有代表性的输入数据 for _ in range(100): input_data np.random.randn(1, 48, 168, 1).astype(np.float32) yield [input_data] converter.representative_dataset representative_dataset_gen converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type tf.uint8 converter.inference_output_type tf.uint8实测对比YOLOv5s (FP32): ~14 MB CPU推理 ~120 msYOLOv5s (INT8 量化): ~4 MB CPU推理 ~70 msPlateNet (FP32): ~3 MB CPU推理 ~15 msPlateNet (INT8 量化): ~1 MB CPU推理 ~8 ms量化带来的体积和速度提升是立竿见影的。4. Android工程集成与核心代码实现4.1 开发环境与依赖配置使用Android Studio进行开发。主要的Gradle依赖如下dependencies { // CameraX def camerax_version 1.3.0 implementation androidx.camera:camera-core:${camerax_version} implementation androidx.camera:camera-camera2:${camerax_version} implementation androidx.camera:camera-lifecycle:${camerax_version} implementation androidx.camera:camera-view:${camerax_version} // TensorFlow Lite implementation org.tensorflow:tensorflow-lite:2.14.0 implementation org.tensorflow:tensorflow-lite-gpu:2.14.0 // 可选GPU委托 implementation org.tensorflow:tensorflow-lite-support:0.4.4 // 工具类库方便图像处理 // 用于图像处理如透视变换 implementation com.quickbirdstudios:opencv:4.8.0 // 或使用其他轻量级图像库 }注意OpenCV for Android的库体积较大如果只需要简单的图像裁剪和缩放完全可以用Android自带的Bitmap和Matrix操作或者TFLite Support库里的ImageProcessor以减小APK体积。4.2 核心流程代码拆解1. 初始化TFLite模型与推理器class PlateRecognizer(context: Context) { private lateinit var detectInterpreter: Interpreter private lateinit var recognizeInterpreter: Interpreter private val gpuDelegate GpuDelegate() // 初始化GPU委托 init { // 加载检测模型 val detectModel loadModelFile(context, yolov5s_quant.tflite) val detectOptions Interpreter.Options().apply { // 尝试添加GPU委托失败则回退CPU try { addDelegate(gpuDelegate) } catch (e: Exception) { Log.e(TAG, GPU delegate failed, using CPU, e) } numThreads 4 // 设置线程数通常4个线程性能较好 } detectInterpreter Interpreter(detectModel, detectOptions) // 加载识别模型类似方式 // ... } private fun loadModelFile(context: Context, filename: String): MappedByteBuffer { val fileDescriptor context.assets.openFd(filename) val inputStream FileInputStream(fileDescriptor.fileDescriptor) val channel inputStream.channel return channel.map(FileChannel.MapMode.READ_ONLY, fileDescriptor.startOffset, fileDescriptor.declaredLength) } }2. CameraX图像分析器实现这是流水线的起点。class CameraAnalyzer(private val plateRecognizer: PlateRecognizer) : ImageAnalysis.Analyzer { private val executor Executors.newSingleThreadExecutor() // 单线程队列保证顺序处理 private val imageProcessor ImageProcessor.Builder() .add(ResizeOp(640, 640, ResizeMethod.NEAREST_NEIGHBOR)) // 调整到模型输入尺寸 .add(NormalizeOp(0f, 255f)) // 归一化根据模型要求调整 .build() override fun analyze(imageProxy: ImageProxy) { // 将ImageProxy转换为TFLite TensorImage格式 val tensorImage TensorImage.fromBitmap(toBitmap(imageProxy)) val processedImage imageProcessor.process(tensorImage) // 提交到后台线程进行推理 executor.submit { val detectionResult plateRecognizer.detect(processedImage.buffer) // ... 进行NMS获取车牌框 for (plateBox in plateBoxes) { val croppedPlate cropAndWarp(originalBitmap, plateBox) // 裁剪并校正车牌 val plateText plateRecognizer.recognize(croppedPlate) // 通过LiveData或Handler将结果传回主线程 resultLiveData.postValue(RecognitionResult(plateBox, plateText)) } imageProxy.close() // 重要必须关闭以释放相机缓冲区 } } private fun toBitmap(imageProxy: ImageProxy): Bitmap { ... } // YUV_420_888 转 RGB Bitmap }3. YOLOv5检测结果后处理NMS这是关键且容易出错的一步。YOLOv5的TFLite模型输出通常是一个形状为[1, 25200, 85]的张量以640输入为例。其中25200是锚框数量85 4框坐标 1目标置信度 80COCO类别数。我们需要过滤掉背景对于车牌检测我们通常只训练了一个“license plate”类别。fun nonMaxSuppression( predictions: ArrayArrayFloatArray, // 模型原始输出需要reshape confidenceThreshold: Float 0.5f, iouThreshold: Float 0.45f ): ListPlateBox { val detections mutableListOfPlateBox() // 1. 解析预测张量提取所有置信度大于阈值的框 // 坐标是cx, cy, w, h (归一化)需要转为x1, y1, x2, y2 // 2. 按置信度降序排序 // 3. 实现标准NMS算法遍历排序后的列表将当前最高分框加入结果计算它与剩余所有框的IoU删除IoU大于阈值的框视为同一目标重复直到列表为空。 // 注意这里的IoU计算和NMS实现需要自己写Android没有内置。 return detections }实操心得NMS的IoU阈值设置很关键。设得太高如0.6可能无法滤除紧密相邻的误检框设得太低如0.3可能会把同一个车牌因为模型抖动产生的多个相近预测框都滤掉导致漏检。对于车牌0.4-0.5是个不错的起点。4. 车牌区域预处理与识别裁剪出的车牌区域可能是倾斜的直接送入识别模型效果会差。需要进行透视校正。fun cropAndWarp(srcBitmap: Bitmap, plateBox: PlateBox): Bitmap { // 1. 根据plateBox的四个角点如果检测模型直接输出角点最好否则可以从矩形框估计或使用角点检测 val srcPoints plateBox.corners // 假设已经从检测模型或后续处理中得到四角点 // 2. 定义目标矩形的四个点例如宽高比3.5:1 val dstWidth 168 val dstHeight 48 val dstPoints arrayOf( PointF(0f, 0f), PointF(dstWidth.toFloat(), 0f), PointF(dstWidth.toFloat(), dstHeight.toFloat()), PointF(0f, dstHeight.toFloat()) ) // 3. 计算透视变换矩阵并应用 val matrix Matrix() matrix.setPolyToPoly(srcPoints, 0, dstPoints, 0, 4) val warpedBitmap Bitmap.createBitmap(dstWidth, dstHeight, Bitmap.Config.ARGB_8888) val canvas Canvas(warpedBitmap) canvas.drawBitmap(srcBitmap, matrix, null) // 4. 转为灰度图并二值化如果识别模型需要 return warpedBitmap }校正后的图像经过灰度化、归一化即可输入PlateNet进行识别。解码CTC输出得到最终字符串。5. 性能优化与实战调优5.1 推理速度瓶颈分析与优化在真机上用Profiler工具跑一下你会发现时间主要花在图像预处理YUV转RGB、缩放、归一化。这部分可以用RenderScript已废弃或更高效的libyuvGoogle开源库来加速或者直接利用CameraX的ImageAnalysis设置输出格式为ImageFormat.YUV_420_888并直接操作YUV平面避免全图转换。模型推理使用TFLite GPU Delegate对于高通芯片GPU加速效果显著。但要注意GPU与CPU之间的数据拷贝有开销对于非常小的模型可能得不偿失。需要实测对比。使用NNAPI Delegate在支持NNAPI的硬件如华为NPU、高通DSP上可以获得巨大加速。创建NnApiDelegate即可TFLite运行时会自动选择可用加速器。调整线程数Interpreter.Options().numThreads设置为4通常能较好利用多核CPU。不是越多越好太多线程会引入同步开销。固定输入尺寸如果使用动态输入尺寸每次推理都会有额外的图优化开销。在ImageAnalysis中固定输出分辨率并与模型输入尺寸一致。后处理NMS和图像裁剪/变换如果实现不够高效也会成为瓶颈。确保这些操作使用原生代码如C或优化过的库如OpenCV的本地库避免在Java/Kotlin层进行大量的像素级循环。5.2 内存与功耗管理模型加载模型应放在assets目录使用MappedByteBuffer加载这样模型文件不会完全加载进堆内存减少内存压力。图像缓冲区CameraX的ImageProxy必须及时关闭imageProxy.close()否则会导致相机缓冲区泄漏最终使预览卡顿甚至崩溃。推理器复用Interpreter的创建成本很高一定要在应用生命周期内复用同一个实例。后台策略当App退到后台应立即停止ImageAnalysis释放相机和推理资源。可以在LifecycleObserver中监听生命周期。5.3 准确率提升技巧多帧融合对于视频流可以对连续多帧的识别结果进行投票或加权平均。例如连续5帧中有4帧识别出“京A12345”1帧识别为“京A12346”则取前者为最终结果。这能有效平滑单帧误识别。车牌规则过滤利用车牌号码的规则如省份简称发牌机关代号号码的组合规则对识别结果进行合法性校验和纠正。可以建立一个简单的有限状态机或正则表达式库。感兴趣区域ROI如果应用场景固定如停车场入口可以只对图像下半部分或特定区域进行检测减少计算量也避免了天空、树木等背景干扰。6. 常见问题与排查实录在实际开发和测试中我遇到了不少典型问题这里列出来供大家参考。6.1 模型相关问题问题1模型在PC上精度很高但转换TFLite后部署到Android检测框乱飞或识别全错。排查首先检查输入数据的预处理是否完全一致。包括图像通道顺序RGB vs BGR、归一化范围0-1 vs 0-255、均值/标准差减除等。一个常用技巧是在Android端预处理后将第一个像素值打印出来与Python端预处理后的同一个像素值对比。解决确保TFLite模型转换时没有启用不兼容的优化。尝试先不进行任何量化用FP32模型测试。如果FP32正常而INT8异常问题很可能出在校准数据集没有代表性导致量化误差过大。问题2NMS后同一个车牌还是出现多个框。排查检查NMS的IoU阈值是否设置合理。用调试工具把NMS前的所有预测框画出来观察重叠情况。解决可能是模型在训练时对同一个目标产生了多个高置信度的锚框预测。可以尝试在训练时提高NMS阈值或者使用更先进的NMS变种如Soft-NMS。也可以在后处理中对检测框进行聚类合并。6.2 Android端集成问题问题3相机预览和推理都很卡顿帧率很低。排查使用Android Studio的Profiler工具查看CPU、内存和GPU使用情况。重点看analyze方法执行耗时。解决确保analyze在后台线程执行不要阻塞相机回调线程。降低ImageAnalysis的分辨率例如设置为640x480或更低。降低推理频率不是每一帧都需要处理。可以设置一个采样间隔例如每3帧处理1帧 (ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)配合计数器)。检查是否频繁创建Interpreter或TensorBuffer这些对象应复用。问题4在某些手机上特别是低端机或特定品牌App运行一会儿就闪退。排查查看Logcat错误日志常见的是OutOfMemoryError或Failed to allocate memory。解决检查是否有内存泄漏特别是Bitmap和ImageProxy未回收。尝试减少Interpreter的线程数。如果使用了GPU Delegate在某些GPU驱动有问题的机型上可能会崩溃。需要做好异常捕获崩溃时回退到CPU模式。考虑在Application中启用大堆android:largeHeaptrue但这只是权宜之计。6.3 业务逻辑问题问题5识别结果中数字“0”和字母“O”数字“1”和字母“I”经常混淆。解决这是车牌识别的经典问题。可以从两方面入手数据层面在训练数据生成或标注时确保字体清晰可辨。可以专门收集或生成一批易混淆字符的样本加大它们在训练中的权重。后处理层面根据车牌号码的编排规则进行纠正。例如在中国车牌中第二位通常是字母除少数武警车牌第五位通常是数字。可以基于这样的先验知识建立一个简单的混淆矩阵和纠正规则。问题6夜间或强光背光环境下识别率骤降。解决数据增强在训练数据中增加大量模拟不同光照条件过曝、欠曝、点光源、背光的样本。预处理增强在Android端对裁剪出的车牌区域进行图像增强如直方图均衡化、自适应二值化如OpenCV的adaptiveThreshold等提升图像对比度。模型层面考虑在识别模型前加入一个轻量级的图像增强子网络或者直接使用对光照鲁棒性更好的特征提取方法。整个项目从零到落地最大的体会就是“平衡”。在移动端深度学习的战场上速度、精度、功耗、体积永远在博弈。没有最好的模型只有最适合当前场景和硬件的方案。我的建议是先从一个小而精的模型开始确保流程跑通然后像挤海绵一样从数据、模型结构、训练技巧、推理引擎、代码实现每一个环节去优化一点点把性能榨出来。最后别忘了在真实场景中做大量测试因为实验室的漂亮数字和用户手里的实际体验往往隔着一条鸿沟。