简介:基于DSP的车牌识别系统完整源码包,内含可直接使用的工程文件与配套说明,并对新能源绿牌识别做了扩展。项目覆盖车牌定位、字符分割、特征提取与识别等关键流程,SVM分类器与ANN网络相关模型、字符映射表、XML配置等一应俱全,适合计算机、电子信息等专业学生用于课程设计、期末大作业或毕业设计参考。资源共417个文件,压缩包约101MB;源码以C++为主,含32个cpp、24个h和19个hpp文件,另附295张jpg图片、若干txt及docx文档,可帮助查看处理效果、理解使用步骤。目前已有67人浏览学习,内容完整且可运行,适合具备一定图像处理基础、想在DSP平台实践车牌识别算法的学习者。作者还提供训练菜单与批量测试入口,便于二次开发与算法对比,是研究字符识别与嵌入式视觉的实用参考资料。
1. 基于DSP的车牌识别:先从源码结构看清楚它在解决什么问题
拆这个资源的人,多半是手里有块 TI C6748 或者同档次的 DSP 开发板,想让它短时间跑出车牌识别效果。zip 里没有花哨的深度学习权重,svm.7z、annGray.7z、annCh.7z 这些压缩包对应的是传统视觉一代的成熟组合:ERFilter 做候选区域检测,SVM/ANN 做分类器,xmlParser 读配置,chinese_mapping 存汉字映射。它解决的核心问题是,在内存和浮点资源都受限的嵌入式环境里把"定位—分割—识别"整条链路跑通,还兼容了新能源绿牌的 8 位字符格式。适合正在做 DSP 视觉课程设计、或者要把 OpenCV 生态里车辆检测能力压缩到 RTOS/裸机场景的工程师,也适合想从源码层面理解 SVM 与 ANN 在真实产品里如何分工的人。
2. ERFilter 车牌定位模块的 DSP 化改造
2.1 极端区域检测:车牌字符为什么用 ERFilter
车牌定位最怕的不是字不清楚,而是光照不均匀。传统边缘检测加形态学在逆光、夜间车灯直射时,边缘断裂严重,候选框要么多要么漏。ERFilter 走的是另一条路:从灰度图像里提取极端区域(extremal region),再按区域之间的嵌套关系构建组件树,逐层合并,最终输出类字符连通域。它的好处是天然对全局阈值不敏感,字符笔画内部灰度一致时也能被完整召回。
工程里的 erfilter.cpp 基本就是 OpenCV text 模块那个 ERFilter 的裁剪版,主要改动在去掉对 cv::Mat 的隐式依赖、把 vector 换成定长数组。在 DSP 上直接编译原版会踩两个坑:第一,ERFilter 内部要维护一个按面积排序的栈结构,vector 的 push_back 在缺内存时抛异常,RTOS 下直接挂;第二,OpenCV 的 erfilter.cpp 会调用 cv::parallel_for_ 做分块,DSP 上没有 OpenMP 支持。所以移植时的常见做法是把组件栈预分配成图片尺寸的两倍,用下标代替迭代器,所有中间结果放到一块连续内存里。
2.2 灰度图输入与内存预分配
下面是按 C6748 的 L2 SRAM 使用习惯改写的结构体,去掉依赖后核心逻辑可以跑在任意 C 编译器上。
// DSP_ERFilter: 为C6748裁剪的极端区域提取器 #include <cstdint> struct RegionElem { int parent; // 父区域索引, 构建组件树用 int child; // 首个子区域索引 int next; // 兄弟链; 用数组下标替代指针, 保证可序列化 int pixelCount; // 区域像素数, 用于面积过滤 int level; // 灰度阈值层级, 0~63 float mean; // 区域灰度均值, 后续特征计算复用 }; class ERFilterLite { public: void init(uint16_t w, uint16_t h) { pool_ = new RegionElem[(uint32_t)w * h * 2]; buf_ = (uint8_t*)dsp_memalign(4, (uint32_t)w * h); } int run(const uint8_t* gray, Rect* boxes, int maxBox, int minArea, int maxArea); private: RegionElem* pool_; uint8_t* buf_; };这段代码的逻辑说明:pool_的大小取 w×h×2,是考虑到字符区域嵌套合并时最坏情况下区域数量接近像素数,2 倍留了合并冗余;buf_用dsp_memalign对齐到 4 字节,保证 cache line 访问不跨边界。run里前向遍历灰度图,遇到未处理像素就做区域生长,生长过程中只更新pixelCount和mean,合并父节点时再决定是否输出候选框。
参数上,minArea和maxArea需要按 camera 的实际安装高度调。常见做法是 minArea 取图像面积的 0.0005 到 0.001,maxArea 取 0.01,这样能把路牌、车标等大块极端区域先滤掉。如果发现车牌候选框被切成两半,说明level层级太少,把 256 级灰度量化到 64 级时阶梯太大,建议保留 128 级。
2.3 候选框合并与形态学闭运算
ERFilter 输出的是字符级别的区域,字符之间还有间隔,所以要按车牌先验做水平方向合并。源码里没有单独做开运算,而是在合并候选框后做了一次横向膨胀加纵向腐蚀的闭运算,目的是把相邻字符粘连成完整车牌框,同时去掉孤立噪点。
常见的合并条件是:两个字符框的垂直中心距离小于各自高度的一半、水平间距在 0.2 到 1.2 倍字符宽度之间、面积比在 0.5 到 2.0 之间。满足条件就合并外接矩形,直到没有新合并发生。这一步在 DSP 上要用到一个额外数组记录每个候选框的归属链,否则反复遍历startsWith会退化到 O(n³)。合并完之后再做一次宽高比检查,蓝牌宽高比约 440:140,新能源车牌约 480:140,允许 20% 浮动。
// 基于并查集的候选框合并 // rects: ERFilter输出的字符候选框, n: 框数量 static void mergeBoxes(Rect* rects, int n, int* next, int* rank) { for (int i = 0; i < n; i++) next[i] = i; // 初始化并查集 for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (rankSamePlate(rects[i], rects[j])) { // 垂直中心/间距/面积比 int a = findRoot(next, i), b = findRoot(next, j); if (a != b) next[a] = b; // 合并到根 } } } // 第二次循环: 把同一根节点的框取并集得到最终车牌框 }说明一下参数:rankSamePlate内部三个阈值建议放到配置项里,用 xmlParser 读出来,不要写死在代码里。并查集的rank数组在框数量小于 64 时可以省掉,直接让next[a]=b就行,但候选框多时按秩合并能让整体遍历保持接近线性。这一步跑完得到的矩形再放大 5% 作为后续字符分割的输入区域,避免边缘字符被切掉。
3. SVM 与 ANN 字符分类模型的加载、预测与选型
3.1 四个模型文件的分工与选择逻辑
svm.7z、annGray.7z、annCh.7z、ann.7z 这四个包不是同一组模型的多个版本,而是定位和识别两个阶段的四个用途。看命名也能猜出大概,但要区分清楚annGray和annCh的边界:前者处理车牌上的数字字母,后者只处理第一个汉字。
| 模型包 | 输入特征(常见做法) | 输出类别 | 在管线中的位置 |
|---|---|---|---|
| svm.7z | HOG 特征,约 144 维 | 二分类:车牌/非车牌 | ERFilter 候选框筛选 |
| annGray.7z | 20×20 灰度归一化图 | 34 类(数字+字母) | 车牌字符识别(灰度) |
| annCh.7z | 20×20 汉字灰度图 | 31 类省份简称 | 车牌首个汉字识别 |
| ann.7z | 32×32 字符灰度图 | 多类(依训练集而定) | 通用字符识别兜底 |
34 类是怎么来的:0 到 9 共 10 个数字,去掉 I 和 O 的字母 24 个,合计 34。汉字 31 类对应全国省级行政区简称,训练时按 chinese_mapping 的索引顺序排列。ann.7z和annGray.7z看起来重复,实际区别在输入尺度:32×32 的模型对字符形变容忍度更高,但前向计算量是 20×20 的 2.56 倍,DSP 上默认走 annGray;只有识别置信度低时才用 ann 兜底。
3.2 SVM 模型预测的工程写法
SVM 在 DSP 上的预测瓶颈不在乘加,而在核函数计算。车牌检测用的通常是 RBF 核,每个支持向量都要做一次高维距离计算,支持向量数量上去了延迟明显。源码包里 svm.7z 训练出的模型体量不算大,但预测函数要为 DSP 单独写一遍特征向量构造。
// libsvm 标准预测函数在 DSP 上的精简实现 #include "svm.h" static double kernel_rbf(const svm_node* x, const svm_node* y, double gamma) { double sum = 0.0; while (x->index != -1 && y->index != -1) { if (x->index == y->index) { double d = x->value - y->value; sum += d * d; x++; y++; } else if (x->index < y->index) x++; else y++; } return exp(-gamma * sum); } double predictPlate(const svm_model* model, const float* hog, int dim) { svm_node* x = (svm_node*)dsplib_malloc((dim + 1) * sizeof(svm_node)); for (int i = 0; i < dim; i++) { x[i].index = i + 1; x[i].value = hog[i]; } x[dim].index = -1; double sum = -model->rho[0]; for (int i = 0; i < model->l; i++) { sum += model->sv_coef[0][i] * kernel_rbf(x, &model->SV[i], model->param.gamma); } dsplib_free(x); return (sum > 0.0) ? 1.0 : -1.0; }说明一下参数:dim是 HOG 特征维数,训练 svm.7z 时用什么维数预测就要用什么,少一位和多位都会导致 index 错位;model->param.gamma是 RBF 核宽度,常见取值范围 0.01 到 0.1,gamma 取大了容易过拟合,预测时表现为候选框大量被判为正样本。dsplib_malloc用完立即释放,特征向量只存活一次前向预测的时间。注意rho是 libsvm 模型里的常量偏移,代表决策函数里的 b 值,不能省。
3.3 灰度与颜色双通道的切换
annGray 和 annCh 的推断接口可以共用一套前向函数,区别只在输入图的通道数和归一化方式。灰度模型输入是亮度归一化到 0~1 的浮点图;颜色模型需要先把 RGB 转成 HSV,提取色调通道再喂给同一网络。DSP 上做 HSV 转换成本不低,一个 640×480 的画面全量转一次要吃掉几毫秒,所以源码里的默认策略是先用灰度跑一遍整图识别,置信度低于阈值才对对应区域做颜色判断。
// annGray/annCh 共用同一推断API, 只在特征提取阶段分路 int classifyChar(const uint8_t* charImg, int w, int h, const DSP_ANN* net, float* scores, int* top2) { float* in = net->input; // 输入层固定 20*20 归一化 for (int i = 0; i < w * h; i++) in[i] = charImg[i] / 255.0f; // 前向: 20*20 -> hidden(64) -> output(34 或 31) for (int hIdx = 0; hIdx < net->hidden; hIdx++) { float acc = net->hiddenBias[hIdx]; for (int i = 0; i < net->inputSize; i++) acc += net->wIn[hIdx * net->inputSize + i] * in[i]; net->hiddenOut[hIdx] = 1.0f / (1.0f + expf(-acc)); // sigmoid } for (int oIdx = 0; oIdx < net->outSize; oIdx++) { float acc = net->outBias[oIdx]; for (int hIdx = 0; hIdx < net->hidden; hIdx++) acc += net->wOut[oIdx * net->hidden + hIdx] * net->hiddenOut[hIdx]; scores[oIdx] = acc; } // 找前两大致置信度 if (scores[top2[0]] < scores[top2[1]]) { int t = top2[0]; top2[0] = top2[1]; top2[1] = t; } return top2[0]; }这里要注意 softmax 没有显式调用,源码里输出层直接用加权和,原因是 DSP 上算 exp 和除法代价高,且字符分类只关心最大得分位置,不做概率解释。如果做多候选融合,建议 extern 一个 softmax 函数单独算。scores数组由调用方分配,长度不小于net->outSize,否则写越界。top2用于后面汉字和数字字母之间的交叉验证。
4. chinese_mapping 与 xmlParser:车牌字符集与配置的轻量实现
4.1 省份汉字映射表与索引顺序
训练 annCh.7z 时的类别序号和最终输出汉字不是天然对应的,必须有一个映射文件。chinese_mapping 这个文件在源码里就是一张顺序表,索引 0 代表"京",1 代表"津",依次排下去。这里最容易被忽略的是训练时的 sortIdx 顺序,如果训练脚本用的文件名是按拼音排序,那么映射表的顺序也必须一致,不对应的话识别结果会整体错位。
// 省份简称映射, 顺序与训练样本的 sortIdx 保持一致 static const char* provinceMap[] = { "京","津","冀","晋","蒙","辽","吉","黑", "沪","苏","浙","皖","闽","赣","鲁","豫", "鄂","湘","粤","桂","琼","渝","川","贵", "云","藏","陕","甘","青","宁","新" }; static int mapProvinceIndex(const char* province) { for (int i = 0; i < 31; i++) if (strcmp(provinceMap[i], province) == 0) return i; return -1; // 不在映射表内, 调用方需要做拒识 }映射表在前端使用时的参数是索引长度必须是 31,不能多也不能少。有的源码为了兼容摩托车号牌会加"使""领"等特殊字符,如果 annCh.7z 是按 31 类训练的,加字符就要重训最后输出层,不能只改映射表。返回值 -1 的处理很关键:识别到不在映射表里的类时,常见做法是判为拒识而不是随便选一个就近汉字,否则绿牌的"粤B D12345"会被错误映射成字典里的邻近字符。
4.2 xmlParser 的轻量解析实现
工程里没有引 OpenCV 的 FileStorage,因为其 XML/JSON 写法在嵌入式上太重。xmlParser.cpp 实现的是一个只支持单层 key-value 的解析器,去掉注释和多余空白,只做两件事:取字符串、取整数。
// 轻量XML取值, 仅支持单层tag, 不处理嵌套 static int xmlGetInt(const char* xml, const char* tag, int defVal) { char head[64], tail[64]; snprintf(head, sizeof(head), "<%s>", tag); snprintf(tail, sizeof(tail), "</%s>", tag); const char* s = strstr(xml, head); const char* e = s ? strstr(s + strlen(head), tail) : nullptr; if (!s || !e) return defVal; char buf[32] = {0}; int n = (int)(e - s - strlen(head)); if (n > 0 && n < 32) { memcpy(buf, s + strlen(head), n); return atoi(buf); } return defVal; }参数说明:head和tail的缓冲区长度 64 是基于标签名不超过 32 字的假设,如果配置项命名很长要同步加大,否则 snprintf 截断会让 strstr 匹配到错误位置。defVal是容错默认值,XML 里漏写某个节点时用默认值启动,避免 DSP 上跑一半因为空指针崩掉。这个实现有个隐含限制:遇到同名标签嵌套时会取第一个闭合对,所以配置文件的组织结构必须保持单层,不要出现区域分组的嵌套写法。
4.3 新能源车牌与 8 字符序列的适配
新能源车牌相对传统蓝牌多了一位,字符序列变成 8 位,且第 3 位固定是 D(纯电)或 F(混动)。如果直接复用旧的 7 字符分割逻辑,D/F 会被当成普通字母参与分类,影响后续位数对齐。源码的处理是不改字符分类器,而是在分割后做一次规则校验。
static bool isNewEnergyPlate(const char* plate, int len) { if (len != 8) return false; // 第3位必须是 D(纯电) 或 F(混动) return plate[2] == 'D' || plate[2] == 'F'; } static int handlePlateSequence(const char* raw, int len, char* out) { if (isNewEnergyPlate(raw, len)) { out[0] = raw[0]; out[1] = raw[1]; out[2] = raw[2]; // 后5位数字字母混排, 跳过D/F的分类置信度惩罚 memcpy(out + 3, raw + 3, 5); return 8; } memcpy(out, raw, 7); return 7; }这段代码的逻辑是把 D/F 从字符分类结果中摘出来,不作为分类器输出的一部分。训练时也不需要单独加 D/F 两个类别,只需要保证传统 34 类分类器对数字和字母的区分度足够。handlePlateSequence的输出缓冲区需要至少 9 字节,多出的第 9 字节留给字符串结尾'\0',车牌识别结果经常直接拼到串口输出帧里,少了结尾符会带出脏数据。
5. batch_test_menu 批量回放与 DSP 板级验证
5.1 批量测试菜单的组织方式
开发 DSP 程序最怕对着单张图调参,调好了换摄像头角度又废。源码里的 batch_test_menu 就是一个无界面的批量回放工具,按目录扫描测试图,输出每张图的识别结果和耗时。常见用法是把真值放在同名 txt 里,程序自动比对并统计正确率。
# 批量回放: 每张图配一个同名txt, 内容格式为 "plate=粤B12345,type=blue" ./batch_test_menu \ --input ./samples/test_imgs \ --label ./samples/test_labels \ --model ./models \ --topk 2 \ --threads 0--model指向存放 svm.7z、annGray.7z、annCh.7z 解压后的目录,程序按固定文件名加载。--topk 2表示字符分类时保留前两个候选,用于观察第二候选和正确结果的差距。--threads 0关闭 OpenMP 并行,DSP 上跑的是单核版本,这个参数保留是为了和 PC 端联调时对齐行为。
5.2 三个关键调优点
第一,模型权重全部放到 L2 SRAM 而不是 DDR,否则每帧预测要反复访问外部内存,TLB miss 直接吃掉一半带宽,常见做法是用 CCS 的#pragma DATA_SECTION把权重数组定位到L2RAM。第二,gamma 核函数里的exp用查表替代,在 -10 到 0 的输入区间放 512 个点的表,精度损失小于百分之一,耗时下降明显。第三,图像按行对齐到 16 字节,DSP 的 cache line 是 64 字节,不对齐时一次读一行会触发两次缓存填充,这在高帧率场景下是隐形的帧率杀手。
5.3 现场排错技巧
batch_test_menu 支持把中间结果 dump 成 PPM 图片,定位阶段输出的候选框、字符分割后每块的索引、分类器输出的 top1 和 top2,都按帧号写到独立目录。在 DSP 上没显示设备时,这个方法几乎是唯一的调试手段。如果发现真值标注正确但识别错,先去翻 dump 出来的字符块,看是不是分割阶段把"B"和"8"切了半边,那是分割阈值的问题,不是分类器的锅。确认分割正常但分类出错,再看 top2 是哪个类别,如果经常把"B"和"8"混在一起,就要回头检查训练集里这两个字符的样本数量配比,加样本重训 annGray。
本文还有配套的精品资源,点击获取