
简介面向Python与计算机视觉初学者及智能交通应用开发者的车牌识别实战包围绕灰度化、二值化、滤波去噪、边缘检测、轮廓查找与形态学定位、字符分割及模板匹配识别展开可用于课程设计、项目练手与算法流程复现。压缩包共2000个文件约12.37MB包含1999张jpg图像样本以及1个py源码文件便于对照图像数据理解预处理、定位和字符匹配各环节。已有3109人学习下载。读者可据此搭建从车牌区域提取到字符识别的完整流程结合字符模板与样本图调试匹配效果并梳理智能交通、停车场管理等场景的实现思路与排错路径。1. 从一张夜间抓拍图说起这套 PythonOpenCV 车牌识别能做到什么程度上个月帮朋友看一个园区入口的小需求手里只有一张夜里的抓拍图车牌在画面右下角斜着、还带反光。他问能不能不上深度学习那套重家伙用纯 PythonOpenCV 把车牌抠出来、把字符认出来。这套带源码、数据和字符匹配模板的车牌识别实战项目基本就是这个思路不训练模型、不装推理框架靠 HSV 颜色分割定位车牌形态学去噪垂直投影切字符最后拿模板逐位比对出结果。它适合两类人一类是想搞明白传统图像处理流水线长什么样的新手照着跑一遍比看十篇原理文章有用另一类是手上有个离线小场景不想为了认一块车牌去搭一整套推理服务的开发者。边界也说清楚——光照规矩、角度不大的图效果好复杂场景该上模型还是得上模型。2. 项目结构与运行环境拿到源码先别急着敲 main.py这套代码的组织方式是典型的「按流水线阶段分目录」不是按功能分层。定位、分割、识别三段各占一个包模板和数据单独放主文件只负责串流程。理解这个划分后面改任何一个环节都知道该动哪个文件不用满仓库搜函数名。2.1 目录结构与模块职责常见做法是下面这种结构源码包里基本能一一对应上路径职责改动频率main.py入口串起定位→校正→分割→识别低config.py颜色阈值、尺寸、路径等参数集中放高locate/color_seg.pyHSV 颜色分割 形态学 轮廓筛选高locate/rectify.py四点透视变换、灰度与二值化中segment/projection.py垂直投影切字符高recognize/matcher.py模板加载与逐位匹配中recognize/templates/省份汉字 数字字母模板图新增为主data/images/测试图集只加不改config.py单独拎出来这件事很关键。颜色阈值、形态学核尺寸、轮廓宽高比范围这些参数是整条流水线里最容易反复调的部分散落在各个函数里会让人崩溃。集中之后换一批图只需要改一个文件跑一轮看结果再改循环很快。我一般还会在 config 里给每个参数写一行注释说明它调大调小会带来什么效果隔两周回来还能看懂。recognize/templates/是这套方案的灵魂。目录下通常再分两层province/放省份简称汉字alnum/放 0-9 和 A-Z去掉 I、O 这类容易与数字混淆的字母。文件名就是标签本身比如京.png、A.png、7.png加载时用文件名当 key省掉一张映射表。模板图建议统一成同样的尺寸后面匹配时就不用再做对齐。2.2 环境依赖与最小可运行验证依赖很少这是这套方案的一大优势。Python 3.8 以上装两个包就够# 建议先建虚拟环境避免和系统里的 opencv 冲突 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install opencv-python numpy # 如果源码里用了 PIL 读写中文路径再加一条 pip install Pillow装完先别急着跑main.py做一次最小验证确认三件事OpenCV 能导入、能读图、模板目录能被读到。这一步能挡掉一半的「跑不起来」问题。# check_env.py —— 环境自检跑通再往下走 import os import cv2 import numpy as np print(opencv:, cv2.__version__) # 低于 4.x 的部分 API 行为有差异 print(numpy:, np.__version__) # 用 imread 读遇到中文路径要改用 np.fromfile imdecode img cv2.imread(data/images/test_01.jpg) print(image shape:, None if img is None else img.shape) # shape 拿到 (h, w, 3) 说明通道正常拿到 (h, w) 说明图是灰度图或读失败 tpl_dir recognize/templates/alnum if os.path.isdir(tpl_dir): files [f for f in os.listdir(tpl_dir) if f.endswith(.png)] print(template count:, len(files)) else: print(模板目录不存在检查路径或压缩包是否解压完整)这段代码的输出要逐条看。image shape打印None八成是路径写错或者图根本不存在别怀疑算法。模板数量如果是 0说明目录结构和你以为的不一样可能是压缩包里多套了一层文件夹。这类问题在群里问「为什么报错」之前自己打印一下就能定位。还有一个隐蔽的坑Windows 上cv2.imread读中文路径会静默返回None不抛异常。项目里如果测试图放在中文命名的文件夹下就会出现「明明文件在那儿却读不到」的玄学现象。稳妥写法是def imread_unicode(path): 支持中文路径的读图失败返回 None 而不是抛异常 try: data np.fromfile(path, dtypenp.uint8) # 先按字节读进来 img cv2.imdecode(data, cv2.IMREAD_COLOR) # 再解码成 BGR 图 return img except Exception: return Nonenp.fromfile绕开了 OpenCV 内部的文件名编码处理imdecode负责解码。这个函数建议直接放进utils/io_utils.py全项目统一调用省得每个模块各写一遍。2.3 一条完整数据流从读图到输出车牌字符串主流程本身很短短到很多人第一次看会觉得「就这」。但正是因为它短每个阶段的输入输出边界才清晰出问题能快速定位是哪一段的锅。# main.py 主流程骨架 import cv2 from config import CFG from locate.color_seg import locate_plate from locate.rectify import rectify_plate, to_binary from segment.projection import split_chars from recognize.matcher import TemplateMatcher def recognize_one(img_path): img cv2.imread(img_path) if img is None: return None, 读图失败 # 大图先缩一缩1080p 原图直接跑形态学会非常慢 img cv2.resize(img, None, fxCFG.SCALE, fyCFG.SCALE) plate locate_plate(img, CFG) # 返回车牌区域的四点坐标或 None if plate is None: return None, 未定位到车牌 warped rectify_plate(img, plate) # 透视校正成水平矩形 binary to_binary(warped) # 灰度 二值化 chars split_chars(binary, CFG) # 切成 7 个字符小图 if len(chars) ! 7: return None, f字符数异常: {len(chars)} matcher TemplateMatcher(CFG.TPL_DIR) return .join(matcher.match(c) for c in chars), ok几个参数值得说清楚。CFG.SCALE是全局缩放系数常取 0.5 到 1.0大图缩一半形态学运算量直接降到四分之一。locate_plate返回的是原图坐标系下的四个角点顺序要固定比如左上、右上、右下、左下否则后面透视变换会扭转 90 度。split_chars返回的字符数必须正好是 7普通蓝牌或 8新能源绿牌数量不对就直接判失败别硬凑硬凑出来的结果比报错更坑人。返回值的第二个位置是状态字符串这个设计看着粗糙但很实用。批量跑测试集的时候把状态统计一下就能知道瓶颈在定位还是分割——如果八成失败都是「未定位到车牌」那就别去调模板匹配了方向错了。3. 车牌定位与预处理颜色分割 形态学怎么把车牌框出来定位是整条流水线最脆弱的一环也是最值得花时间调的一环。定位歪了后面分割和匹配再怎么优化都是白搭。这套源码走的是颜色特征路线思路是「先按颜色把候选区域圈出来再用几何形状筛掉不像车牌的」。3.1 先按颜色筛一遍HSV 阈值与蓝牌/绿牌差异RGB 三个通道对光照的敏感度不一致直接卡 RGB 阈值换个天气就失效。HSV 把色相、饱和度、明度拆开色相受光照强度影响小得多所以颜色分割基本都在 HSV 空间做。# locate/color_seg.py 颜色分割部分 import cv2 import numpy as np def color_mask(img_bgr, hsv_range): 按 HSV 范围生成候选掩膜 hsv cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) lower np.array(hsv_range[0], dtypenp.uint8) upper np.array(hsv_range[1], dtypenp.uint8) return cv2.inRange(hsv, lower, upper) # 落在范围内置 255否则 0 # 常见阈值注意 H 通道在 OpenCV 里是 0-180 而不是 0-360 BLUE_RANGE ((100, 43, 46), (124, 255, 255)) # 普通蓝牌 GREEN_RANGE ((35, 43, 46), (77, 255, 255)) # 新能源绿牌 YELLOW_RANGE ((11, 43, 46), (34, 255, 255)) # 黄牌大车这三个范围是最常见的起点不是标准答案。S下限 43、V下限 46 是为了滤掉灰白色区域但阴天画面整体偏暗时V下限压到 30 左右能多召回一些反过来画面里大片蓝色广告牌时S下限可以提到 80 以上把低饱和的蓝色挡掉。蓝牌和绿牌的色相区间差了将近 60 度别指望一个范围通吃。工程上的做法是分别生成掩膜再合并或者先判断图里主色调走哪条路。新能源车牌识别现在几乎是刚需只支持蓝牌的代码跑真实数据会大面积失败这一点在拿到源码后第一件事就该确认。还有一个容易被忽略的点蓝色范围的下界是 100 不是 90。H 在 90 附近偏青色天空、水面大量落在这一带。把下界卡到 100能明显减少误检代价是偏紫的车牌可能漏掉——但比框到天空强。3.2 形态学闭运算与轮廓筛选宽高比、面积、矩形度颜色掩膜出来是一堆碎点车牌区域可能是断开的。闭运算先膨胀后腐蚀能把临近的碎片连成块同时基本不改变整体面积。def locate_plate(img_bgr, cfg): mask color_mask(img_bgr, BLUE_RANGE) mask cv2.bitwise_or(mask, color_mask(img_bgr, GREEN_RANGE)) # 横向核远大于纵向核车牌是扁的横向连得更开 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 3)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) # 再开一次去掉零散小点 mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations1) cnts, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for c in cnts: x, y, w, h cv2.boundingRect(c) if h 10: continue ratio w / float(h) area cv2.contourArea(c) rect_area w * h if rect_area 0: continue extent area / rect_area # 矩形度越接近 1 越像规整矩形 # 宽高比 2.5-4.5、矩形度 0.5两个条件同时满足才留下 if cfg.MIN_RATIO ratio cfg.MAX_RATIO and extent cfg.MIN_EXTENT: candidates.append((x, y, w, h, area)) if not candidates: return None # 面积最大的那个通常是车牌但注意别把广告牌选进来 candidates.sort(keylambda t: t[4], reverseTrue) return candidates[0][:4]kernel用(17, 3)这组数字是有道理的车牌的长宽比大约 3:1横向核大才能把左右断开的字符连成一片纵向核小是为了不把上下相邻的保险杠、车灯一起粘进来。改成(25, 25)会得到一整块车身改小到(5, 3)又会碎成几段我试过好几轮这个比例附近最稳。轮廓筛选的两个条件各有分工。宽高比是粗筛国内单层车牌比例在 3.14 左右取 2.5 到 4.5 留够余量矩形度是细筛车牌是规整矩形extent通常在 0.8 以上而树枝、文字这类干扰轮廓往往很低。两个条件一起用比单用一个稳不少。排序取面积最大有个前提画面里只有一辆车。多车场景下这个策略一定是错的得改成候选列表全部返回交给后面的字符分割做二次验证——切不出 7 个合理字符的候选直接丢掉。这一步筛选的准确率往往比把定位参数调到极致更有效。3.3 透视校正与二值化把斜牌掰正再动手切字符定位拿到的框通常不是水平矩形而是带旋转的四边形。直接用boundingRect裁会把车牌之外的东西也裁进来字符还带着倾斜。透视变换能把四个角点映射成标准矩形。# locate/rectify.py import numpy as np import cv2 def order_points(pts): 把四个点按 左上、右上、右下、左下 排好顺序错了变换结果会翻转 pts np.array(pts, dtypenp.float32).reshape(4, 2) rect np.zeros((4, 2), dtypenp.float32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上xy 最小 rect[2] pts[np.argmax(s)] # 右下xy 最大 d np.diff(pts, axis1) rect[1] pts[np.argmin(d)] # 右上x-y 最小 rect[3] pts[np.argmax(d)] # 左下 return rect def rectify_plate(img, quad, out_w240, out_h80): src order_points(quad) dst np.array([[0, 0], [out_w - 1, 0], [out_w - 1, out_h - 1], [0, out_h - 1]], dtypenp.float32) M cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(img, M, (out_w, out_h))输出尺寸240x80是固定值这点很重要。固定尺寸意味着后面所有字符切出来的小图尺寸是可预期的模板归一化也能用同一个目标尺寸。如果让输出尺寸跟着原图变模板匹配的阈值就得跟着变调试会非常痛苦。order_points这段是经典写法靠坐标和与坐标差来判方向。它有个已知缺陷车牌旋转超过 45 度时会错乱。实际场景里倾斜到那个程度基本也不可能识别了所以不用过度设计但心里要有数。二值化建议用自适应阈值而不是固定阈值车牌表面反光、污渍很常见def to_binary(plate_bgr): gray cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # 自适应阈值每个像素参考邻域均值抗局部光照不均 binary cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, # 反相让字符变白、背景变黑 19, 10 # blockSize 必须是奇数C 是偏移量 ) return binaryblockSize19和C10决定了局部性强度。blockSize越大越看整体小了容易把反光区当成字符C越大越保守能滤掉噪点但细笔画可能断。这两个值建议放在 config 里换数据集时优先调它们比调颜色阈值见效快。THRESH_BINARY_INV这个反相别漏。后续垂直投影统计的是白色像素字符是白色的才好统计。第一次跑出来分割结果全是空的时候先检查这里是不是用成了THRESH_BINARY。4. 字符分割与模板匹配没有深度学习模型怎么认出字符到了这一步输入是一张标准化过的 240x80 二值图输出应该是 7 个字符字符串。整段没有神经网络靠的是投影切分加模板比对也正因为简单出了问题每一环都能单独可视化。4.1 二值化后的垂直投影分割垂直投影就是把每一列的白色像素个数累加起来得到一个长度为宽度的数组。字符所在位置的列白点密集数值高字符之间的间隙白点稀疏数值低。找波谷就找到了切分点。# segment/projection.py import numpy as np def split_chars(binary, cfg): h, w binary.shape # 逐列统计白点数量 proj np.sum(binary 255, axis0) / 255.0 # 阈值取高度的比例太低会把噪点当字符 thresh h * cfg.PROJ_RATIO chars, in_char, start [], False, 0 for i, v in enumerate(proj): if v thresh and not in_char: in_char, start True, i elif v thresh and in_char: in_char False if i - start cfg.MIN_CHAR_W: # 太窄的当噪声丢掉 chars.append(binary[:, start:i]) # 处理最后一个字符贴着右边界的情况 if in_char and w - start cfg.MIN_CHAR_W: chars.append(binary[:, start:w]) return merge_small(chars, cfg) # 处理断裂字符PROJ_RATIO常取 0.08 到 0.15。取 0.05 时车牌边框那几条横线会被当成字符切进来取 0.2 时笔画细的字符比如「1」直接被过滤掉。这个值需要在测试集上试几轮没有万能值。MIN_CHAR_W用来滤掉噪点常取 6 到 10 个像素在 240 宽的输出图上平均字符宽约 26 像素。太小会切出一堆碎片太大会漏掉「1」这种窄字符。merge_small是补丁函数专门处理「川」「京」这类左右结构被切成两半的情况。判断逻辑是如果相邻两块的宽度之和在正常字符宽度范围内且间隔很小就合并。没有这一步字符数经常是 9 个或者 10 个直接判失败。4.2 字符归一化到固定尺寸切出来的字符宽高各不相同直接和模板比没有意义必须先归一到同一尺寸。def normalize_char(char_img, size(20, 40)): 缩放到统一尺寸两侧留一点 padding 保证居中 h, w char_img.shape target_w, target_h size # 按高度等比缩放宽度留白避免字符被横向拉伸变形 scale target_h / float(h) new_w max(1, int(w * scale)) resized cv2.resize(char_img, (new_w, target_h), interpolationcv2.INTER_AREA) if new_w target_w: return cv2.resize(resized, (target_w, target_h)) canvas np.zeros((target_h, target_w), dtypenp.uint8) offset (target_w - new_w) // 2 canvas[:, offset:offset new_w] resized return canvas这段的关键是「按高度等比缩放 宽度居中补零」。如果直接cv2.resize到 20x40「1」会被横向拉成一大块和模板完全对不上。等比缩放保持字符本身的笔画比例补零让它在画布上居中模板也是同样处理两边才能对齐。interpolationINTER_AREA在缩小图像时比默认的双线性更少产生伪影二值图缩放尤其明显。放大时才考虑INTER_LINEAR。4.3 模板匹配逐位比对与结果拼接匹配环节没有用到什么高深算法就是把候选字符和模板库里的每一张算一个相似度取最高的那个当结果。# recognize/matcher.py import os import cv2 import numpy as np class TemplateMatcher: def __init__(self, tpl_dir): self.templates {} # 标签 - 归一化后的模板图 for fname in os.listdir(tpl_dir): if not fname.endswith(.png): continue label os.path.splitext(fname)[0] img cv2.imread(os.path.join(tpl_dir, fname), 0) if img is None: continue self.templates[label] normalize_char(img) # 和候选用同一套归一化 def match(self, char_img): cand normalize_char(char_img) best_label, best_score ?, -1.0 for label, tpl in self.templates.items(): # TM_CCOEFF_NORMED 返回 -1 到 1越大越像 res cv2.matchTemplate(cand, tpl, cv2.TM_CCOEFF_NORMED) score float(res.max()) if score best_score: best_score, best_label score, label return best_labelTM_CCOEFF_NORMED是归一化相关系数对整体亮度偏移不敏感比TM_SQDIFF更适合二值图这种只有 0 和 255 的场景。缺点是逐模板循环模板多的时候慢——20 个字母加 10 个数字单字符只比 30 次实际速度完全够用没必要提前优化成向量化。有个细节必须保证模板和候选字符必须走同一个normalize_char。如果模板是用cv2.resize直接拉伸到 20x40 的候选是等比缩放加补零的那相似度天生就偏低怎么调阈值都没用。这是模板匹配法最常见的对不齐问题。另外模板库的质量直接决定上限。模板图最好从真实车牌上裁而不是用电脑字体渲染。字体渲染出来的笔画粗细均匀真实车牌受摄像头、压缩影响笔画会有粗细变化两者匹配分数普遍偏低。项目里带的模板如果是真实裁剪的别想着自己用字体重新生成一套「更干净」的——分数会掉得很明显。4.4 中文省份简称的单独处理省份汉字是这套方案最容易崩的地方。汉字笔画多、结构复杂模板匹配的分数普遍比数字字母低而且汉字模板数量多31 个容易互相误判。工程上的处理方式有两种。第一种是提高汉字模板的匹配权重也就是在对比分数时给汉字候选留个余量第二种更实用——先判断是不是汉字再在汉字子集里匹配。判断依据可以用字符的外接框宽高比汉字通常比数字字母更方一些。def match_first_char(self, char_img): 第一位单独用省份模板库匹配避免和字母互相干扰 cand normalize_char(char_img) best_label, best_score ?, -1.0 for label, tpl in self.province_templates.items(): res cv2.matchTemplate(cand, tpl, cv2.TM_CCOEFF_NORMED) if float(res.max()) best_score: best_score, best_label float(res.max()), label # 分数过低时返回占位符让上层决定是否丢弃这次结果 return best_label if best_score 0.35 else ?0.35 这个阈值是经验值不同模板集差别很大得自己跑一批图看分布。低于阈值返回?而不是硬猜一个这点很重要——业务上「识别失败」比「识别出一个错牌号」好处理得多后者可能导致误放行或者查询到别人的车。省份模板建议按实际业务覆盖的省份裁剪不是 31 个都必须有。某个园区的车九成是本省牌照模板集收窄到 5 到 8 个省份准确率会明显提升。模板数量少误匹配的机会就少。5. 避坑与排查调了三天还是识别成乱码的常见原因这套方案看着简单实际调起来坑不少而且大部分坑不会报错只会悄悄给你一个错结果。下面这几条是踩过的按现象、原因、解决三段写清楚。5.1 定位框到整辆车或者干脆框空现象可视化定位结果时画出来的框比车牌大好几圈甚至框住了整个车尾或者完全找不到候选。原因形态学核尺寸不对或者颜色阈值放得太宽。核用(25, 25)时闭运算把车牌和保险杠、车灯连成了一整块轮廓筛选里的宽高比条件自然失效反过来核太小车牌被切成几段每段都不满足最小面积。颜色阈值太宽则是把车身同色区域也纳进来了。解决把 mask 打出来看。我在调这块时习惯保存三张中间图——原始 mask、闭运算后的 mask、筛选后的候选框。看闭运算那张如果车牌和周围连成一片就是核太大如果车牌还是碎的就是核太小或者iterations不够。改完再看候选框框大但比例对说明是核的问题框完全不对回头看颜色阈值。5.2 车牌是斜的切出来的字符全部歪着现象定位框看着挺准但切出来的字符明显倾斜模板匹配分数普遍在 0.3 以下满屏乱码。原因跳过了透视校正直接用boundingRect裁了水平矩形。斜着的车牌被裁成矩形后字符本身还是倾斜的归一化到 20x40 也掰不回来。解决确认rectify_plate真的被调用了并且输入的四个角点顺序是对的。顺序错的话透视变换会把图旋转 90 度或者镜像表现是字符全变成横的或者反的。判断方法很简单把warped保存出来看一眼正常的车牌应该是水平的、字正立的、左右留白均匀。这一步的可视化检查强烈建议加进 main 里用cv2.imwrite存一张就够了比在调试器里看数组直观得多。另外角点如果来自approxPolyDP近似精度设得太松会得到三个点或者五个点。稳妥做法是加一句断言点数不是 4 就退回minAreaRect取四个角。5.3 字符数量总是 6 个或者 8 个怎么都凑不齐 7 个现象定位和校正都正常但split_chars返回的字符数不稳定同一张图不同参数下 6 个、8 个、9 个都出现过。原因有三类。一是投影阈值PROJ_RATIO不合适导致「1」被漏掉或者边框线被当成字符。二是左右结构的汉字被切成两半多出一个碎片。三是车牌上的螺丝孔、污渍形成独立的小白块被当成字符。解决按顺序排查。先打印proj数组看每个字符对应的峰值区间和间隙宽度肉眼判断阈值该往哪边调。然后给MIN_CHAR_W设一个下限把宽度小于 6 像素的块统统丢掉这能挡掉大部分螺丝孔。最后加上merge_small处理断裂判断依据是相邻块间距小于平均字符间距的一半且合并后宽度在正常范围内。这三步做完大部分图的字符数就稳定了。还有一种情况是车牌左右两侧的白色边框被切进来了。解决办法是在二值化之后把图像最左和最右各 3 到 5 列强制置黑让边框不参与投影统计。5.4 数字和字母互相认错0 和 O、8 和 B 分不清现象整体识别结果看起来接近但总有一两位是错的错的往往就是 0/O、1/I、8/B、5/S 这几对。原因模板库里同时存在长得几乎一样的两个字符而且候选字符的笔画粗细和模板不完全一致分数只差一点点取最大值时随机性很大。解决从业务规则入手比调算法有效。国内车牌的编码规则是固定的——第一位是省份汉字第二位是字母发牌机关代号后五位是字母和数字混合但通常不含 I 和 O。所以直接把I、O从模板库删掉让 0 和 O 的竞争变成单选。这一招能解决大半的混淆。更进一步可以加一层位置约束第二位强制只从字母模板里匹配第三到第七位优先数字模板。规则会用之后准确率提升很明显因为把「视觉判断」变成了「视觉加规则」容错空间大了很多。5.5 换了一批图准确率断崖下跌现象项目自带的测试图上跑得挺好换成自己拍的图识别率从八成掉到两成。原因源码里的参数是在特定数据集上调出来的摄像头型号、拍摄距离、光照条件一变颜色阈值和尺寸参数就都不适用了。特别是分辨率差异——如果原图是 4000 像素宽缩放系数还是 0.5形态学核(17, 3)相对车牌来说就太小了。解决先把所有跟绝对尺寸有关的参数改成相对值。核尺寸按图像宽度的比例算比如kw max(9, int(w * 0.012))这样换分辨率也能自适应。然后重新校准颜色阈值方法是统计一批新图的 HSV 分布看车牌区域落在哪个区间而不是沿用旧值。最后如果新场景的光照条件差异大比如夜间考虑加一步直方图均衡化或者自适应 gamma 校正在颜色分割之前把亮度拉平。我一般会留一个小脚本专门干这件事丢进去 20 张新图输出每张图定位到的候选框数量、字符数、以及最终结果扫一眼就知道该调哪一段。这种小工具花十分钟写能省掉几小时的瞎试。6. 二次开发把 demo 改成能挂到业务里的小服务源码能跑通只是起点真正落地还需要把它包成一个能被调用的模块。这套方案的好处是纯 CPU、无模型文件打包成服务的成本很低。6.1 封装成函数并加上失败兜底第一件事是把main.py里的流程抽成一个纯函数输入是numpy数组输出是结构化的结果不依赖任何全局状态和文件路径。# service/recognizer.py import cv2 import numpy as np from config import CFG from locate.color_seg import locate_plate from locate.rectify import rectify_plate, to_binary from segment.projection import split_chars from recognize.matcher import TemplateMatcher class PlateRecognizer: def __init__(self, cfgCFG): self.cfg cfg # 模板只加载一次别放进请求处理函数里反复读盘 self.matcher TemplateMatcher(cfg.TPL_DIR) def recognize(self, img_bgr): result {plate: None, chars: [], scores: [], reason: ok} try: img cv2.resize(img_bgr, None, fxself.cfg.SCALE, fyself.cfg.SCALE) quad locate_plate(img, self.cfg) if quad is None: result[reason] no_plate_located return result warped rectify_plate(img, quad) chars split_chars(to_binary(warped), self.cfg) if len(chars) ! self.cfg.EXPECT_LEN: result[reason] fbad_split_{len(chars)} return result labels, scores [], [] for i, c in enumerate(chars): label, score (self.matcher.match_first_char(c) if i 0 else self.matcher.match(c)) labels.append(label) scores.append(round(score, 3)) result[plate] .join(labels) result[chars] labels result[scores] scores # 平均分过低视为不可信让上层走人工复核 if sum(scores) / len(scores) self.cfg.MIN_CONF: result[reason] low_confidence except Exception as e: result[reason] ferror: {type(e).__name__} return result模板加载放在__init__里是必须的。如果每次请求都cv2.imread三十张模板单次耗时能翻好几倍而且高频读盘在容器里很容易触发性能抖动。返回结构里带上scores和reason是为了让上层能分级处理。平均分高于 0.7 直接信任0.5 到 0.7 之间标记为待复核低于 0.5 直接丢人工。这套分级比一刀切的成功失败要好用得多实测下来能挡掉绝大部分错误识别又不至于让人工看太多正常结果。6.2 加一层轻量 HTTP 接口与并发注意点封装好之后挂个 Flask 就够用了这种场景请求量通常不大没必要上更重的东西。# service/app.py import cv2 import numpy as np from flask import Flask, request, jsonify from service.recognizer import PlateRecognizer app Flask(__name__) recognizer PlateRecognizer() # 进程内单例模板只加载一次 app.route(/recognize, methods[POST]) def recognize(): f request.files.get(image) if f is None: return jsonify({code: 400, msg: missing image}), 400 data np.frombuffer(f.read(), dtypenp.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR) if img is None: return jsonify({code: 400, msg: decode failed}), 400 return jsonify({code: 0, data: recognizer.recognize(img)}) if __name__ __main__: # 生产环境用 gunicorn 起多 worker单 worker 时线程数要和 CPU 核数匹配 app.run(host0.0.0.0, port8000, threadedTrue)这里有个并发上的注意点OpenCV 的部分函数内部会开多线程再叠加 Flask 的多线程容易出现线程争抢导致单次耗时反而变长。稳妥做法是在服务启动时调cv2.setNumThreads(1)把并行交给进程级的 worker 处理每个 worker 单线程跑 OpenCV整体吞吐更稳定。图像大小也要卡一下。请求方传一张 4K 图进来缩放后仍然很大形态学运算会变慢还可能被恶意大图打到内存。加一个if img.shape[1] 1920:的预缩放成本很低。6.3 验证方法用小批量脚本量化每一段的通过率改完之后怎么知道改好了不能靠肉眼看几张图。写一个评估脚本把失败原因统计出来比单看总准确率有用得多。# tools/eval.py import os import cv2 from collections import Counter from service.recognizer import PlateRecognizer def evaluate(img_dir, label_file): rec PlateRecognizer() labels dict(line.strip().split() for line in open(label_file, encodingutf-8)) stat Counter() correct 0 for fname, gt in labels.items(): img cv2.imread(os.path.join(img_dir, fname)) if img is None: stat[read_fail] 1 continue r rec.recognize(img) if r[plate] gt: correct 1 stat[hit] 1 else: # 失败原因按定位 / 分割 / 置信度分开统计 stat[r[reason]] 1 total len(labels) print(faccuracy: {correct / total:.3f} ({correct}/{total})) for k, v in stat.most_common(): print(f {k}: {v}) if __name__ __main__: evaluate(data/images, data/labels.txt)输出里的失败原因分布是关键信息。如果no_plate_located占大头说明定位参数要重新调如果bad_split_7之类的分割错误多说明二值化或投影阈值有问题如果low_confidence多而结果其实是对的说明模板库覆盖不够需要补模板。按这个顺序一轮轮收敛每轮只改一个环节改完重跑评估。我一般要求每轮至少看 50 张图的统计结果才下结论样本太少的时候数字波动很大容易把自己带沟里。从那以后我每次拿到这类传统视觉方案都先强制跑一遍分段评估再动手改代码。凭感觉调参最容易出现「改好了这张、弄坏了那张」而分段的数字不会骗人。这套源码的价值不在于它能识别多复杂场景而在于每一段都看得见、改得动适合拿来当传统图像处理流水线的练手底子。希望帮到你。本文还有配套的精品资源点击获取