ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android车牌识别源码解析:OpenCV静态库集成与NDK编译实战

Android车牌识别源码解析:OpenCV静态库集成与NDK编译实战

简介:本资源为基于OpenCV的汽车牌照识别完整项目包,采用AndroidStudio构建,面向计算机、软件工程、人工智能、通信工程等专业的在校学生与教师,可用于毕业设计、课程设计、作业或项目立项演示。项目已通过导师评审,答辩评分达95分,代码经测试运行成功,功能完整可靠。压缩包共262个文件,约53.9MB,以95个Java源码、72个静态库、39个so动态库、24个XML布局及若干Gradle构建脚本为主,涵盖Android端界面开发、OpenCV图像处理与本地库调用等模块,并附使用文档与全部资料。已有64人学习关注。读者可获取一套可直接运行的牌照识别方案,理解图像预处理、车牌定位与字符识别流程,并在此基础上修改扩展功能,适合作为毕设、课设或入门进阶的参考范例。

1. 从一堆 .a 静态库说起:这套车牌识别源码到底能跑出什么

如果你拿到过一个 Android 车牌识别项目,解压后第一眼看到的往往不是 Java 代码,而是一堆libopencv_core.a、libopencv_imgproc.a、libopencv_ocl.a这样的静态库文件。很多人到这一步就懵了:这到底是给人看的源码,还是给机器吃的二进制?我拆过不少这类毕设包,可以负责任地说,这套基于 OpenCV + Android Studio 的汽车牌照识别项目,核心价值恰恰藏在这些.a文件背后——它把 OpenCV 的 C++ 能力通过 NDK 编译进 Android 工程,让车牌检测、字符分割、OCR 识别整条链路跑在手机端,而不是靠云端接口糊弄答辩。

它适合谁?计算机、软件工程、人工智能、通信、自动化这些专业里,需要交课程设计或毕业设计,又不想只做个「调用百度 API 返回车牌号」的壳子的人。你拿到的是完整 Android 工程 + 使用文档 + 全部资料,能编译、能跑通、能改。下面我按「先看懂结构、再动手编译、最后调参排错」的顺序,把这份资源拆开讲透。

2. 工程结构与 OpenCV 集成方式:先搞清 .a 和 .so 的分工

2.1 为什么是静态库 .a 而不是动态库 .so

Android 上集成 OpenCV 常见两条路:一是用官方预编译的OpenCV-android-sdk,里面是.so动态库加 Java 封装;二是自己用 NDK 把 OpenCV 源码编成.a静态库,再链接进自己的 native 模块。这套项目走的是第二条路,从libopencv_core.a、libopencv_imgproc.a、libopencv_ocl.a这几个文件名就能看出来。

静态库的好处是:最终 APK 里只有一个你自己的.so,不依赖外部 OpenCV Manager,装机即用,答辩演示不会出现「请先安装 OpenCV Manager」的尴尬。代价是编译配置更麻烦,CMakeLists.txt或Android.mk里链接顺序写错就一堆 undefined reference。常见做法是在CMakeLists.txt里按依赖顺序排列:imgproc依赖core,所以core要放在imgproc后面。

2.2 目录骨架与关键文件定位

一个能跑通的 Android OpenCV 工程,目录大致是这样组织的,你对照自己的包检查:

路径作用是否可改
app/src/main/java/...Java/Kotlin 层,相机预览、UI、调用 native可改
app/src/main/cpp/C++ 源码,车牌检测与识别核心核心可改
app/src/main/cpp/libs/armeabi-v7a/预编译的.a静态库一般不动
app/src/main/cpp/CMakeLists.txtnative 构建脚本按需改
app/src/main/jniLibs/最终打包的.so自动生成
app/build.gradleNDK、ABI、SDK 版本配置按需改

CMakeLists.txt是整条 native 链路的入口,我一般先看它确认三件事:find_library找没找到 log 库、add_library生成的是不是SHARED、target_link_libraries里静态库顺序对不对。这三处任何一处错,编译阶段就过不去。

2.3 从相机帧到车牌字符串的数据流

整条链路可以拆成四步,理解它对后面调参至关重要:

  1. Java 层通过 Camera2 或旧版 Camera API 拿到预览帧,转成byte[]或Mat传给 native;
  2. native 层做灰度化、高斯模糊、Sobel 或 Canny 边缘检测;
  3. 形态学闭运算把边缘连成矩形候选区,按宽高比和面积筛出车牌区域;
  4. 对候选区做二值化、字符分割,再走模板匹配或简单 OCR 输出字符串。

这四步里,第 2、3 步的参数最影响识别率,也是后面避坑章节要重点说的。你先把这个数据流在脑子里跑通,再看代码就不会迷路。

3. 编译与运行:从 clone 到真机出结果的可复现步骤

3.1 环境版本对齐,别让 NDK 版本背锅

