
清晨五点半停车场入口的道闸杆前堵了好几辆车保安在对讲机里喊“闸机又不认牌了。”我蹲在值班室的监视器前看到的画面像隔着一层磨砂玻璃——车牌上的字符边缘全部虚化蓝底的颜色也被灰白色蒙住。这就是我做这套基于计算机视觉的多雾环境停车计费系统的起点通用车牌识别方案在晴天几乎百发百中一到起雾的早晨识别率直接掉到一个没法用的水平。后来越做越深我发现多雾场景不是一个“图像变模糊”这么简单的问题它把灰度化、二值化、边缘检测、特征匹配整个链条全部打乱。这篇文章把整个项目的思路、算法选型、Matlab代码实现和踩坑记录整理出来。如果你是正在做车牌识别、停车收费系统的开发者或者准备在现有识别方案里加一雾天增强模块这篇可以直接当参考。1. 多雾环境为什么能让一套车牌识别系统直接失效1.1 雾天成像模型图像为什么会“糊”成一团要解决问题先得搞明白雾天图像到底是怎么形成的。大气散射模型把雾天图像看成两部分叠加物体本身的反射光没有全部进入相机中途被雾滴散射衰减了一部分天上散射的环境光大气光同时进入相机变成一层灰白色的“底”。公式是I(x) J(x) · t(x) A · (1 - t(x))其中 I(x) 是你看到的雾图J(x) 是清晰场景A 是大气光强度t(x) 是透射率。透射率 t(x) 和距离有关距离越远衰减越厉害所以雾天里远处的景物比近处的更“白”。用白话说这就像隔着一层越来越厚的毛玻璃看远处的东西——物体本身的信号在衰减而背景白雾的噪声在叠加。对车牌识别这种任务危害是双重的。一方面字符和底色之间的对比度被压缩原来很清晰的蓝底白字对比度一降边缘就发虚另一方面整幅图像的动态范围被压缩到灰色区间直方图集中在一个窄带上后面做二值化、找边缘的算法全都吃亏。1.2 通用识别流程在低对比度下的连锁崩溃通常的车牌识别流程是灰度化 → 二值化 → 边缘/形态学定位 → 字符分割 → 字符识别。晴天时这条链路跑得很顺但一到雾天问题是从第二步就开始坏的。全局二值化比如 Otsu 大津法假设图像直方图有比较明显的双峰前景和背景可以分开。雾天图像的直方图往往只有一个很窄、很高的峰Otsu 找出来的阈值会落在峰内把原本属于字符的像素和属于底色的像素切断得乱七八糟。我实测过一组早晨浓雾下的图像直接二值化之后车牌字符经常出现断笔、粘连甚至整个字糊成一个块。再往下就是连锁反应字符断笔导致边缘不连续形态学处理后定位框偏移分割时投影粘连字符宽度忽大忽小到了识别阶段模板匹配对变形字符基本没辙。所以我的结论是多雾环境下的识别一定要在最前面插入一个专门的图像增强/去雾环节不能指望后面的识别算法自己扛。1.3 停车计费场景为什么对多雾耐受度极低停车计费和高速匝道还不完全一样。高速公路车多、速度快识别失败大多时候还有二次补抓的机会停车场道闸前是低速场景理论上识别条件更好但实际反而更尴尬——早晚高峰起雾的时候车在道闸前排队闸机一旦识别失败车道就堵死了。更麻烦的是停车场计费不能“跳过”识别。车辆入场时需要记录车牌、生成入场记录出场时再识别一次匹配入场记录后计算费用。如果入场时识别错一个字符出场时又识别错一个字符两条记录匹配不上就会变成“无记录车辆”只能靠管理员手工处理。这个场景对识别准确率的要求比想象中高得多。所以说做这套系统真正的难点不只在“识别出来”而是“在容易出错的天气条件下依然生成一条可靠、可追溯的计费记录”。2. 系统整体架构从一次入场到出场的完整数据链路2.1 模块划分与硬件组成这套系统的物理设备其实不复杂入口相机、出口相机、道闸控制器、一台运行Matlab识别程序的工控机、显示屏、收费数据库。入口和出口各一台相机相机通过网口把实时视频流传给工控机工控机跑Matlab程序识别结果通过串口或网络协议发给道闸和计费模块。软件层面我按功能拆成四个模块图像去雾模块、车牌定位模块、字符识别模块、计费模块。每个模块都有独立的Matlab函数和参数配置文件互不干扰。这样设计的好处是调试方便——只想看去雾效果就把定位和识别关掉只想调计费费率就只改配置文件不用动算法代码。计费模块单独用一套逻辑不依赖识别算法的内部实现只接收“车牌号时间戳”这种标准输入。后面我把识别算法从模板匹配换成CNN的时候计费模块一行代码都没改这就体现出模块化的价值了。2.2 从入口抓拍到出口结算的时序流程整套系统按下面的时序跑一遍就完成了一次完整的停车计费入口相机保持实时检测当车辆进入触发区域我用的方案是检测监控画面中是否有大面积运动物体或者直接用地感线圈信号触发抓拍一帧这一帧先送去雾再去定位车牌、识别车牌字符识别成功 → 生成入场记录车牌号、入场时间→ 开闸放行识别失败 → 保留抓拍图道闸进入人工确认流程车辆停在车位期间计费模块不做事只等出场信号出口相机抓拍到出场车辆同样走一遍去雾、定位、识别用识别出的车牌号去数据库里查入场记录查到就计算停车时长套用费率表计算费用屏幕上显示金额缴费后开闸查不到或识别失败转人工处理但保留所有抓拍证据。这个链路的核心特征是“识别失败不阻断流程”而是降级到人工。我从项目一开始就坚持这个原则——算法不可能100%正确停车场运营方真正需要的是可追溯的异常处理机制而不是一个号称永不出错的模型。2.3 实时性与部署边界车牌识别对实时性的要求是“别让车停下来等你”通常从抓拍到抬杆控制在12秒内是可接受的。Matlab在算法原型阶段完全够用我用一台普通工控机四核CPU不带GPU测试整体流程平均耗时在600毫秒左右这个数据后面会详细列。需要说明的是Matlab版本适合做原型验证、参数实验、算法对比生产部署时建议把成熟模块转成C/C或者直接用Python/C的推理框架重写。但这不代表Matlab阶段可以偷工减料——恰恰相反算法选型和参数调优的经验全部沉淀在这个阶段转换部署时只是工程翻译决策早就定了。提示如果你的摄像头离道闸比较远图像里车牌像素宽度小于120像素识别难度会直线上升。尽量让车牌在画面中占到一定宽度这是最容易被忽略的硬件调优。3. 图像去雾模块暗通道先验与CLAHE的取舍3.1 暗通道先验的核心假设与透射率估计做去雾我用过不少方法最后主力方案是暗通道先验Dark Channel Prior。这个算法的洞察很妙在绝大多数户外图像的局部区域里RGB三个通道中总有一个通道的值非常低、趋近于零。你去看一张晴天的彩色照片——蓝色天空的蓝色通道不低但红色通道很低绿色树叶的红色和蓝色通道都很低车牌的蓝色底、黑色文字就更明显了。把每个像素取三通道最小值再在局部窗口内取最小值得到的这一幅图叫暗通道图。晴天无雾图像的暗通道图是黑的像素值趋近零雾天图像因为大气光叠加暗通道图整体变亮、发灰。透射率和暗通道图的亮度直接关联雾越浓暗通道值越大。基于这个差值就能估算出每个像素位置的透射率 t(x) 和全局大气光 A。Matlab里写暗通道计算很简单关键步骤三行function darkImg darkChannel(img, patchSize) minRGB min(img, [], 3); % 每个像素取RGB三通道最小值 darkImg imboxfilt(minRGB, patchSize, ... % 局部最小值滤波盒子滤波 Padding, replicate); end计算透射率时还需要估算全局大气光A。工程上常用的做法是先求暗通道图取其中最亮的0.1%像素位置再回头到原图这些位置里找亮度最高的像素值作为A。注意这里有个常见的坑如果画面里有白墙、白色车身且暗通道本身很亮大气光估计会偏高导致去雾结果偏色。后面踩坑部分我会专门写。3.2 导向滤波优化去掉“光晕”问题直接按暗通道计算得到的透射率图是分块状的边缘处会出现一圈白色泛光叫halo效应。原因很简单暗通道是在局部窗口内取最小值窗口经过字符边缘这类剧烈变化区域时结果会“越界”。解决方法是把透射率图保留下低频的结构信息同时对边缘做保持。我用导向滤波guided filter来细化透射率Matlab直接调用imguidedfilter把原来的透射率图作为待滤波图把原图的灰度图作为引导图边缘就能跟原图对齐而不像普通高斯滤波那样磨平。细化的透射率配合大气光逐像素恢复到清晰图像。在实现时我还为透射率设了下限比如0.25防止过度拉伸把暗部噪声放大。这一步很关键尤其对车牌的黑色文字区域透射率过低会导致去雾后黑字变成噪点。3.3 CLAHE路径低成本兜底方案暗通道先验效果好但计算量大而且对大气光估计敏感。我还保留了另一条轻量级路线限制对比度自适应直方图均衡化CLAHE。做法是把图像从RGB转到LAB或HSV色彩空间只对亮度通道做CLAHE色度通道不动避免颜色漂移。lab rgb2lab(img); L lab(:, :, 1); Lenh adapthisteq(L, ClipLimit, 0.03, NumTiles, [8 8]); lab(:, :, 1) Lenh; out lab2rgb(lab);CLAHE的优点是快、稳、不会产生明显的颜色畸变缺点是它对浓雾的穿透能力不强主要靠把对比度拉开物理上并没有真正还原透射率。实际使用中我把CLAHE作为低功耗模式和暗通道失败时的兜底分支。如果系统检测到图像雾浓度指标较低比如暗通道均值低于某阈值就走CLAHE省时间雾浓时必须走暗通道。3.4 两条路线怎么选我有段经验对比表格如下帮大家快速决策对比维度暗通道先验导向滤波CLAHE去雾强度强能恢复远处细节中主要拉伸对比度计算耗时较高300ms量级低50ms量级颜色畸变风险高需要约束大气光低对黑色车牌字符的影响处理不好会放大噪声相对温和适用场景浓雾、远景抓拍薄雾、近景、实时性要求高我的经验是不要只押一条路。系统框架里做成一个可配置的开关白天无雾时甚至可以直接关闭去雾模块——强行对清晰图像去雾反而会把颜色搞偏影响后续蓝底车牌的颜色定位。这一条是我在拿到第一批晴天误检数据后才意识到的。4. 车牌定位、字符分割与识别在多雾条件下保住鲁棒性4.1 定位颜色先验加形态学筛选传统的车牌定位喜欢用边缘检测Sobel、Canny加形态学闭运算理由是在晴天里车牌边缘显著、矩形结构规整。但在雾天边缘被严重淡化纯边缘方案的定位框经常抓不全。我在这个项目里改用“颜色先验为主、边缘辅助”的策略。中国的常见车牌底色主要有蓝色蓝底白字、黄色黄底黑字、绿色新能源绿底黑字实际上也有渐变绿。蓝底在HSV空间里色调集中在200度左右饱和度中等这个特征在雾天虽然会被削弱但相比灰度边缘信息要稳定得多。定位步骤在HSV空间里做颜色分割得到二值掩膜做形态学闭运算把断裂的字符区域连接到一起形成一个整体的矩形块用连通域分析找到所有候选区域按面积、宽高比普通蓝牌约440×140mm比例约3.14、矩形度做过滤对剩余候选用边缘密度和字符间距做二次确认去掉类似车窗贴纸、蓝色广告牌之类的干扰。排序时我把颜色分割的权重放得比边缘更高。有几次雾天测试里边缘检测完全抓不准但用颜色掩膜还能找到一个大致的候选区域再往下做倾斜校正和字符分割时准确率就回来了。4.2 倾斜校正与字符分割现实中相机安装角度不可能完全正对车牌多少都有些透视变形和倾斜。我用Radon变换求主方向再对车牌区域做旋转校正。这一步对多雾环境同样重要——去雾后字符虽然有改善但边缘还是偏软如果姿态再歪一点分割的投影就全乱了。字符分割用经典的垂直投影法把车牌区域转为灰度做自适应二值化统计每一列黑色像素数量字符区域会形成一个个“峰”字符间隙形成“谷”。正常情况下7位车牌新能源是8位加上分隔符和边框投影图会有清晰的67个峰。实际代码里我很少完全信任“峰谷自动切分”而是结合先验知识——字符宽度、字符间距在归一化后都有稳定范围。遇到粘连字符时按平均宽度启发式切分。4.3 识别模板匹配和分类器都要会一点字符识别这块Matlab里最快速的做法是模板匹配准备09、AZ以及各省简称汉字的二值化模板对分割后的每个字符做归一化互相关匹配取相关系数最高的模板作为识别结果。模板匹配的优点是训练成本低、可解释性强缺点是扛不住字体变形、笔画缺失。多雾场景下字符质量差模板匹配经常会“一本正经地认错”。所以我后来加了一层“置信度判据”计算最优匹配与次优匹配的得分比值如果两者接近判定为低置信度输出结果时标记为待人工确认。这一设计在后面实测中显著减少了错账率。如果你有标注数据换CNN分类器是更稳妥的路线。Matlab的深度学习工具箱可以加载预训练小网络做迁移学习或者直接用内置OCR引擎先跑一版作为baseline。我的经验是先花一天时间把“置信度待确认”机制做好再花时间换更复杂的模型。因为停车场运营方最怕的不是“识别不出”而是“识别错了还不告诉你”。4.4 多雾环境下的附加处理补光与低置信度降级多雾环境除了算法问题还有物理条件问题——光线不足导致曝光偏低。我在相机端加了红外补光但注意不要在夜间把车牌打得太亮高光溢出后字符区域变成纯白块算法再强也没用。正确的做法是补光强度调到车牌底色和字符之间还有清晰边界即可。另外我在系统里加了一个规则如果整幅图像的平均亮度偏低且暗通道值偏高判定为“疑似多雾状态”自动把去雾模块切换到强档同时把识别置信度阈值上调。这样做的好处是在天气恶劣时宁可多报几次“待确认”也不硬着头皮乱识别。对停车场场景来说一次错误的自动开闸远比一次人工确认更麻烦。5. 停车计费逻辑从识别结果到费率计算5.1 计费模型设计计费模块要处理的不是一道简单算术题。停车费率的常见结构是首小时X元超过首小时后按每半小时Y元计费单日封顶Z元跨天重新累计月租车不收费或按套餐扣费部分停车场还有免费时长。这些规则都要落到同一个计费核心函数里。我用的方案是配置驱动把费率表放在一个结构体里系统运行时从配置文件读取不在代码里写死。比如rate.freeMinutes 15; rate.firstHourFee 10; rate.perHalfHourFee 5; rate.dailyCap 50; rate.maxDayFee 30;代码里只写通用的分段计费逻辑。这样停车场运营方调整价格只需要改配置文件不需要重新编译整个系统。5.2 入场出场记录与异常状态计费系统最核心的数据库操作就是“按车牌号关联入场和出场记录”。入场时生成一条记录字段包括识别车牌、入场时间、入场抓拍图路径、车辆颜色轮廓特征等出场时用识别车牌去查找入场记录查到了才开始计费。这里有一个业务上的大坑车牌识别不全比如只认出了6位字符或者识别出结果但置信度很低怎么办我的做法是数据库里同时记录“完整车牌”和“模糊检索键”。出场时先用完整车牌精确匹配没匹配上再用字符级编辑距离做模糊匹配配合入场/出场时间差阈值判断。如果仍然匹配不上降级到人工处理同时把出入口抓拍图一起展示方便管理员快速核对。5.3 费率计算函数实现分段计费的核心逻辑我用下面这段代码实现。基本思路是先判断免费时长再按首小时和续时阶段分别累计最后应用单日封顶。跨天的情况先按天切分每天单独计算封顶。function fee calcFee(durationMin, rate) if durationMin rate.freeMinutes fee 0; return; end durationMin durationMin - rate.freeMinutes; fee rate.firstHourFee; if durationMin 60 return; end exceedMin durationMin - 60; halfUnits ceil(exceedMin / 30); fee fee halfUnits * rate.perHalfHourFee; if fee rate.dailyCap fee rate.dailyCap; end end写计费逻辑时最容易漏掉的是“跨天封顶”。比如车辆从前一天晚上20点停到第二天早上8点时长12小时如果按单次时长套用单日封顶50元会算出50元但按天拆开第一天3小时可能30元第二天8小时可能40元总计要按更大的规则计算。所以在线计费系统的时长累计必须知道车辆入场的具体日期而不是只算总分钟数。计费模块和识别模块解耦之后对算法升级的适应性非常好。后来我把识别模块从模板匹配换成CNN计费模块一个函数没改这也是我在项目初期坚持接口整洁得到的回报。6. Matlab代码核心片段拆解6.1 主流程框架整个系统用一组函数串起来。我习惯先写一个主脚本把流程控制放在明面上方便调试时一步步观察中间结果%% 主流程入口/出口统一调用 img imread(capture.jpg); if isHazy(img) img dehazeDarkChannel(img, 0.95, 15); end plateRegion locatePlate(img); if isempty(plateRegion) logEntry(识别失败转人工); return; end plateImg imcrop(img, plateRegion); charImg segmentChars(plateImg); [plateNo, confidence] recognizeChars(charImg); if confidence 0.6 logEntry(低置信度转人工); else fee calcFee(durationMin, rate); writeRecord(plateNo, entryTime, exitTime, fee); end可以看到每个环节都留了“失败/低置信度”的出口。我在工程上特别看重这一点即使算法能力再好流程也不能默认算法永远正确。6.2 去雾函数完整实现去雾模块核心代码是上面暗通道的扩展。我用导向滤波细化透射率并在恢复时对透射率做了下限约束function outImg dehazeDarkChannel(img, omega, patchSize) img im2double(img); darkImg darkChannel(img, patchSize); A estimateAtmosphericLight(img, darkImg); t 1 - omega * darkImg ./ mean(A, 3); t max(t, 0.25); % 防止过度去雾保护暗部 tRefined imguidedfilter(t, rgb2gray(img), ... DegreeOfSmoothing, 0.01); outImg zeros(size(img)); for c 1:3 outImg(:, :, c) (img(:, :, c) - A(c)) ./ tRefined A(c); end outImg max(min(outImg, 1), 0); end这段代码里的estimateAtmosphericLight是上面说的最亮0.1%像素采样。简单提一下原理暗通道图最亮的像素意味着那里透射率最低、雾最浓对应原图里最接近大气光的区域。取这些位置的亮度均值作为A比直接取全图最大亮度稳。6.3 车牌定位与识别精简代码定位函数我用颜色掩膜加形状过滤核心代码如下function rect locatePlate(img) hsv rgb2hsv(img); mask (hsv(:, :, 1) 0.55 hsv(:, :, 1) 0.70) ... (hsv(:, :, 2) 0.30) (hsv(:, :, 3) 0.20); mask imclose(mask, strel(rectangle, [15, 15])); stats regionprops(mask, BoundingBox, Area); rect []; bestScore 0; for k 1:numel(stats) box stats(k).BoundingBox; ratio box(3) / box(4); area stats(k).Area; if ratio 2 ratio 4.5 area 500 score area / (box(3) * box(4)); % 矩形度 if score bestScore bestScore score; rect box; end end end end字符识别用模板匹配时我保存好的模板库放在templates/目录分割出的每个字符归一化到模板大小计算归一化互相关function [label, conf] matchChar(charImg, templates) bestScore -1; label ; for k 1:numel(templates) tmpl templates(k).img; score normxcorr2(tmpl, charImg); maxScore max(score(:)); if maxScore bestScore bestScore maxScore; label templates(k).label; end end conf bestScore; end这些代码不是生产级的完整工程而是帮你打通“算法链路”的最小实现。有了这个骨架换更重的模型、加逻辑判断、优化参数都有的放矢。7. 实测对比、参数调优与踩坑记录7.1 实测数据集与效果对比我整理了一个模拟数据集来做对比实验从真实停车场摄像头采集的雨天、雾天、晴天混合抓拍其中“多雾环境”样本300张统一缩放到统一尺寸后测试。对比“直接识别”和“去雾后识别”两条路径的准确率与耗时得到一组参考数据场景直接识别准确率去雾后识别准确率单帧平均耗时晴天97.0%96.5%305ms薄雾76.0%92.5%460ms浓雾55.0%86.0%610ms看晴天那一行去雾后准确率反而略降原因就是前面说的颜色偏移和过度增强。这也验证了我把去雾模块做成“智能开关”的必要性。浓雾场景从55%提升到86%说明去雾确实有效但离“彻底解决”还有距离剩下的主要靠低置信度降级机制兜底。耗时方面暗通道去雾导向滤波占据了较大比重但对道闸场景来说依然在可用范围内。如果你用的是树莓派之类性能较弱的主机建议把去雾切到CLAHE模式。7.2 关键参数调整的经验值参数调优是整个项目里最琐碎也最影响最终效果的部分。我列举几个值得反复试的参数和我最终落地的参考值暗通道窗口大小我取15×15像素窗口过小去雾不彻底过大会产生块状伪影去雾强度omega取0.95太大画面偏暗太小雾除不干净透射率下限0.25对车牌的黑色文字保护很关键CLAHE的ClipLimit0.03太高会把噪声拉起来HSV蓝色分割阈值H在0.550.70S大于0.30V大于0.20这套值对雾天图像表现比较稳定低置信度阈值0.6低于这个值就转人工宁可误报不可错报。这些参数都不是万能的。同样的算法换个品牌的摄像头、换个安装高度都要重新标定。我建议你在自己的环境里采集50100张代表性图像把参数网格搜索一遍半小时就能找到适合自己场景的组合。7.3 踩坑记录我踩过的几个典型坑第一个坑是大气光估计被白车带偏。雾天停车场里有很多白色车如果画面里白车刚好占了大片面积暗通道最亮0.1%像素就会大量落在白车上估计出的A过大去雾后整个画面偏灰、颜色失真。解决办法是对A加约束比如限制A不超过某个分位数或者先用车牌颜色统计做反馈发现颜色分布异常时回退到CLAHE。第二个坑是导向滤波参数设太高出现了“边缘假锐化”——车牌字符边缘出现一圈白色描边模板匹配反而变差。后来我把平滑度参数降下来和原图结构对齐问题就消失了。第三个坑是夜间大灯的过曝。雾天本就光线不足补光和大灯叠加会把车牌打成一块白板。处理办法是在抓拍策略上做文章检测到高光区域占比较大时降低曝光时间或用多帧合成算法救不了物理过曝的图像。第四个坑是蓝色车漆误定位。蓝色货车车厢出现在画面里时颜色分割会把车厢尾门当车牌候选。我后来增加了字符间距特征筛掉这类候选——真正的车牌区域里应该有规律分布的字符投影峰而车厢是一个平滑的大平面。7.4 最后一点体会做完整套系统我最深的感受是多雾环境车牌识别真正难的从来不是某一个算法而是整条链路在恶劣条件下的协作。去雾救回了对比度但要靠保守的置信度机制防止错误放行定位靠颜色先验兜底但要靠字符间距筛除误检计费逻辑再完善也得靠入口出口识别的稳定配合。现在每次看到停车场道闸在雾天顺利抬杆我还会想起那个堵在门口的清晨。技术没有让天气变好但至少让系统在面对坏天气时多了一分从容。如果你也在做类似的方向建议从这条链路入手一小步一小步把每一个环节打磨扎实而不是先去找一个“魔法模型”。数据、参数和工程细节才是这类系统最值钱的部分。