☰
Python-OpenCV车牌识别系统:四段流水线工程实践
2026/11/2 20:57:00 网站建设 项目流程

简介:面向计算机视觉初学者、课程设计与毕业设计需求者的车牌识别项目源码包,围绕 Python 与 OpenCV 实现图片车牌定位识别、图形界面操作、识别结果导出 Excel,并扩展摄像头实时识别场景。相比传统方案,将实时视频流接入识别流程,有助于提升实际场景下的识别效率与准确率;同时作者对识别模块函数作了优化,识别速度与稳定性得到改善,适合用来理解车牌检测、字符识别与模型调用的完整链路。压缩包共 16468 个文件,约 24.91MB,主体为 jpg 车牌与样本图像,另含 py 源码、pyc 缓存、xml 配置、dat 模型数据及少量项目说明文件,便于直接运行、调试和二次开发。已有 1844 人学习,可作为入门视觉项目、算法改进与界面集成的实践参考。

1. 闸机口的一次误识别:这套 Python-OpenCV 车牌系统能干什么

去年帮一位做园区停车管理的朋友看现场,他说闸机口那套设备对蓝底车牌的识别时好时坏,下雨天基本靠人工抬杆。我翻了一圈 python opencv github 上能找到的同类实现,又对着计算机视觉的常规做法捋了一遍,发现这类基于 Python 与 OpenCV 的车牌识别系统骨架高度相似:图像预处理、车牌定位、字符分割、字符识别四段流水线,人工智能部分常用模板匹配或 Tesseract OCR 兜底,外面再套一个命令行或 PyQt 界面。这套源码适合三类人:计算机视觉入门想找一个完整练手项目的学生、要给停车场或门禁做离线演示原型的开发者,以及想把传统图像处理流水线当基线、之后再换深度学习模型的工程师。它不依赖 GPU,一张普通摄像头抓拍图就能跑完整链路,改起来也直观。

2. 架构与目录:四个处理阶段如何串成一条流水线

把这套系统拆开看,它本质上是一条从 BGR 图像到字符串的流水线,最怕写成五百行的main.py一把梭。定位参数、分割阈值、识别后端这三块调试频率完全不同:定位要反复看掩膜图,分割要反复看投影曲线,识别则可能整个换掉。所以合格的做法是按阶段拆成独立类,用一个Pipeline编排,每一段都能单独 import 出来跑单测。常见做法是依赖注入,PlatePipeline构造函数接收detector、rectifier、segmenter、recognizer四个对象,后面换识别后端时只动一个参数,前面的定位分割代码一行不改。

2.1 从 BGR 帧到结果字典的数据流

流水线入口拿到的是cv2.imread读出来的numpy.ndarray,形状(H, W, 3),BGR 通道序。整条链路固定输入输出,方便批量跑和接 HTTP 服务。编排代码大致是这样:

import cv2 class PlatePipeline: """把定位、校正、分割、识别四段串起来,对外只暴露 run_image""" def __init__(self, detector, rectifier, segmenter, recognizer, debug=False): self.detector = detector # 负责找候选框 self.rectifier = rectifier # 负责摆正和缩放 self.segmenter = segmenter # 负责切字符 self.recognizer = recognizer # 负责字符转文本 self.debug = debug # 打开后保存中间图,定位失败时非常有用 def run_image(self, path): image = cv2.imread(path) if image is None: # 读图失败直接抛,不要在后面 cvtColor 时报一个看不懂的断言错误 raise ValueError(f"读图失败,检查路径或扩展名: {path}") resized, boxes = self.detector.find_candidates(image) if not boxes: return {"plate": "", "score": 0.0, "status": "no_plate"} # 取面积最大的候选框,绝大多数场景下就是车牌 plate_roi = self.rectifier.rectify(resized, boxes[0]) chars = self.segmenter.segment(plate_roi) if len(chars) < 6: return {"plate": "", "score": 0.0, "status": "segment_failed"} plate, score = self.recognizer.predict(chars, plate_roi) return {"plate": plate, "score": round(score, 3), "status": "ok"}

这段的关键在返回值约定:统一返回plate、score、status三个字段,调用方不用关心内部走到哪一步。status用no_plate、segment_failed、ok三种状态区分失败原因,批量跑的时候一眼能看出是定位问题还是分割问题。score是识别置信度的均值,模板匹配下就是归一化相关系数,低于 0.6 一般要人工复核。debug=True时每个阶段把中间图写到data/output/debug/,这个开关在调参阶段能省掉大量 print。