这类毕设包最常见的翻车点就是 NDK 版本不匹配。项目里如果用的是较老的Android.mk风格,你拿 NDK r23 以上去编,gnustl_static已经被移除,直接报错。我一般先看build.gradle里的ndkVersion和CMakeLists.txt里的ANDROID_STL设置。

// app/build.gradle 关键片段 android { compileSdkVersion 30 defaultConfig { minSdkVersion 21 targetSdkVersion 30 ndkVersion "21.4.7075529" // 与项目 .a 库编译时用的 NDK 对齐 externalNativeBuild { cmake { cppFlags "-std=c++11 -frtti -fexceptions" abiFilters "armeabi-v7a", "arm64-v8a" } } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" } } }

ndkVersion建议锁在 21.x 或 22.x,这是.a静态库常见的编译区间。abiFilters只保留armeabi-v7a和arm64-v8a,别贪多带上 x86,否则 APK 体积翻倍还容易缺库。cppFlags里的-frtti -fexceptions必须开,OpenCV 的 C++ 接口依赖异常和 RTTI。

3.2 CMakeLists 里静态库的链接顺序

这是最容易出 undefined reference 的地方,我直接把可抄的写法放出来:

cmake_minimum_required(VERSION 3.4.1) # 引入预编译静态库所在目录 add_library(opencv_core STATIC IMPORTED) set_target_properties(opencv_core PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libopencv_core.a) add_library(opencv_imgproc STATIC IMPORTED) set_target_properties(opencv_imgproc PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libopencv_imgproc.a) add_library(opencv_ocl STATIC IMPORTED) set_target_properties(opencv_ocl PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libopencv_ocl.a) # 自己的 native 模块 add_library(plate_recog SHARED plate_recog.cpp) find_library(log-lib log) # 链接顺序:被依赖的放后面 target_link_libraries(plate_recog opencv_imgproc opencv_ocl opencv_core ${log-lib})

逻辑说明:IMPORTED告诉 CMake 这是外部已编好的库,IMPORTED_LOCATION指向具体.a路径,${ANDROID_ABI}会自动按当前编译的 ABI 切换目录。链接顺序上,imgproc用到core的符号,所以core必须排在imgproc之后,ocl同理。写反了就是满屏undefined reference to cv::Mat::...。

3.3 真机运行与权限确认

编译通过后装到真机,第一次打开大概率黑屏或闪退,先查两件事:相机权限和 ABI 匹配。

<!-- AndroidManifest.xml 必须声明 --> <uses-permission android:name="android.permission.CAMERA"/> <uses-feature android:name="android.hardware.camera" android:required="true"/>

运行时还要动态申请CAMERA权限,Android 6.0 以上不申请直接崩。ABI 方面,如果你的手机是 arm64,但jniLibs里只有 armeabi-v7a 的.so,会报UnsatisfiedLinkError。确认abiFilters和实际生成的.so目录一致,再装一次。

4. 车牌检测与字符识别的参数调优:识别率上不去的根因

4.1 预处理阶段的三个关键参数

车牌识别在手机端跑,光照和角度是最大敌人。预处理阶段我一般盯三个参数:高斯模糊核大小、Canny 双阈值、形态学核尺寸。

// 灰度 + 高斯模糊 cv::Mat gray, blurred; cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0); // 核大小 5x5,太大丢边缘 // Canny 边缘检测 cv::Mat edges; cv::Canny(blurred, edges, 100, 200); // 低阈值 100,高阈值 200 // 形态学闭运算连接边缘 cv::Mat morph; cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(17, 5)); cv::morphologyEx(edges, morph, cv::MORPH_CLOSE, kernel);

参数说明:高斯核5x5是平衡去噪和保边的常用值,光照差可加到7x7,但再大车牌字符边缘就糊了。Canny 低阈值100、高阈值200是经验值,低阈值调低会引入更多噪点边缘,调高会漏掉弱边缘。形态学核17x5是横向矩形,因为中国车牌是扁长的,横向闭运算能把断开的边缘连成整块。这三个值不是固定的,你换一批测试图就要微调。

4.2 车牌区域筛选的几何约束

边缘连成块之后,会得到一堆候选矩形,得靠几何特征筛。常见做法是按宽高比和面积占比过滤:

