十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python+OpenCV实战:雨天车牌识别准确率从23%提升到82%的5个关键技巧

Python+OpenCV实战:雨天车牌识别准确率从23%提升到82%的5个关键技巧 半年前我在做一个停车场车牌识别项目时暴雨天识别率直接掉到两成出头管理员最后不得不在门口拿伞守着道闸等车主摇下车窗报车牌号。后来我把整个流程拆开重做了一遍用Python和OpenCV把雨天车牌识别的准确率从23%拉到了82%误差率降到了原来的五分之一以下这篇文章就是把当时踩过的坑和最终留下的5条关键技巧完整记录下来。这个场景适合谁如果你正在做门禁道闸、停车场管理、园区安防这类摄像头识别项目又刚好被雨雾天、夜间反光、车窗水珠折磨过那么这篇文章里的每一条都能直接抄作业。就算你只学过一点Python基础还没系统用过OpenCV只要能装好环境、跑通几个demo也能一步步把它搭出来。我尽量把每个参数背后的原因说清楚而不是丢给你一堆“照着调就行”的黑盒参数。1. 为什么雨天识别率会断崖式下跌1.1 雨天图像退化的三个核心因素先说原理层面为什么偏偏是雨天最容易翻车。晴天的时候摄像头拍到的车牌区域通常对比度清晰、边缘锐利字符和底色分得很开而雨天画面里同时叠加了三类干扰第一是大气散射导致对比度下降。雨滴和悬浮水雾会让光线发生散射远处物体像蒙了一层纱车牌边缘变得模糊字符和背景的灰度差被压缩。这在视觉上的表现就是灰蒙蒙一片连人眼都很难一眼看清。第二是雨线和水珠遮挡。雨滴落在镜头前方或者车牌表面会在图像上形成短线状噪声或者高光小圆点这些区域很容易被误判成字符的一部分或者恰好压在关键字符上导致分割失败。第三是反光和非均匀光照。路面积水、车窗玻璃、车牌表面的水膜都会产生高光反射尤其到了晚上对面车灯一照HSV颜色空间里蓝色的车牌底色可能直接变成白色或者灰色颜色定位阶段就翻车了。1.2 完整系统流程设计很多人一上来就想着用深度学习模型硬扛目标检测网络负责定位车牌OCR网络负责识别字符。这个思路本身没问题但在真实雨天场景下如果直接把原始图像塞给模型数据分布和训练集差异太大再强的网络也会掉点。我当时对照试验过同样一个识别模型晴天准确率93%雨天直接掉到23%差距就是这么夸张。所以最终落地的方案是“强化图像预处理 传统图像处理定位分割 OCR识别 规则校验”的组合拳完整流程是输入视频帧 → 图像去雾与对比度增强 → HSV颜色空间定位车牌 → 形态学处理筛选候选区域 → 透视校正 → 自适应阈值二值化 → 字符分割 → OCR识别 → 车牌规则校验 → 多帧投票输出这一套流程下来单帧处理耗时大约80到120毫秒在普通CPU上就能跑实时监控。而且前面几步全是OpenCV的传统图像处理瓶颈很小后面识别阶段才调用模型。这个设计思路最大的好处是哪怕中间某一步失效了你也能通过保存的中间结果快速定位问题不像端到端深度学习那样完全是个黑盒。这也是我坚持用OpenCV做主线的原因。2. 技巧一先做图像增强再谈识别2.1 暗通道去雾的参数与核心代码雨天图像“灰蒙蒙”的本质可以近似看成雾天退化模型观测图像 场景辐射 × 透射率 大气光 × (1 - 透射率)。暗通道先验是目前去雾效果最稳定、最容易用OpenCV实现的方法。核心思路很简单对一张无雾图像的每个局部区域总存在某个像素在某个颜色通道上的值非常低。而雨天因为有大气光叠加这个暗通道的最小值会被抬高。利用这个差异可以估计出透射率和大气光然后反推回去得到清晰图像。我实际用到的实现是这样的import cv2 import numpy as np def dark_channel(img, size15): b, g, r cv2.split(img) dc cv2.min(cv2.min(b, g), r) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (size, size)) return cv2.erode(dc, kernel) def estimate_atmospheric_light(img, dc): h, w img.shape[:2] num_pixels h * w flat dc.flatten() num_bright max(int(num_pixels * 0.001), 1) indices np.argsort(flat)[-num_bright:] atmospheric np.max(img.reshape(-1, 3)[indices], axis0) return atmospheric def dehaze(img, omega0.95, t_min0.1): img img.astype(np.float32) / 255.0 dc dark_channel(img) A estimate_atmospheric_light(img, dc) t 1 - omega * dark_channel(img / (A 1e-6), size15) t np.clip(t, t_min, 1.0) result np.zeros_like(img) for i in range(3): result[:, :, i] (img[:, :, i] - A[i]) / t A[i] result np.clip(result, 0, 1) * 255 return result.astype(np.uint8)注意几点。omega取0.95是经验值保留一部分薄雾效果防止去雾后颜色失真t_min设为0.1防止透射率过低导致噪声被过度放大。如果觉得边缘有光晕可以再接一个引导滤波OpenCV的cv2.ximgproc.guidedFilter可以用但注意这个函数在opencv-contrib-python包里只装opencv-python的话会报错。不想引入contrib包的话用cv2.blur做一次软抠图也能接受。2.2 CLAHE局部对比度增强暗通道去雾解决的问题是“蒙了一层纱”但雨天车牌还经常面临局部阴影、水渍反光造成的亮度不均——车牌左边亮右边暗或者字符区域暗到看不清。这时如果直接做全局直方图均衡化很容易过曝亮部细节全丢。正确做法是CLAHE限制对比度自适应直方图均衡化它对图像分块处理每一块单独做直方图均衡化同时用clipLimit限制对比度的放大倍数避免噪声被同步放大。我用的是clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) img_yuv cv2.cvtColor(dehazed, cv2.COLOR_BGR2YUV) img_yuv[:, :, 0] clahe.apply(img_yuv[:, :, 0]) enhanced cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR)只在Y通道亮度上做增强U和V通道保持原样这样不会产生偏色。clipLimit别调太大2.0到3.0之间比较合适太大会让车牌底色的颗粒感变重反而影响后续字符分割。2.3 增强算子的权重拼接去雾加CLAHE之后车牌区域通常已经很清楚了但我发现把增强图像和原图做一次加权融合效果比直接用增强图更好。因为去雾算法偶尔会把画面压得过暗或者对比度增强后出现不自然的高光融合可以做一个平衡result cv2.addWeighted(enhanced, 0.6, dehazed, 0.4, 0)这里的权重我试过从0.3到0.9最终选0.6是因为0.6能保留大部分增强效果同时不会让原图的噪声结构被过度放大。如果你用的是一个亮度普遍偏暗的摄像头可以把enhanced的权重提到0.7。这一步看起来不起眼但我实测它对后续定位阶段的稳定性贡献很大。3. 技巧二HSV空间定位车牌比RGB更抗雨天干扰3.1 HSV阈值设计车牌定位是整个流程里最容易卡住的一步尤其在下雨天。我最早用RGB颜色范围去筛选蓝色像素结果只要画面上出现一点水渍反光蓝色通道就被冲淡定位直接失败。换成HSV空间之后问题解决了大部分。HSV模型里H代表色相S代表饱和度V代表明度。雨天反光主要改变V值对H的影响较小所以用H做主判断S和V辅助稳定性高很多。国内常见车牌的颜色分布大致是蓝牌H通道85到125S大于90V大于80新能源绿牌H通道35到85S大于90V大于80黄牌H通道15到34S大于90V大于80实际代码hsv cv2.cvtColor(enhanced, cv2.COLOR_BGR2HSV) blue_mask cv2.inRange(hsv, (85, 90, 80), (125, 255, 255)) green_mask cv2.inRange(hsv, (35, 90, 80), (85, 255, 255)) yellow_mask cv2.inRange(hsv, (15, 90, 80), (34, 255, 255)) mask cv2.bitwise_or(blue_mask, green_mask) mask cv2.bitwise_or(mask, yellow_mask)这里有一个容易踩的坑HSV色相范围在OpenCV里是0到180不是通常理解的0到360。所以蓝色不要写成(120, ...)而是(85, 125)。黄色要避开红色区域因为红色在H通道偏向0到10和170到180很容易和黄色挨着。如果你的项目里有新能源车牌记得把绿色区间覆盖进去我们项目上线之前完全没有考虑新能源车结果被绿牌杀了个措手不及。3.2 形态学处理与候选框筛选得到掩膜之后掩膜上通常会有一堆离散的小块尤其是雨滴造成的噪声点。直接用findContours去提轮廓会得到大量垃圾候选框必须先做形态学处理。我的处理顺序是先用大核闭运算把车牌内部因为字符遮挡导致的空洞填满再用小核开运算滤掉孤立的雨滴噪声kernel_close cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close) kernel_open cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open)闭运算核为什么是(17, 5)而不是正方形因为车牌是横长条结构水平方向需要更大的连接范围垂直方向太大会把上下两个不同区域粘连在一起。核的尺寸不是随便定的它应该略大于你期望填充的最大空洞宽度又要远小于非车牌区域的间距。轮廓筛选时我一共用了四个条件contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) area cv2.contourArea(cnt) if area 1000 or area 20000: continue if w / h 2.8 or w / h 4.0: continue fill_ratio area / (w * h) if fill_ratio 0.5: continue candidates.append((x, y, w, h))这个宽高比区间是怎么订的标准蓝牌长宽约440×140比例3.14新能源车牌更长一些约480×140比例3.4左右。考虑到摄像头斜拍会压缩宽高比我把范围放宽到2.8到4.0。填充率大于0.5这个条件是用来排除那些虽然是长条形但内部空洞过多的区域比如远处的栏杆或车身反光条。3.3 车牌角度的快速校正雨天摄像头通常被固定在一个位置但车辆在道闸前停的位置千奇百怪车牌存在一定倾斜角度。如果直接分割字符后续OCR可能按错顺序读。我用minAreaRect获取最小外接矩形拿到旋转角度然后做透视校正。rect cv2.minAreaRect(cnt) angle rect[-1] if angle 45: angle angle - 90 if abs(angle) 0.5: M cv2.getRotationMatrix2D(center, angle, 1.0) rotated cv2.warpAffine(crop, M, (crop_w, crop_h), flagscv2.INTER_CUBIC, borderModecv2.BORDER_REPLICATE)角度处理有个细节当车牌向左倾斜时返回的角度可能是负的向右倾斜时可能是接近90的正数。所以超过45度时要减90把角度统一映射到-45到45之间。BORDER_REPLICATE是复制边缘像素填充比默认的黑色填充更适合后续做颜色分析和二值化因为黑色边框会产生干扰。4. 技巧三字符分割的关键不在算法而在二值化4.1 自适应阈值与去噪组合字符分割就是把“琼A·D3K72”这些字符一个个切出来再送给OCR识别。很多人以为分割的关键是投影算法怎么写但我在雨天项目里踩过最大的坑其实是二值化做得太烂。用固定阈值或者大津法OTSU处理雨天车牌经常出现这种情况车牌左侧因为阴影变黑字符和底色黏在一起右侧被车灯照得发白字符又消失了。OTSU是全局阈值对光照不均无能为力。解决方法是使用自适应阈值gray cv2.cvtColor(plate_crop, cv2.COLOR_BGR2GRAY) gray cv2.medianBlur(gray, 3) binary cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 79, 10)blockSize79表示每个局部区域大小是79×79这个值必须和车牌在图像中的尺寸匹配。车牌字符笔画宽度如果占10到15个像素blockSize至少得是笔画宽度的5倍以上太小的blockSize会把笔画内部当成背景。参数C10是在局部均值基础上减去的常数用来抑制噪声。C太小亮点噪声会被保留C太大细笔画会断掉。我在字符分割前有必要先做一次medianBlur用的是3×3核它的作用就是滤掉“盐椒噪声”——也就是雨滴溅射在车牌上形成的小白点。这里千万别用高斯模糊高斯模糊会把笔画边缘变钝中值滤波在去除点状噪声时几乎不影响边缘锐度。4.2 垂直投影 连通域修复断字自适应阈值之后我对二值图做垂直投影统计每一列的黑像素数量。字符和字符之间会出现明显的谷底依据谷底就可以切分字符。只要二值化稳定投影法就足够干净不需要引入复杂的连通域分析。projection np.sum(binary, axis0) in_char False start 0 char_cols [] for col, val in enumerate(projection): if val 10 and not in_char: in_char True start col elif val 10 and in_char: in_char False if col - start 10: char_cols.append((start, col))这段代码把连续的“有内容”列提取为候选字符区间。这里两个阈值需要微调val 10是“有内容”的最小像素数具体取决于二值图分辨率col - start 10是字符的最小宽度防止单个噪点列被当成字符。但雨天场景下这个流程常遇到两个意外一是字符笔画本身因为反光断开同一个“京”字中间被投影切成了两段二是雨滴噪声恰好落在字符间让两个字符被黏在一起。第二种情况还好处理用形态学开运算把噪声连桥断开第一种情况我用连通域分析做兜底——如果某个候选区间的宽度只有平均字符宽度的1/2左右并且它的上下边界和相邻字符有重合就把它和相邻区域合并。这个逻辑听起来不复杂但写出来要细心我建议你把投影后的区间先打印出来看一眼再决定合不合并。很多情况下断字之后的那一段和真正字符的宽度比例很明显自动判断规则不需要做得很复杂。4.3 字符宽度一致性的快速校验分割完之后用一致性校验来淘汰错误分割非常有效。正常车牌字符宽度大体接近字符间距也比较均匀可以据此设置一个简单却好用的过滤char_widths [end - start for start, end in char_cols] if len(char_widths) 7: return None median_w np.median(char_widths) valid_chars [] for start, end, w in zip(...): if 0.5 * median_w w 1.5 * median_w: valid_chars.append((start, end))为什么用中位数而不是平均值因为少数几个特别宽字符粘连或特别窄断字的区间会拉偏平均值而中位数对离群值更鲁棒。这一步做完之后我还会检查字符高度正常字符的高度应该占车牌高度的70%以上。如果某个分割块高度特别小多半是误检直接丢弃。5. 技巧四选对开源模型再用规则卡住结果5.1 开源模型选型对比字符分割完成之后识别可以走两条路。如果你不想引入过多依赖用模板匹配也能跑使用场景有限、泛化能力太差。我的建议是直接上开源车牌识别模型目前社区用得多的有HyperLPR、PaddleOCR以及YOLO加CRNN的自训方案我实际体验如下方案优势不足适合场景HyperLPR专为车牌设计中文支持好、轻量对极端倾斜和模糊图片需要额外处理常规道闸、停车场快速上线PaddleOCR识别能力强可复用通用OCR能力环境依赖较重识别中文字符需微调对识别率要求极高GPU资源充足YOLOv8检测 CRNN识别完全可定制可针对雨雾微调需要准备大量标注数据训练周期长有算法团队追求极致场景泛化我这个项目最终用的是HyperLPR做识别层。开箱即用不需要额外标注部署成本低而且它的中文车牌字符集覆盖率比通用OCR好。它的输出包括识别字符串和置信度我可以在规则校验阶段直接接着用。5.2 识别结果的规则校验与置信度重排模型输出的字符串不能直接当成最终结果因为真实车牌有严格的格式约束。利用这些规则把不合理的结果过滤掉是准确率能再上一个台阶的关键。国内民用车牌的基本规则是第一位为省份汉字第二位为发牌机关字母后半段由数字和除I、O之外的字母组成。普通蓝牌共7个字符新能源车共8个字符。我写了一个校验函数import re PROVINCES 京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 def is_valid_plate(plate): if len(plate) not in (7, 8): return False if plate[0] not in PROVINCES: return False if not plate[1].isalpha(): return False tail plate[2:] if re.search(r[IO], tail): return False return True这里最容易漏掉的是字母I和O普通车牌里不会出现这两个字母很多模型会把1误识别成I、0误识别成O。规则校验可以直接把它们修正为数字1和0也可以在模型输出多个候选时优先选择不含I/O的那一个。我建议两者都做先替换再校验长度。置信度重排是个好用的技巧。让模型对每个分割出的字符输出前3个候选字符及置信度然后遍历所有组合用is_valid_plate筛选合法组合最后取置信度乘积最高的一条。这个方法能让最终准确率提升不少尤其在字符被雨滴部分遮挡时很有用。6. 技巧五用数据增强和多帧投票把准确率再顶上去6.1 用OpenCV造出更接近真实雨天的训练数据如果你有训练自己模型的需求或者继续在现有模型基础上微调数据增强是绕不开的。用OpenCV模拟雨天成本低、可控性强核心是模拟雨丝、雾化、亮度下降和噪声def simulate_rain(image): img image.copy() h, w img.shape[:2] # 随机雨丝 for _ in range(np.random.randint(5, 15)): x1 np.random.randint(0, w) y1 np.random.randint(0, h) length np.random.randint(15, 60) angle np.random.uniform(0.3, 0.5) x2 int(x1 - length * np.cos(angle)) y2 int(y1 length * np.sin(angle)) cv2.line(img, (x1, y1), (x2, y2), (180, 180, 180), 1) # 亮度下降 img cv2.addWeighted(img, 0.8, np.zeros_like(img), 0.2, 0) # 高斯模糊模拟水雾 img cv2.GaussianBlur(img, (3, 3), 1.2) return img连续生成几十张模拟雨天图像后把它们和原始样本混在一起用模型对雨天的鲁棒性能提升不少。但有一个原则要记住合成数据是补充不能替代真实雨天采集的数据。我最后把项目里的真实雨天图片单独抽出来放到验证集里防止模型只对合成雨天有效、一到真实场景就露馅。6.2 视频流里的多帧投票机制如果你做的是监控视频流而不是静态图片多帧投票是最划算的提分方法。单帧可能因为瞬时水珠遮挡、车辆震动导致误识别但连续5帧里至少3帧识别出同一结果这个结果大概率是对的。具体实现from collections import Counter result_queue [] def vote_result(plate, score, max_queue7): if plate: result_queue.append((plate, score)) if len(result_queue) max_queue: result_queue.pop(0) if len(result_queue) 3: return None counter Counter([p for p, s in result_queue]) top_plate, top_count counter.most_common(1)[0] if top_count 3: top_score np.mean([s for p, s in result_queue if p top_plate]) if top_score 0.6: return top_plate return None这个方案有一个问题需要注意排队里的旧结果会拖慢新结果的输出。比如车辆已经开走了队列里的旧车牌还会再“生效”一下。所以max_queue不要设太大7帧足够一旦检测到当前帧完全没有车牌候选可以直接清空队列。输出频率也要控制每3帧处理一下识别程序就行不需要每一帧都跑完整流程CPU占用可以降低约一半识别效果几乎不受影响。7. 调试建议和常见问题排查7.1 中间结果可视化比任何log都管用整个流程分这么多步最怕的是你不知道哪一步出了问题。我的习惯是在代码里预留一个调试开关把每一步的中间结果都用cv2.imwrite保存下来debug_dir debug cv2.imwrite(f{debug_dir}/01_dehazed.jpg, dehazed) cv2.imwrite(f{debug_dir}/02_enhanced.jpg, enhanced) cv2.imwrite(f{debug_dir}/03_mask.jpg, mask) cv2.imwrite(f{debug_dir}/04_plate_crop.jpg, plate_crop) cv2.imwrite(f{debug_dir}/05_binary.jpg, binary) cv2.imwrite(f{debug_dir}/06_segmented.jpg, segmented_vis)拿到一张出错的雨天人像图从01看到06很快就能定位到是哪一步阈值不匹配。比如01里车牌很清晰但04车牌区域根本没被裁出来那就是HSV定位的阈值问题如果04裁剪正确但05二值化后字符断成一截一截的那就是自适应阈值的blockSize或C参数需要调。7.2 常见问题速查表我在调参过程中整理了一张速查表基本覆盖了雨天场景下最常见的拦截点。现象可能原因处理方式定位阶段框出多个矩形但没一个是车牌HSV阈值范围过窄雨天颜色饱和度偏低把S下限从90降到70V下限从80降到70车牌定位到了但裁剪图角度严重倾斜minAreaRect角度判断不正确角度大于45度时先减90再校正二值图字符大面积断裂中值滤波核过大或自适应阈值blockSize太小blockSize调到车牌宽度的1/5左右C值降到8二值图字符粘连严重自适应阈值C值过大或车辆反光严重降低C值并在垂直投影前先做开运算OCR把0识别成O或把1识别成I规则校验缺失启用字母I/O排除和替换规则单帧识别对但结果频繁跳动没有多帧投票单帧雨滴误判加入至少3帧一致投票机制去雾后颜色失真严重omega取值过大或引导滤波缺失omega降到0.85到0.9或加引导滤波平滑透射率7.3 版本与安装注意OpenCV的安装有几个很容易踩的版本坑。pip install opencv-python装的是基础版不包含ximgproc、bgsegm这些扩展模块如果你需要用到引导滤波、SIFT、卡尺工具等必须装opencv-contrib-python。这两个包不要同时装同时装会导致运行时动态库冲突甚至import cv2后调用函数直接段错误。一个方便检查版本的方式python -c import cv2; print(cv2.__version__; print(ximgproc in dir(cv2))如果你的Python版本是3.10及以上注意别下载旧版OpenCV部分旧版本没有对应的预编译wheel会当场编译失败。遇到这种情况直接升级到4.8以上版本基本能解决。另外识别模型HyperLPR建议用源码安装而不是pip一装了之它依赖的很多动态链接库和Python版本关系比较紧用源码安装更容易控制依赖链。写在最后这个项目做到后面我最大的体会是真正决定雨天车牌识别上限的不是模型本身多花哨而是图像处理链条上有没有一个环节掉链子。前几步的阈值、滤波核、形态学参数手一抖差别就很大。我建议你把每个步骤的中间结果都存下来肉眼过一遍再谈调参。如果非让我只留一条推荐顺序先打通HSV定位和字符分割再去做OCR最后回头优化增强。另外一个小的实战建议给识别程序设置一个“低置信度放行并记录”模式也就是置信度达标就输出不达标的就记录下来交给人工复核别硬着头皮输出一个大概率错误的结果。这个机制让停车场管理员事后查漏变得轻松很多也让我后期拿到了大量真实误判样本用来继续调参。实测这套方案在雨天下能一直跑不会三天两头崩验证集上从最初的23%到82%整个过程的耐心和收益是成正比的。
返回列表