四个阶段的输入输出整理成一张表更清楚:

阶段输入核心操作输出
车牌定位原图 BGRHSV 颜色掩膜、形态学开闭运算、轮廓宽高比筛选缩放后的图 + 候选框列表
精修校正原图 + 框最小外接矩形估角、仿射旋转、统一缩放到 440×140摆正的车牌小图
字符分割车牌小图灰度化、自适应二值化、垂直投影切分字符图列表,一般 7 个
字符识别字符图列表模板匹配逐字打分,低分时整牌走 OCR车牌字符串 + 置信度

提示:先把车牌统一缩放到 440×140 再做分割和识别,所有阈值都基于这个尺寸调。否则换一个分辨率不同的摄像头,投影阈值和形态学核全得重调,这是这类项目最常见的维护成本。

2.2 目录结构:每个文件负责哪一段

源码包的目录一般长这样,根目录只放入口和配置,业务代码全在core/里:

plate-recognition/ ├── main.py # 命令行入口,argparse 参数解析 ├── config.py # 全局配置:尺寸、阈值、模板路径 ├── core/ │ ├── __init__.py │ ├── detector.py # 车牌定位:HSV 掩膜 + 形态学 + 轮廓筛选 │ ├── rectifier.py # 角度校正与尺寸归一化 │ ├── segmenter.py # 字符分割:二值化 + 垂直投影 │ ├── recognizer.py # 字符识别:模板匹配 / OCR 兜底 │ └── pipeline.py # 四段流水线编排 ├── utils/ │ ├── image_utils.py # 中文路径读写、中间图保存 │ └── logger.py ├── templates/ # 字符模板,文件名即标签,如 8.png、A.png ├── data/ │ ├── input/ # 待测图片 │ └── output/ # 结果图、CSV、debug 中间图 ├── ui/ │ └── app.py # 可选 PyQt 界面,调 pipeline 的壳 ├── requirements.txt └── README.md

config.py值得单独说。把RESIZE_WIDTH、HSV_LOWER、HSV_UPPER、MIN_AREA、RATIO_RANGE、BINARY_BLOCK_SIZE、TEMPLATE_SIZE这些常量集中在一个文件里,调参时不用在四五个文件之间跳。我见过把阈值散落在各个类里的写法,换一批图就要全局搜索数字,血泪经验是宁可多一个配置文件,也别让魔法数字散开。utils/image_utils.py里放中文路径读写函数,后面避坑章节会讲为什么必须单独处理。templates/目录的文件名直接当标签用,0.png到9.png、A.png到Z.png,去掉容易和数字混淆的I和O。

2.3 依赖与环境:版本选择和安装顺序

Python 版本建议 3.8 到 3.11,再新的版本部分轮子还没跟上。OpenCV 用opencv-python4.x,注意 4.x 的cv2.findContours只返回两个值(contours, hierarchy),网上很多老教程还是三个返回值,那是 3.x 的写法,直接抄会报 unpack 错误。numpy跟着 OpenCV 的依赖走就行,不用锁太死。OCR 兜底依赖pytesseract,但它只是 Python 封装,系统里还得单独装 Tesseract 本体,这一步后面单独讲。

opencv-python==4.9.0.80 numpy==1.26.4 pytesseract==0.3.10 Pillow==10.2.0

Pillow主要给 UI 预览和部分图片格式兜底用,纯命令行跑识别可以不装。PyQt5只有用ui/app.py时才需要,放到可选依赖里,别让只想跑命令行的人多装两百兆。安装顺序上,先建虚拟环境再装依赖,避免和系统里的 OpenCV 版本打架:

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt

装完跑一句python -c "import cv2; print(cv2.__version__)"确认版本是 4.x。如果输出是 3.x,大概率是系统里另有一个 OpenCV 抢了路径,虚拟环境没生效,这种情况在同时装过多个 Python 的机器上很常见。

3. 核心代码拆解:定位、校正、分割、识别四段实现

