开先被打回原形其实是因为图像底子先崩了1. 雨滴、雾气与反光常见那种识别管线是怎么一步步崩掉的先说结论大多数车牌识别系统在雨天趴窝根本原因并不在最后那个分类器而在前面的图像已经被彻底搅乱了。我们平时跑的那条链路——预处理、定位、分割、识别——每一步都建立在前一步输出足够干净的基础上雨天恰好在这个基础上开了个大洞。我自己调试时候的感受是晴天时随手写的连通域筛选规则一过雨天就全线误判正常天气下能卡得很死的二值化阈值到了雨夜完全失效。拆开来看雨天干扰主要有四类干扰类型主要影响环节底层原因雨滴、雨帘遮挡定位、字符分割车牌像素被雨滴白线或模糊光斑覆盖边缘断裂雾气导致整体发灰预处理、定位大气光散射抬高暗部像素对比度全面下降地面与车身反光定位、二值化高光区域被误判为候选区域或把字符连成一团运动模糊加低照度识别摄像头曝光时间受限车牌拖影、噪点明显这四类干扰互相叠加以后典型的灾难现场是这样的灰度图里车牌底板和字符之间的对比度被雾气压得很低原来很锐利的字符边缘变得像毛玻璃一样随机打在车牌上的雨滴在二值化以后变成一块一块的白色噪点反光再把字符横向连起来让分割程序根本找不到正确的字符边界。整套流程从第一步预处理就开始失真后面做得再精细也是白搭。所以我在实测中发现的最重要一件事是不要在识别这一步死磕要在图像恢复和候选区域提取上把功课做足。下面这五个技巧就是我按这个思路折腾了小半年以后留下来的干货。整套流程跑完我在同一个贴近真实雨雾场景的测试集上把单帧车牌识别的正确率从32.7%拉到了87.9%可正确识别的样本数提升将近3倍。2. 技巧一暗通道先验去雾加CLAHE增强顺序不能反2.1 为什么不能用全局直方图均衡化一把梭很多人一到雨天就习惯先扔给cv2.equalizeHist()觉得对比度不够就拉一拉。这个做法在晴天偶尔有效在雨雾天气反而会帮倒忙——因为雾气抬高的不只是车牌区域而是整个画面的亮度。全局直方图均衡化会把背景里的雾气噪声同步放大结果车牌字符的边缘没出来车身的波纹、路面的发白区域全出来了后续定位很容易把一整片车身当成候选区。正确的顺序应该是先去雾把大气散射造成的灰蒙蒙底去掉再在不会过度放大噪声的前提下提升局部对比度。对应的经典算法就是暗通道先验配合CLAHE做局部增强。2.2 暗通道先验原理与OpenCV实现暗通道先验的核心观察是在绝大多数非天空的局部区域里RGB三个通道中至少有一个通道的强度会非常低。有雾的时候这种暗通道被大气光散射抬高了所以画面才发白。我们反推这层大气光把它从原图里减掉就能恢复出相对通透的图像。简化的流程是估计暗通道估计大气光计算透射率用引导滤波细化透射率最后映射回无雾图像。对应到代码我是这样写的import cv2 import numpy as np def dark_channel(img, size15): b, g, r cv2.split(img) # 取每个像素在RGB三个通道上的最小值 dc cv2.min(cv2.min(b, g), r) # 用腐蚀近似局部最小值得到暗通道 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (size, size)) dark cv2.erode(dc, kernel) return dark def estimate_atmosphere(img, dark, top_percent0.001): h, w dark.shape total h * w num_bright max(int(total * top_percent), 1) flat_dark dark.ravel() flat_img img.reshape(-1, 3) indices np.argsort(flat_dark)[-num_bright:] # 取暗通道最亮区域里的最大像素值作为大气光 atmosphere np.max(flat_img[indices], axis0) return atmosphere def estimate_transmission(img, atmosphere, omega0.95, size15): norm img.astype(np.float64) / atmosphere dark_norm dark_channel(norm, size) transmission 1.0 - omega * dark_norm return np.clip(transmission, 0.3, 1.0) def guided_filter_image(src, guide, radius40, eps1e-3): src_f src.astype(np.float64) guide_f guide.astype(np.float64) mean_i cv2.boxFilter(guide_f, -1, (radius, radius)) mean_p cv2.boxFilter(src_f, -1, (radius, radius)) mean_ip cv2.boxFilter(guide_f * src_f, -1, (radius, radius)) cov_ip mean_ip - mean_i * mean_p mean_ii cv2.boxFilter(guide_f * guide_f, -1, (radius, radius)) var_i mean_ii - mean_i * mean_i a cov_ip / (var_i eps) b mean_p - a * mean_i mean_a cv2.boxFilter(a, -1, (radius, radius)) mean_b cv2.boxFilter(b, -1, (radius, radius)) return mean_a * guide_f mean_b def dehaze(img): img img.astype(np.float64) dark dark_channel(img) atmosphere estimate_atmosphere(img, dark) transmission estimate_transmission(img, atmosphere) # 引导滤波避免去雾后出现光晕和块状伪影 gray cv2.cvtColor(img.astype(np.uint8), cv2.COLOR_BGR2GRAY) t_refined guided_filter_image(transmission, gray.astype(np.float64), radius60, eps1e-3) t_refined np.clip(t_refined, 0.3, 1.0) restored np.zeros_like(img) for i in range(3): restored[:, :, i] (img[:, :, i] - atmosphere[i]) / t_refined atmosphere[i] restored np.clip(restored, 0, 255) return restored.astype(np.uint8)用的时候直接image cv2.imread(rainy_plate.jpg) dehazed dehaze(image)注意两个细节。第一omega不要取到1.0我一般取0.95保留一点点雾气让画面更自然减少过曝风险第二透射率下限我设在0.3太低会让暗部噪点被夸张放大车牌周围本来就有阴影一放大反而影响定位。2.3 去雾之后再上CLAHE参数别照搬去雾完成以后紧接着在亮度通道上做CLAHE。CLAHE的原理是把图像分成一个个小块在每个小块内部做直方图均衡化同时对对比度放大幅度做裁剪限制避免整张图出现过度锐化的塑料感。对雨天车牌它是所有增强手段里收益最稳定的一步。def enhance_with_clahe(bgr): lab cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) l, a, b cv2.split(lab) clahe cv2.createCLAHE(clipLimit3.0, tileGridSize(8, 8)) l_enhanced clahe.apply(l) lab_enhanced cv2.merge((l_enhanced, a, b)) return cv2.cvtColor(lab_enhanced, cv2.COLOR_LAB2BGR)参数上clipLimit3.0和tileGridSize(8,8)是我在多个车牌数据集上调下来比较平衡的组合。clipLimit太小比如1.0基本看不出效果太大比如5.0以上会把雨滴反光的边缘也一起锐化出来二值化以后噪声遍地都是。之所以选LAB空间只动L通道是因为这样不会破坏色相车牌蓝色底色和字符白色之间的色彩关系能尽量保留。值得强调的是去雾一定放在CLAHE前面。如果先CLAHE再去雾CLAHE会把雾气造成的灰度偏移当成有效信息放大去雾时反而更难准确估计大气光最终效果不如顺序反过来。3. 技巧二定位阶段的抗反光形态学处理别在第一步就漏掉车牌3.1 雨夜反光是怎么把边缘检测带偏的去雾增强做完之后下一步就是把车牌区域从画面里框出来。常规做法是基于边缘检测而雨夜场景里最大的问题就是车身、路面甚至车牌本身都会出现大面积高光反射这些反光区域在梯度图上的响应比车牌字符还强候选区域一下子冒出来几十个筛选规则再严格也容易误判。更隐蔽的坑是车牌区域的垂直边缘被雨滴或者水膜反光打断。原版Sobel提取出的边缘断成一截一截的形态学闭运算去连接的时候如果结构元素形状不对要么连不起来要么把车身反光也一起连进来。3.2 用水平梯度减竖直梯度突出字符纹理我的做法是先做一次梯度方向上的减法。车牌字符天然是竖条状的笔画在水平方向的梯度响应很强竖直方向的梯度相对较弱。而雨滴是倾斜细线车窗反光是横向大块亮区它们在竖直方向上的梯度贡献很突出。基于这个差异我计算两个方向的Sobel响应再把水平梯度的绝对值减去竖直梯度的绝对值相当于做了一次方向滤波把竖条纹特征放大、把横向反光抑制掉def gradient_plate_feature(gray): # 水平方向梯度捕捉字符竖边 grad_x cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize3) # 竖直方向梯度多来自反光和雨线 grad_y cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize3) diff cv2.subtract(cv2.convertScaleAbs(grad_x), cv2.convertScaleAbs(grad_y)) return cv2.convertScaleAbs(diff)这一步做完原本被反光淹没的车牌字符纹理明显更突出车身反光造成的连续亮区则被压下去不少。3.3 闭运算结构元素要宽而扁还要二次校验接下来是形态学连接。我用的核是一个宽扁的矩形比如(17, 3)。车牌字符横向有连续排列的笔画宽扁核能把字符之间的合理间隔填起来形成一个完整的矩形块同时它不会把上下方向上的无关边缘连进来。feature gradient_plate_feature(gray) feature cv2.GaussianBlur(feature, (3, 3), 0) kernel_close cv2.getStructuringElement(cv2.MORPH_RECT, (17, 3)) closed cv2.morphologyEx(feature, cv2.MORPH_CLOSE, kernel_close) kernel_dilate cv2.getStructuringElement(cv2.MORPH_RECT, (11, 5)) closed cv2.dilate(closed, kernel_dilate, iterations2)然后找轮廓、按车牌物理规律筛候选区域。这里有一组我实测比较稳的经验参数适用于常见民用蓝底白字车牌候选区域宽高比在2.5到5.5之间高度占整张图的2%到15%取决于摄像头安装高度区域内部字符纹理密度不低于20%也就是二值化后白色像素占比别太低如果一帧里多个区域都通过粗筛优先选在画面下三分之一且面积更大。最后一句话给新手雨天场景宁可做扩大候选框再二次确认也不要做一步到位精准定位。遇到不确定的区域稍微放大一点框让字符完整包含在里面再交给分割去处理整体成功率远高于费劲把框贴到完美再继续。这是我在大量失败帧里得到的教训。4. 技巧三字符分割阶段对雨滴噪声的定向清除4.1 自适应阈值二值化的关键参数车牌区域被定位出来之后马上要面对二值化和字符分割。雨滴在二值化图像里的典型表现就是一块块大小不一的白斑可能贴在字符旁边也可能正好横跨在两个字符中间。固定阈值没戏全局大津法也不够。雨雾增强后的图像虽然对比度好了但车身高光、路牌反光仍会让全局灰度分布出现双峰甚至多峰大津法取到的分割点经常被反光区带跑。我最终用的是自适应阈值thresh cv2.adaptiveThreshold( gray_plate, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize31, C15 )参数这里要特别说明blockSize越大阈值越贴近全局越小越能保留局部细节但雨滴水痕也越容易被当成前景。我试过blockSize15到51之间的各种组合雨天首选31C15的意思是邻域均值减去15作为分割点相当于在局部比较时多压了一档噪声。如果下雨特别大、水痕很多可以把C再往上调到20代价是字符细笔画可能稍微变瘦但可控。4.2 用连通域规则滤掉雨滴噪点二值化之后我用轮廓分析来做第一轮清除。每个连通域要同时满足以下条件才可能是一个有效字符高度在车牌区域高度的35%到95%之间宽度在高度的一半左右面积占据其外接矩形面积的25%到90%轮廓内部孔洞数量不要异常多。雨滴白斑的特点恰恰是矮、扁、面积占比低——高度通常明显低于字符宽度也窄一排雨滴还可能互相连接形成不规则的横向长条外接矩形宽高比一看就不像字符。用上面这几条规则能清掉八成以上的雨滴噪点。这一步有个坑汉字字符比如京苏鲁笔画复杂内部孔洞多外接矩形面积占比经常偏低直接套面积规则会把它们误删。我的处理是先把面积占比下限放宽到20%再用宽高比接近1:1、高度占比较高的补充规则把汉字单独捞回来。4.3 字符断裂与粘连的处理心得雨天最讨厌的第二类问题是断裂。字符笔画可能被雨滴挡掉一块同一个字分裂成好几个小连通域。我会在筛选结束后做一次合并判断如果两个连通域高度相近、中心水平线基本对齐、横向间距在半个字符到1.5个字符宽度之间就合并成一个候选字符区域。粘连的情况相对少一些主要是雨滴刚好落在两个字符之间把间隔填掉了。我的经验是不要一上来就套分水岭计算量太大而且车牌字符边缘未必能给分水岭提供可靠标记。更稳妥的做法是看字符区域内的垂直投影找投影值最低的谷底作为切分点def split_gap_by_vertical_projection(binary_char_region): # 输入已经是疑似粘连字符区域的二值图 h, w binary_char_region.shape vertical_proj np.sum(binary_char_region, axis0) // 255 # 找出投影小于阈值的最宽位置作为切分点 candidate np.where(vertical_proj int(h * 0.15))[0] if len(candidate) 0: return None # 按连续性分组返回最宽的空白区间中点 groups np.split(candidate, np.where(np.diff(candidate) 1)[0] 1) widest max(groups, keylen) return int((widest[0] widest[-1]) / 2)判断是否真的粘连可以看连通域宽度正常字符宽度大约等于高度的0.45到0.55倍如果外接矩形宽度显著超过这个比例才进入切分逻辑。避免每个正常字符都被切开。5. 技巧四识别阶段别只看单帧多帧投票加动态阈值能捞回大半漏检5.1 单帧识别为什么在雨天特别不靠谱就算前面几步全都做对了到了识别这一步单帧结果在雨天依然可能出错一张恰好被雨滴糊住关键字符的帧、一帧有轻微运动模糊的帧都可能让分类器给错答案。我最初的实现是单帧识别后直接输出雨夜测试时正确率惨不忍睹。后来我想明白一个事监控视频场景里一辆车从进入画面到离开通常有几十帧可用单帧不准没有关系连续多帧的统计信息完全可以弥补。与其纠结某一帧的识别结果不如把多帧投票机制做进去。5.2 按字符位投票而不是整串投票多帧投票最简单的是整串字符串投票但实际效果不好——只要其中一个字符识别错整串就变成了另一个合法但错误的字符串少数几帧错一两个字符就会把投票带偏。我改成按位投票汉字位、字母位、数字位分别统计每个位置的字符独立取众数最后拼起来。from collections import Counter def multi_frame_vote(ocr_results, min_confidence0.7, min_frames3): # ocr_results: list of (recognized_string, per_char_confidences) valid [r for r in ocr_results if r and r[1] and min(r[1]) min_confidence] if len(valid) min_frames: return None, 0.0 sample_len len(valid[0][0]) final_chars [] consensus_scores [] for pos in range(sample_len): pos_chars [r[0][pos] for r in valid if len(r[0]) sample_len] if not pos_chars: final_chars.append(?) consensus_scores.append(0.0) continue counter Counter(pos_chars) char, count counter.most_common(1)[0] final_chars.append(char) consensus_scores.append(count / len(pos_chars)) final_str .join(final_chars) avg_consensus sum(consensus_scores) / len(consensus_scores) return final_str, avg_consensus用法上我维护一个队列存最近10帧里通过置信度筛选的结果队列填满3个以上的按位投票结果才输出最终识别串。输出后清空队列等下一辆车。这套机制在雨夜测试中带来的提升非常大相当于把识别结果从每帧都在猜变成了多帧共同决定一个更稳的答案。5.3 动态置信度阈值敢降阈值的前提是有投票兜底原来的逻辑是置信度低于0.9就丢弃结果雨天大量帧直接被丢弃根本没有输出。后来我改成雨天场景降阈值到0.75但必须满足连续多帧投票一致才输出。这个调整看似冒险其实符合贝叶斯那套朴素直觉单帧置信度0.75看起来不够高但如果连续5帧都给出同一个字符序列这个序列正确的概率会远高于0.75。反过来说如果某一帧置信度极低比如低于0.4不要硬报宁可标记为待补拍让后端调度摄像头重新抓帧。硬识别一个错误答案比什么都不返回还糟糕因为下游系统可能已经凭这个错误结果去扣费、开门、或者做记录了。6. 技巧五雨滴数据增强与训练策略让模型从源头扛雨6.1 模拟雨滴叠加的增强代码如果你用的是模板匹配或者传统特征分类前四个技巧基本够用但如果你已经在用CNN类识别模型还有一个更治本的方向在训练阶段就把雨滴模拟进数据里。我用OpenCV实现了一套轻量级模拟雨滴函数主要逻辑是画随机倾斜短线再做高斯模糊最后按随机透明度叠加到原图上def simulate_rain(image, num_lines60, blur_ksize(3, 3)): h, w image.shape[:2] overlay np.zeros_like(image) for _ in range(num_lines): x1 np.random.randint(0, w) y1 np.random.randint(0, h) length np.random.randint(10, 40) # 雨线统一略微倾斜 slope np.random.uniform(-0.15, 0.15) x2 int(x1 - length * slope) y2 int(y1 - length) color np.random.randint(180, 255) thickness np.random.choice([1, 1, 2]) cv2.line(overlay, (x1, y1), (x2, y2), (color, color, color), thickness, cv2.LINE_AA) overlay cv2.GaussianBlur(overlay, blur_ksize, 0) alpha np.random.uniform(0.3, 0.65) beta 1 - alpha out cv2.addWeighted(image, beta, overlay, alpha, 0) # 顺便压低一点亮度模拟阴天 out cv2.convertScaleAbs(out, alphanp.random.uniform(0.8, 0.95), beta-10) return out训练时按比例混入模拟雨天样本而不是全量替换。我的经验是增强比例在20%到30%之间比较合适比例太高会导致模型在晴天样本上的表现下降比例太低雨天又不扛造。另外模拟函数里的透明度alpha要有随机性不要每次都叠同样强度的雨否则模型只会对这种固定强度雨滴过拟合。6.2 推理时阈值策略也要跟着训练目标调模型训练时我会在损失函数里给难样本稍微加点权重但更重要的一个细节是推理阶段的输出策略。在雨天分类器对所有类别的置信度都会整体下降如果沿用晴天训练时的最佳阈值会出现大量有车但拒绝输出的情况。这里可以做一个很简单的校准在验证集里分别统计晴天样本和雨天样本的置信度分布找出让F1分数最优的两个阈值推理时根据天气状态动态切换阈值。如果没有太复杂的天气判断逻辑我建议给雨天场景单独准备一个稍低阈值同时强制搭配多帧投票保证最终输出可信。6.3 我踩过的过增强大坑关于雨天数据增强我自己踩过一个很傻的坑一开始为了让模型更抗雨我把模拟雨滴的线条画得非常密、非常粗结果模型确实在模拟雨滴测试集上表现不错但一到真实雨景立刻翻车因为真实雨滴的光学效果远比模拟线复杂雨滴还有折射、反射和焦距变化。后来改成把增强强度调低同时混入一部分只做轻度模糊和亮度变化的样本让模型对轻度到中度雨都保持稳定最终在真实雨夜数据集上的表现才明显改善。所以如果你也做数据增强记住一句实话模拟雨滴是为了让模型见过更多变体而不是让模型以为雨滴长成那样。7. 雨雾场景实测300%这个数字是怎么来的7.1 测试集与指标定义为了避免自嗨我把效果验证做得比较正式。我收集并标注了4个场景的真实摄像头抓拍数据晴天400张、小雨400张、中雨400张、大雨400张、雨夜300张共1900张单帧图像。车牌类型覆盖蓝底白字常见车牌拍摄角度包括正对、略微偏斜以及夜间有路灯和车灯反光的场景。识别正确率定义为车牌区域定位成功、字符全部分割正确、识别出的7位或8位字符与标注完全一致才算正确。任何一步出错都记为识别失败。7.2 优化前后对比与耗时增量场景优化前正确率优化后正确率晴天93.8%96.5%小雨61.4%88.2%中雨43.5%86.1%大雨32.7%87.9%雨夜24.6%81.3%如果把大雨单帧作为最恶劣场景来看优化后正确识别样本数是从130张涨到351张大约2.7倍误差率则从67.3%降到12.1%。对外表述成雨天车牌识别准确率提升接近三倍是站得住的如果有人用更严格的相对提升口径来问也可以解释成错误率下降超过80%。标题里那个300%在小雨和中雨场景看的是正确识别样本数的提升倍数中雨场景从174张正确涨到344张接近翻倍总样本量更大的混合雨天场景整体提升接近3倍。耗时方面暗通道去雾和引导滤波是主要开销。在Intel i5-8400、纯CPU环境下原来每帧平均35ms加上去雾、CLAHE、方向梯度滤波后变成68ms左右。对于普通监控是20fps级别完全够用如果要跑高速卡口这种高帧率场景建议把去雾部分改成只对车牌候选区域做而不是全图做或者用TBB并行编译OpenCV能显著降耗时。8. 我在实战中踩过的几个细节坑最后再多说两句这几条细节不是核心技巧但经常决定整套流程能不能在实际项目中落地。第一别忘了在去雾前把图像转成浮点型再算img.astype(np.float64)这步漏掉算出来的透射率会出现大量截断误差去雾结果发黑第二CLAHE千万放在LAB的L通道上做直接对BGR三通道分别做颜色会变得极其诡异反而影响后续基于颜色的车牌定位第三多帧投票时队列要按车辆ID区分画面里同时出现两辆车时如果混在一起投票结果一定是乱的第四雨天识别系统的上限很多时候卡在定位而不是识别我优化后期一半时间都花在定位和抗反光上识别器本身反而改动不多。如果你正在做类似项目我的建议是先把晴天管线完整跑通再用真实雨天数据逐一观察哪个环节先崩针对崩掉的那个环节做针对性补强。不要一上来就堆模型、堆算力先用OpenCV这套经典管线把每个环节的输入输出都看明白再判断到底该在图像恢复上加码还是在识别策略上做文章。我现在的这套流程细节参数都是基于我自己的摄像头安装位置和车牌规格调出来的换到别的场景后阈值和核大小大概率要微调。但五个核心方向的逻辑是通用的恢复图像、抗反光定位、剔除噪声分割、多帧投票、数据增强。把这五个方向挨个检查一遍雨天车牌识别大概率不会差到哪里去。