std::vector<std::vector<cv::Point>> contours; cv::findContours(morph, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); for (auto& c : contours) { cv::Rect rect = cv::boundingRect(c); float ratio = (float)rect.width / rect.height; float areaRatio = (float)(rect.width * rect.height) / (src.cols * src.rows); // 中国车牌宽高比约 3:1 到 4:1,面积占比 2% 到 15% if (ratio > 2.5 && ratio < 5.0 && areaRatio > 0.02 && areaRatio < 0.15) { // 认为是车牌候选区 } }

宽高比卡在2.5到5.0之间,是因为拍摄角度会让比例变形,卡太死稍微斜一点就漏检。面积占比2%到15%是排除远处小噪点和近处占满屏的误检。这两个区间是我试过多批图后觉得比较稳的,你可以按自己场景收窄。

4.3 字符分割与识别的简化方案

毕设级别的字符识别,多数不会上深度学习,而是用投影法分割 + 模板匹配。先对车牌区域二值化,再做垂直投影找字符间隙:

cv::Mat plateGray, plateBin; cv::cvtColor(plateROI, plateGray, cv::COLOR_BGR2GRAY); cv::threshold(plateGray, plateBin, 0, 255, cv::THRESH_BINARY + cv::THRESH_OTSU); // 垂直投影 std::vector<int> projection(plateBin.cols, 0); for (int x = 0; x < plateBin.cols; x++) { for (int y = 0; y < plateBin.rows; y++) { if (plateBin.at<uchar>(y, x) > 0) projection[x]++; } } // 投影值低于阈值的列即为字符间隙,据此切分

THRESH_OTSU自动算阈值,省去手动调。投影法对清晰、端正的车牌有效,一旦有倾斜或污损,分割就会错位,这也是这类项目识别率的天花板所在。想提升就得加透视校正或换 CNN,但那就超出毕设工作量了。

5. 避坑与排查:那些让答辩当场翻车的细节

5.1 编译报 undefined reference

现象:CMake编译阶段报大量undefined reference to cv::xxx。 原因:静态库链接顺序错误,或IMPORTED_LOCATION路径没对上当前 ABI。 解决:按第 3.2 节的顺序,被依赖的库放后面;确认libs/${ANDROID_ABI}/下确实有对应.a文件,别把 armeabi-v7a 的库塞进 arm64-v8a 目录。

5.2 真机闪退且无日志

现象:安装成功,点开就退,Logcat 只有一行UnsatisfiedLinkError。 原因:APK 里没有匹配当前手机 ABI 的.so,或 native 方法名和 Java 层native声明不一致。 解决:用unzip -l app.apk | grep .so看打包了哪些 ABI;核对Java_包名_类名_方法名的 JNI 函数签名,包名里的点要换成下划线。

5.3 预览画面正常但识别框乱跳

现象:相机能预览,但车牌框在屏幕上疯狂抖动,根本框不住。 原因:每帧都独立检测,没有做帧间平滑;或形态学核太大,把相邻区域连成一片。 解决:加一个简单的滑动窗口,连续 N 帧检测到相近位置才认为有效;把形态学核从17x5降到13x5试试。

5.4 识别结果字符错位

现象:车牌框对了,但切出来的字符多一个少一个,或者把边框当成字符。 原因:二值化后车牌上下边框没去掉,投影时边框被当成字符。 解决:在字符分割前,先对车牌区域做一次上下边缘裁剪,去掉上下各 10% 到 15% 的高度,再投影。

5.5 换手机后识别率骤降

现象:在自己手机上好好的,换同学手机演示就废了。 原因:不同手机相机输出的分辨率、色彩空间不同,预处理参数是按原手机调的。 解决:在 native 层统一把输入帧缩放到固定宽度(比如 640),再做后续处理,让参数与分辨率解耦。

6. 进阶改造与验证:把毕设变成能讲清楚的作品

6.1 用固定测试集量化识别率

答辩时老师最爱问「识别率多少」,你得有数。我一般会准备 30 到 50 张不同光照、角度的车牌图,跑一遍统计:

测试条件样本数正确识别识别率
正午顺光151493%
傍晚逆光151067%
倾斜 15 度10660%
夜间补光10550%

这张表比任何口头描述都有说服力,也能直接引出「逆光和倾斜是主要误差来源」的改进方向。

6.2 加透视校正提升倾斜场景

倾斜车牌识别率低,根因是字符被拉伸。加一步透视变换能明显改善:先找到车牌四个角点,再用cv::getPerspectiveTransform和cv::warpPerspective把它拉正。

// 假设已找到车牌四角 srcPts(左上、右上、右下、左下) std::vector<cv::Point2f> dstPts = { {0.f, 0.f}, {300.f, 0.f}, {300.f, 100.f}, {0.f, 100.f} }; cv::Mat M = cv::getPerspectiveTransform(srcPts, dstPts); cv::Mat warped; cv::warpPerspective(plateROI, warped, M, cv::Size(300, 100));

300x100是输出尺寸,按中国车牌比例设的。校正后再走字符分割,倾斜样本的识别率能提一截。这一步代码量不大,但答辩时是个很好的加分点。

6.3 一个我踩过的坑

早些年我图省事,把所有预处理参数写死在 C++ 里,换一批测试图就得重新编译。后来学乖了,把高斯核、Canny 阈值、形态学核这些做成 Java 层可调的参数,通过 JNI 传进去,调参时改配置就行,不用反复编译 native。从那以后我每次做这类图像项目,都强制把可调参数外置,哪怕只是几个整数。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进