四段里最影响最终准确率的是定位和分割,识别反而因为字符集小、模板固定,容易做到看得过去。现实中的翻车大多发生在定位阶段:掩膜空了、框偏了、车牌被切成两半,后面再准也没用。所以调参顺序建议是先用--debug把定位中间图看顺眼,再调分割,最后才动识别阈值。

3.1 车牌定位:HSV 颜色掩膜加形态学收口

蓝底白字车牌在 HSV 空间里有比较稳定的色相区间,OpenCV 的 H 范围是 0 到 180,蓝色大致落在 100 到 124。直接对整图做边缘检测再找矩形,在车身反光、树影干扰下误检率很高,颜色先验是性价比最高的过滤手段。定位类的核心代码如下:

import cv2 import numpy as np class PlateDetector: """蓝底车牌定位:颜色掩膜 + 形态学闭运算 + 轮廓几何筛选""" def __init__(self, resize_width=640): # S 和 V 的下限不要设太高,否则阴天和地库容易整块掩膜为空 self.lower_blue = np.array([100, 80, 80]) self.upper_blue = np.array([124, 255, 255]) self.resize_width = resize_width def preprocess(self, image): h, w = image.shape[:2] scale = self.resize_width / float(w) resized = cv2.resize(image, (self.resize_width, int(h * scale))) hsv = cv2.cvtColor(resized, cv2.COLOR_BGR2HSV) return resized, hsv def find_candidates(self, image): resized, hsv = self.preprocess(image) mask = cv2.inRange(hsv, self.lower_blue, self.upper_blue) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) # 先开运算去掉车身反光形成的小噪点 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) # 再闭运算把车牌内部字符之间的缝隙填上,让轮廓连成一块 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=3) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if w * h < 800: continue ratio = w / float(h) # 蓝牌宽高比约 3.14:1,留出透视变形余量 if 2.2 < ratio < 4.2: boxes.append((x, y, w, h)) boxes.sort(key=lambda b: b[2] * b[3], reverse=True) return resized, boxes

参数逐个说:lower_blue和upper_blue是 HSV 三通道的下上限,H 100 到 124 覆盖蓝色,S 和 V 下限 80 是经验值,光照越差越要往下调。iterations=3的闭运算会让车牌区域稍微膨胀,换来的是字符缝隙被填平、轮廓更完整,代价是相邻的蓝色车标可能粘进来,所以后面还有宽高比兜底。面积阈值 800 是按缩放后 640 宽估的,越小越容易把远处的小车牌也收进来,误检也越多。宽高比 2.2 到 4.2 是按标准蓝牌比例放宽后的区间,倾斜拍摄时比例会变形,收太窄会漏。

注意:这段只处理蓝牌。黄牌、绿牌要另开一套 HSV 区间,或者干脆走灰度 + 边缘的通用方案。不要指望一个颜色区间通吃所有车牌类型。

3.2 精修与校正:minAreaRect 摆正到 440×140

定位给的框是水平外接矩形,车牌一旦有倾斜,框里就会带进背景,字符分割时投影会乱。精修阶段用最小外接矩形估一个角度,做一次仿射旋转,再统一缩放到固定尺寸。OpenCV 4.5 之后minAreaRect的角度语义有调整,常见写法要处理宽高互换的情况:

import cv2 import numpy as np class PlateRectifier: def __init__(self, target_size=(440, 140)): self.target_size = target_size def rectify(self, image, box): x, y, w, h = box roi = image[y:y + h, x:x + w] gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 50, 150) contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return cv2.resize(roi, self.target_size) rect = cv2.minAreaRect(max(contours, key=cv2.contourArea)) angle = rect[2] # 统一到 -45 到 45 的旋转角,OpenCV 4.5 前后语义不同,这样写兼容 if rect[1][0] < rect[1][1]: angle -= 90 # 角度过大基本是误检框套歪了,强行旋转只会更糊 if abs(angle) > 15: return cv2.resize(roi, self.target_size) center = (w / 2.0, h / 2.0) matrix = cv2.getRotationMatrix2D(center, angle, 1.0) rotated = cv2.warpAffine(roi, matrix, (w, h), borderValue=(255, 255, 255)) return cv2.resize(rotated, self.target_size)

target_size=(440, 140)是按标准蓝牌比例定的,宽高比 3.14,和定位阶段的筛选比例对得上。Canny的 50 和 150 是常用起点,边缘太多就把高阈值往上加。abs(angle) > 15这个判断是安全阀:正常车牌倾斜不会超过这个范围,超过说明框本身有问题,与其旋转出一张更糊的图,不如直接缩放。borderValue=(255, 255, 255)让旋转后的空边填白色,避免黑边在二值化时被当成字符墨迹。这一段调完最好存一张图看一眼,字符是否水平,直接决定分割成败。

3.3 字符分割:自适应二值化与垂直投影切分

车牌小图到手后,先灰度化再二值化。这里用自适应阈值而不是全局阈值,原因是车牌可能有反光和污渍,全局阈值一刀切容易把字符切断。二值化后裁掉上下边框和铆钉区域,再做垂直投影,统计每一列的白像素数量,连续高于阈值的列就是一个字符:

import cv2 import numpy as np class PlateSegmenter: def segment(self, plate_bgr): gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # 自适应阈值对光照不均更稳,blockSize 必须是奇数 binary = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 25, 10 ) h, w = binary.shape # 裁掉上下边框和左右边缘,避免边框被当成字符 binary = binary[int(h * 0.12):int(h * 0.92), int(w * 0.02):int(w * 0.98)] projection = np.sum(binary, axis=0) / 255.0 chars, start = [], None for i, value in enumerate(projection): if value > 10 and start is None: start = i elif value <= 10 and start is not None: # 宽度太窄的列块大概率是噪点或边框残留 if i - start >= 8: chars.append(binary[:, start:i]) start = None # 字符数不对时按宽度排序取前 7 个,先保证流水线能往下走 if len(chars) > 7: chars = sorted(chars, key=lambda c: c.shape[1], reverse=True)[:7] return chars

blockSize=25、C=10这组参数是按 440×140 的车牌图调的,图越小 blockSize 要跟着降。THRESH_BINARY_INV让白字变白、蓝底变黑,和后面投影逻辑对应。裁掉上下 12% 是经验值,标准车牌字符约占中间 70% 的高度,上下边缘一般只有边框。投影阈值 10 表示一列至少要有 10 个白像素才算字符笔画,车牌图 140 高,正常字符竖笔画远超这个数,噪点很难达到。字符最小宽度 8 是下限,太窄的列块直接丢。

分割结果的字符数通常在 7 个左右,但蓝牌第一位是省份汉字,用模板匹配时汉字模板最难做,很多实现会跳过第一位,只识别后面的字母数字。这一步的取舍要提前想清楚,不然后面接业务系统时发现少了省份,还得回头改分割逻辑。

3.4 字符识别:模板匹配打分与 OCR 兜底

字符集就三十多个,模板匹配是最省事的方案:把模板统一到 20×40 并二值化,逐个字符用cv2.matchTemplate算归一化相关系数,取最高分的标签。识别类可以这样写:

import os import cv2 import numpy as np class PlateRecognizer: def __init__(self, template_dir, template_size=(20, 40), min_score=0.6): self.template_size = template_size self.min_score = min_score self.templates = self._load_templates(template_dir) def _load_templates(self, template_dir): templates = {} for name in os.listdir(template_dir): if not name.lower().endswith(".png"): continue label = os.path.splitext(name)[0] img = cv2.imread(os.path.join(template_dir, name), cv2.IMREAD_GRAYSCALE) if img is None: continue img = cv2.resize(img, self.template_size) # 模板和输入必须走同一套二值化,否则墨迹粗细不一致会拉低分数 _, img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) templates[label] = img return templates def predict(self, chars, plate_roi=None): labels, scores = [], [] for char_img in chars: char_img = cv2.resize(char_img, self.template_size) best_label, best_score = "?", -1.0 for label, tpl in self.templates.items(): res = cv2.matchTemplate(char_img, tpl, cv2.TM_CCOEFF_NORMED) score = float(res.max()) if score > best_score: best_label, best_score = label, score labels.append(best_label) scores.append(best_score) text = "".join(labels) score = sum(scores) / len(scores) if scores else 0.0 # 平均分太低时整牌交给 OCR 再判一次,OCR 有上下文,比逐字硬匹配稳 if score < self.min_score and plate_roi is not None: ocr_text = self._ocr_fallback(plate_roi) if ocr_text: return ocr_text, self.min_score return text, score def _ocr_fallback(self, plate_roi): import pytesseract gray = cv2.cvtColor(plate_roi, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # psm 7 表示按单行文本识别,白名单限定字母数字,减少乱识别 config = "--psm 7 -c tessedit_char_whitelist=ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" return pytesseract.image_to_string(binary, config=config).strip()

TM_CCOEFF_NORMED返回 -1 到 1,越接近 1 越像,用它是因为对整体亮度变化不敏感。模板和输入都做THRESH_OTSU是关键细节,很多人只对输入二值化,模板还是灰度图,分数会莫名其妙偏低。min_score=0.6是个经验阈值,低于它说明字符可能被切坏或沾了污渍,这时候逐字匹配已经不可信,整牌走 OCR 反而更稳。OCR 白名单去掉了I和O,因为车牌里不用这两个字母,留着只会增加混淆。汉字省份需要系统装中文语言包,没装时白名单里不要放中文,否则识别结果会很奇怪。

4. 跑起来:环境安装、命令行参数与批量测试

4.1 三步启动:虚拟环境、依赖、单图跑通

第一次跑按顺序来,不要跳步。先确认 Python 版本,再建虚拟环境,最后装依赖。依赖装完后先找一张清晰的正面车牌图,放到data/input/下,用单图模式跑通再说批量。

python --version # 确认 3.8 到 3.11 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt python main.py --source data/input/test1.jpg --mode image --debug

--debug会把定位掩膜、校正后的车牌、二值化结果、投影图都存到data/output/debug/,第一次跑强烈建议打开。命令行的输出是一个字典,status为ok时plate字段就是识别结果。如果status是no_plate,先去看掩膜图是不是全黑,全黑一般是光照或 HSV 区间问题;如果是segment_failed,去看校正后的车牌图是不是歪的或者糊的。

4.2 入口参数与配置文件怎么改

入口统一用argparse,常用的五个参数覆盖单图、批量、调试三种场景:

import argparse from core.pipeline import PlatePipeline from core.detector import PlateDetector from core.rectifier import PlateRectifier from core.segmenter import PlateSegmenter from core.recognizer import PlateRecognizer def build_parser(): parser = argparse.ArgumentParser(description="车牌识别命令行入口") parser.add_argument("--source", required=True, help="单图路径,或批量模式下的图片目录") parser.add_argument("--mode", choices=["image", "batch"], default="image") parser.add_argument("--output", default="data/output", help="结果输出目录") parser.add_argument("--show", action="store_true", help="弹窗显示定位结果,无 GUI 环境不要开") parser.add_argument("--debug", action="store_true", help="保存各阶段中间图") return parser def main(): args = build_parser().parse_args() pipeline = PlatePipeline( detector=PlateDetector(resize_width=640), rectifier=PlateRectifier(target_size=(440, 140)), segmenter=PlateSegmenter(), recognizer=PlateRecognizer("templates"), debug=args.debug, ) if args.mode == "batch": pipeline.run_batch(args.source, args.output) else: result = pipeline.run_image(args.source) print(result) if __name__ == "__main__": main()

--source在两种模式下含义不同,单图是文件路径,批量是目录。--show依赖 GUI,服务器和容器里不要开,否则会卡住。--debug不只是存图,后面排查定位问题时它就是黑匣子。日常调参最先动的是config.py里的RESIZE_WIDTH、HSV 上下限和投影阈值,main.py里的对象构造参数保持默认即可,别在两处同时改,否则排查时会分不清生效的是哪个。

4.3 批量模式与结果输出格式

批量模式要解决两个问题:一张图失败不能中断整批,结果要能直接拿 Excel 打开做统计。实现上逐张 try,异常记录下来继续下一张:

import csv import os def run_batch(self, input_dir, output_dir): os.makedirs(output_dir, exist_ok=True) rows = [] for name in sorted(os.listdir(input_dir)): if not name.lower().endswith((".jpg", ".jpeg", ".png")): continue path = os.path.join(input_dir, name) try: result = self.run_image(path) rows.append([name, result["plate"], result["score"], result["status"]]) except Exception as exc: # 单张失败不影响整批,但要把原因写进 CSV,方便回头定位 rows.append([name, "", 0, f"error: {exc}"]) csv_path = os.path.join(output_dir, "result.csv") with open(csv_path, <p> <a href="https://download.csdn.net/download/weixin_51141489/87366071" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询