1. 项目整体设计与技术选型
1.1 核心需求定位
车牌识别停车场管理系统,拆开来看其实是三个问题叠加在一起:怎么把车牌从画面里准确抠出来、怎么把抠出来的字符正确读出来、以及读完以后怎么跟停车场的进出场、计费、查询这些业务串起来。很多人第一眼看到“车牌识别”就把注意力全放在算法上,实际做过之后我最大的体会是:识别只是入口,业务闭环才是真正花时间的地方。
这套系统我最终定的技术栈是Python做后端、Vue做前端。Python负责图像处理、车牌识别算法和业务接口,Vue负责停车场大屏、进出场操作界面和记录查询页面。前后端通过HTTP接口通信,摄像头拍照识别这块走的是浏览器本地摄像头采集图片,再把图片传给后端识别的路子。
这样拆分的好处有三个。一是前后端职责清晰,算法迭代不会影响界面逻辑,界面调整也不用碰算法代码;二是Vue的组件化开发特别适合停车场这种多页面、多状态的场景,进出场、月卡管理、收费明细每个模块都能独立维护;三是Python在图像处理生态上几乎没有对手,OpenCV、HyperLPR、PaddleOCR这些库拿来就能用,不需要自己从零训练模型。
1.2 为什么选本地摄像头拍照方案
停车场管理系统里摄像头方案的选型,业内主要有两条路线。一种是接硬件厂商的SDK,走设备直连,识别在摄像头前端完成,后端只收结果;另一种就是本项目这种,用浏览器调本地摄像头,把抓拍画面传到服务器识别。
硬件厂商SDK的优点是识别速度快、稳定,但问题是厂商绑定严重,每个品牌的SDK接口都不一样,而且很多是需要授权费的。浏览器调摄像头的方案虽然每次识别都有一张图片的网络传输开销,但在原型验证、中小型停车场、临时改造场景下非常灵活——不需要额外买识别一体机,一台带摄像头的电脑就能当识别终端用。
我选本地摄像头拍照识别,还有一个实际考虑:停车场现场环境复杂,车牌角度、光线、遮挡各不相同,本地抓拍可以把原始图像直接送到算法层,方便随时调整预处理参数,调试起来比黑盒的硬件识别设备直观得多。浏览器测的getUserMedia接口现在已经非常成熟,兼容性问题很少,实现成本比很多人想象的低。
1.3 系统模块划分
整个系统我划分成五个模块:
- 图像采集模块:Vue页面调用摄像头,抓拍当前画面,支持本地上传图片作为备用输入
- 车牌识别模块:Python后端接收图片,完成车牌定位、字符分割、字符识别三步
- 车辆管理模块:记录车牌号、车辆类型、入场时间、出场时间、停车时长
- 计费模块:按时长计算费用,支持免费时长、单次封顶、月卡三种计费策略
- 数据看板模块:实时显示场内车位余量、今日进出场数量、今日收费金额
模块之间通过接口耦合,约定好数据结构之后各自独立开发。这个划分也是我做下来觉得比较合理的——识别模块只关心图像输入和识别结果输出,完全不碰业务逻辑;计费模块只收时长和车辆类型,不关心车牌是怎么识别出来的。
2. 车牌识别链路:从图像到结构化数据
2.1 车牌检测的两套方案对比
车牌识别第一步是定位车牌在画面里的位置。这一步做不好,后面字符识别再准也没用。我实测下来有两套方案值得讲:
方案一:传统图像处理定位
用颜色特征和边缘特征做定位。国内车牌有蓝色、黄色、绿色、白色几种底色,其中蓝牌占绝大多数,所以可以先把图片转到HSV颜色空间,提取蓝色区域,再做轮廓筛选。
import cv2 import numpy as np def locate_plate_by_color(image): hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) # 蓝色车牌颜色范围,实测调参后比较稳定的区间 lower_blue = np.array([100, 80, 80]) upper_blue = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) # 形态学闭运算,把车牌字符间的空隙连通起来 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for c in contours: x, y, w, h = cv2.boundingRect(c) # 车牌宽高比一般在2.5到4.5之间,筛选掉明显不是车牌的轮廓 if 2.0 < w / h < 5.0 and w * h > 2000: candidates.append((x, y, w, h)) return candidates这方案的好处是零依赖训练模型,纯OpenCV就能跑通,而且速度快。缺点是只对蓝色车牌效果好,新能源绿牌、黄牌大车、白牌特种车辆覆盖率差,而且一旦画面里有蓝色广告牌、蓝色衣服这类干扰物,误检率会明显上升。
方案二:目标检测模型定位
用YOLOv5或者YOLOv8这类目标检测模型,训练一个车牌检测模型。模型直接输出车牌在画面中的边界框。这个方案对车牌类型的覆盖好,抗干扰能力强,但需要准备标注数据、训练和转模型,前期成本更高。
我在这个项目里最终用的是YOLOv8做检测,配套做识别。原因很简单:这两年YOLO系列的生态太成熟了,Ultralytics官方仓库直接支持训练和导出,很小的模型体积就能在CPU上跑出不错的效果。对于一个车牌这种目标单一、特征明显的检测任务,不需要很大参数量,yolov8n就够用。
2.2 车牌字符识别方案
车牌定位完成之后,要做的是字符识别。我试过两种方式,这里把实际效果和踩坑都讲清楚。
方式一:整图识别(PaddleOCR / HyperLPR)
HyperLPR是专门做车牌识别的开源库,内置了车牌检测加识别全套能力,调用非常简单。PaddleOCR虽然定位是通用OCR,但对车牌这种规整字符效果也很好,而且对模糊、倾斜图片的鲁棒性比较高。
from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang='ch', use_gpu=False) def recognize_plate_by_ocr(plate_image): result = ocr.ocr(plate_image, cls=True) # 返回格式是 [[[文本框坐标], (识别文本, 置信度)], ...] for line in result: if line is None: continue for text_info in line: text = text_info[1][0] conf = text_info[1][1] if conf > 0.7 and any(ch.isalnum() for ch in text): return text, conf return None, 0方式二:字符分割后逐个分类(CNN分类器)
把车牌区域切出来之后,先做字符分割,每个字符单独送到一个CNN分类器里识别。国内民用车牌是7位字符,第一位是省份汉字,第二位是发牌机关字母,后面是字母和数字混合。字符分割的难点在于第二个字符和第六七个字符之间可能有间隔,需要做连通域分析和先验知识校正。
我在实践中的选择是:检测用YOLOv8,识别用PaddleOCR。这样搭配的理由是YOLO负责把车牌区域准确切出来,排除了背景干扰,PaddleOCR只需要做纯粹的整图文字识别,两者各干各擅长的事。实测在正常光线条件下,整体识别准确率能到95%以上。需要注意的是PaddleOCR第一次运行会下载模型文件,部署到无网环境时要提前把模型目录拷贝过去。
2.3 识别结果后处理与置信度策略
识别不是输出字符串就算完事,后处理这步决定了业务侧数据是不是干净。我在这块总结了三个必须做的操作:
- 字符过滤:车牌只允许汉字、大写字母和数字,识别结果出现小写字母要转大写,出现无关字符直接剔除
- 正则校验:按车牌规则匹配,第一位是汉字,第二位是字母,后五位是字母数字混合。不满足规则的结果直接认定识别失败,不能入库
- 置信度阈值:置信度低于阈值时不自动放行,转入人工确认队列
import re plate_pattern = re.compile(r'^[\u4e00-\u9fa5]{1}[A-Z]{1}[A-Z0-9]{5}$') def validate_plate(text): if not text: return None text = text.strip().upper() if plate_pattern.match(text): return text return None置信度阈值我建议设在0.7到0.8之间。调太低会把错误结果放进系统,调太高会导致大量需要人工介入,反而增加工作量。这个值不是你拍脑袋定的,应该拿实际场景的图片跑一批测试数据,画个置信度分布图,看看误识别和漏识别的平衡点在哪。
3. 停车场收费与车辆管理业务设计
3.1 入场与出场流程
停车场业务流程看起来简单:入场抬杆、出场抬杆、中间计费。但真做起来细节非常多,尤其是出场时的异常场景。
正常的入场流程是:摄像头抓拍识别车牌,判断车辆是否在白名单(月卡车辆)中,记录入场时间和图片,放行入场。正常出场流程是:抓拍识别车牌,查询入场记录,计算停车时长和费用,缴费后放行。
问题往往出在异常场景。我在设计阶段就把这些case列了出来:
- 识别失败入场:抓拍图片质量太差导致没识别出来,需要支持手动输入车牌入场
- 无入场记录出场:车辆入场时没被识别到或者系统宕机,出场时要能根据车牌查询不到记录时,按当前时间虚拟一个入场时间补录
- 月卡过期:车辆是月卡但已过期,出场时按临时车计费
- 特殊车辆:军警、救护等特种车辆直接放行
这些异常流程不是边写边想的,是在接口设计阶段就要预留字段。我在车辆记录表里加了一个source_type字段,区分是自动识别还是人工录入;加了一个status字段表示车辆当前是在场内还是已出场。这类字段后面排查问题时会让你少掉很多头发。
3.2 计费规则设计
计费规则是停车场系统的核心业务逻辑,而且不同停车场的规则差别很大。我做的这套系统支持三种策略组合:
| 计费策略 | 规则说明 | 适用场景 |
|---|---|---|
| 按时计费 | 首小时X元,超出部分每小时Y元,不足一小时按一小时计 | 临时车 |
| 单次封顶 | 单次停车费用最高不超过Z元 | 商场、写字楼 |
| 免费时长 | 入场后前N分钟免费,超过后开始计费 | 大部分商业停车场 |
计费逻辑看起来简单,但有一个容易算错的点:跨天计费。如果停车场规则是“白天和夜间费率不同”,那计费就要把停车时长切分成不同费率的时段分别计算。如果只是单纯按时长累加,要考虑封顶是“按自然日封顶”还是“按单次停车封顶”,这两个语义完全不同。
3.3 前端页面组织
Vue这边的页面我分了四个:
- 实时监控页:调本地摄像头,显示识别结果,做成类似道闸控制台的界面
- 车辆管理页:展示场内所有车辆,支持手动入场、手动出场
- 收费记录页:按时间范围查询进出场记录和收费明细,支持导出
- 数据看板页:车位使用率、今日进出车流量、今日收入等统计卡片
路由用Vue Router管理,状态管理用了Pinia存当前用户信息和车辆列表缓存。组件层用Element Plus,表格、表单、弹窗这些都不用自己写。
这里我想多说一句:前端页面不要急着写代码,先把接口数据结构定清楚。比如车辆记录返回的字段是plate_number还是plateNo,是entry_time还是enterTime,前后端各写各的,联调的时候会很痛苦。我在这个项目里直接用Swagger/OpenAPI定义接口文档,前端根据文档生成类型定义,联调基本没扯皮。
4. 环境搭建与核心代码实现
4.1 项目目录结构
先看整体目录,这个结构是我整理过几版之后的最终形态,前端后端分开,识别模型单独放一个目录,业务代码和算法代码不混在一起。
parking-lpr-system/ ├── backend/ │ ├── app.py # Flask应用入口 │ ├── recognition.py # 车牌识别封装 │ ├── parking_service.py # 停车业务逻辑 │ ├── models.py # 数据库模型 │ ├── config.py # 配置参数 │ └── requirements.txt ├── frontend/ │ ├── src/ │ │ ├── api/ # 接口调用封装 │ │ ├── views/ # 页面组件 │ │ ├── router/ # 路由配置 │ │ └── store/ # Pinia状态 │ ├── package.json │ └── vite.config.js └── models/ # 训练好的模型文件 ├── plate_detect.pt └── paddleocr_model/4.2 Flask后端接口实现
后端我用Flask,原因很直接:项目规模不大,Django自带的那套Admin、ORM、中间件用不上,Flask的轻量灵活更合适。数据库用SQLite起步,等并发上来了再换MySQL也不难迁移。
核心接口一共六个:
from flask import Flask, request, jsonify import sqlite3 from datetime import datetime app = Flask(__name__) DB_PATH = 'parking.db' # 初始化数据库表 def init_db(): conn = sqlite3.connect(DB_PATH) conn.execute(''' CREATE TABLE IF NOT EXISTS vehicle_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_number TEXT NOT NULL, vehicle_type TEXT DEFAULT 'temp', entry_time TEXT NOT NULL, exit_time TEXT, duration_minutes INTEGER, fee REAL DEFAULT 0, status TEXT DEFAULT 'in', -- in/out image_path TEXT, source_type TEXT DEFAULT 'auto' ) ''') conn.commit() conn.close() # 车辆入场接口:接收图片,识别车牌,记录入场信息 @app.route('/api/entry', methods=['POST']) def vehicle_entry(): file = request.files.get('image') if not file: return jsonify({'code': 400, 'msg': '未接收到图片'}), 400 # 调用车牌识别 plate_number, confidence = recognize_plate(file.stream.read()) if not plate_number: return jsonify({'code': 500, 'msg': '车牌识别失败,请手动输入'}), 500 # 入库 conn = sqlite3.connect(DB_PATH) now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') conn.execute( 'INSERT INTO vehicle_records (plate_number, entry_time, image_path) VALUES (?, ?, ?)', (plate_number, now, f'images/{datetime.now().timestamp()}.jpg') ) conn.commit() conn.close() return jsonify({'code': 0, 'data': {'plate_number': plate_number, 'entry_time': now}}) # 车辆出场接口 @app.route('/api/exit', methods=['POST']) def vehicle_exit(): data = request.get_json() plate_number = data.get('plate_number') conn = sqlite3.connect(DB_PATH) # 查询该车牌在库内最新的入场记录 row = conn.execute( 'SELECT * FROM vehicle_records WHERE plate_number = ? AND status = "in" ORDER BY id DESC LIMIT 1', (plate_number,) ).fetchone() if not row: return jsonify({'code': 404, 'msg': '未找到入场记录'}), 404 record_id = row[0] entry_time = datetime.strptime(row[3], '%Y-%m-%d %H:%M:%S') exit_time = datetime.now() duration = int((exit_time - entry_time).total_seconds() / 60) fee = calculate_fee(duration) conn.execute( 'UPDATE vehicle_records SET exit_time = ?, duration_minutes = ?, fee = ?, status = "out" WHERE id = ?', (exit_time.strftime('%Y-%m-%d %H:%M:%S'), duration, fee, record_id) ) conn.commit() conn.close() return jsonify({'code': 0, 'data': {'duration_minutes': duration, 'fee': fee}}) if __name__ == '__main__': init_db() app.run(host='0.0.0.0', port=5000, debug=True)代码里我特意把入口逻辑提出来讲一下:入场时只记录entry_time和车牌号,出场时再算时长和费用。千万别在入场的时候就算好费用,迟到出场时重新算——因为你没法预测车辆会停多久。
4.3 Vue前端摄像头调用与图片上传
前端这块的重点是调用摄像头。Vue组件里用navigator.mediaDevices.getUserMedia获取视频流,然后通过canvas.drawImage把当前帧抓成图片,再转成Blob对象通过FormData发给后端。
<template> <div class="camera-panel"> <video ref="videoEl" autoplay playsinline></video> <canvas ref="canvasEl" style="display: none"></canvas> <div class="btn-group"> <el-button type="primary" @click="startCamera">开启摄像头</el-button> <el-button type="success" @click="captureAndRecognize">拍照识别</el-button> <el-button @click="stopCamera">关闭摄像头</el-button> </div> <el-input v-model="manualPlate" placeholder="识别失败时手动输入车牌" style="margin-top: 15px" /> <el-button type="warning" @click="manualEntry" :disabled="!manualPlate">手动入场</el-button> </div> </template> <script setup> import { ref } from 'vue' import { entryApi, exitApi } from '@/api/parking' import { ElMessage } from 'element-plus' const videoEl = ref(null) const canvasEl = ref(null) const manualPlate = ref('') let stream = null async function startCamera() { try { stream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720 } }) videoEl.value.srcObject = stream await videoEl.value.play() } catch (err) { ElMessage.error(`摄像头调用失败:${err.message}`) } } async function captureAndRecognize() { if (!stream) { ElMessage.warning('请先开启摄像头') return } const canvas = canvasEl.value const video = videoEl.value canvas.width = video.videoWidth canvas.height = video.videoHeight canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height) // canvas转Blob,再通过FormData发送到后端 canvas.toBlob(async (blob) => { const formData = new FormData() formData.append('image', blob, `capture_${Date.now()}.jpg`) const res = await fetch('http://localhost:5000/api/entry', { method: 'POST', body: formData }) const data = await res.json() if (data.code === 0) { ElMessage.success(`识别成功:${data.data.plate_number}`) } else { ElMessage.error(data.msg) } }, 'image/jpeg', 0.92) } function stopCamera() { if (stream) { stream.getTracks().forEach(track => track.stop()) stream = null videoEl.value.srcObject = null } } </script>这里有几个细节容易踩坑,我逐个说明。
第一个是getUserMedia必须在HTTPS或者localhost环境下才能用。你部署到服务器上用HTTP访问,摄像头直接调不起来,浏览器会报NotAllowedError。所以正式部署时一定要配HTTPS证书,本地开发用localhost就没问题。
第二个是video元素的playsinline属性必须加上,不然iOS Safari上视频会强制全屏播放,整个页面布局都被打乱。
第三个是canvas.toBlob是异步的,回调里的代码是延迟执行的,注意不要在回调外面直接读blob变量——这个错误我在最开始写的时候犯过,当时百思不得其解为什么formData里总是空的。
4.4 前后端联调与跨域处理
前端开发服务器跑在5173端口,Flask跑在5000端口,不是同源,所以必须处理跨域问题。我用了Flask-CORS扩展,几行代码搞定:
from flask_cors import CORS CORS(app, resources={r"/api/*": {"origins": "*"}})联调阶段我建议把前端的接口地址写成环境变量,不要写死。比如Vite项目里在.env.development中配置:
VITE_API_BASE_URL = 'http://localhost:5000'这样部署到生产环境时只需要另一份.env.production文件,改一下地址就行,代码不用动。
5. 模型训练与识别效果优化
5.1 车牌检测模型训练流程
用YOLOv8训练车牌检测模型,步骤本身不复杂,但有几个关键点值得展开说。
数据准备是决定模型效果的第一道关。车牌检测数据集,要么自己标注,要么找开源数据集。自建数据集时要注意车牌类别的均衡性——蓝牌、绿牌、黄牌的比例要符合实际场景,如果一个停车场新能源车比例高,绿牌样本不足的话识别效果会很差。
我自己训练时用的是从停车场监控视频里抽帧标注的数据,大约三千张。标注工具用LabelImg或者LabelStudio,格式转成YOLO需要的txt格式,每行是class_id x_center y_center width height,坐标要归一化到0到1之间。
训练命令:
yolo detect train data=plate.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=16plate.yaml里指定训练集和验证集路径,以及类别名称。我只有一个类别plate,所以在文件里写:
train: ./datasets/plate/train/images val: ./datasets/plate/val/images nc: 1 names: ['plate']100个epoch在小数据集上大概十几分钟就能跑完(GPU环境),CPU会慢很多。初学者最容易犯的错误是训练集和验证集有重叠——从同一个视频连续帧里抽出来的图片,内容太相似,验证集准确率虚高,实际到现场换个场景就露馅。抽帧的时候要隔一定时间间隔,确保画面有变化。
训练完之后导出模型:
yolo export model=runs/detect/train/weights/best.pt format=onnx导出ONNX是为了部署方便,可以在没有PyTorch环境的生产服务器上用ONNX Runtime跑推理,模型体积更小,速度更快。这个环节的效果优化很关键。
5.2 识别效果优化实操
在实际场景里识别效果不好,大部分问题出在图像质量,而不是模型本身。我总结了一套排查顺序:
先看车牌在画面里的像素宽度。车牌字符要能看清,车牌区域在图片里至少要占到40到60像素以上的宽度。如果摄像头安装位置太远,车牌太小,再强的模型也白搭。解决方式是调整摄像头角度或焦距,让车牌在画面中间偏下的位置、占比尽量大。这是成本最低但收益最明显的优化。
再看光线条件。早晚逆光、夜间灯光不足、车牌反光,都会导致识别率骤降。我做了两步处理:一是图像送入模型前做自适应直方图均衡化(CLAHE),增强局部对比度;二是在界面上提供补光控制。识别代码里加这行预处理就行:
def preprocess_for_recognition(image): # 转换为LAB色彩空间,对L通道做自适应直方图均衡化 lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8)) l_eq = clahe.apply(l) return cv2.cvtColor(cv2.merge([l_eq, a, b]), cv2.COLOR_LAB2BGR)最后才是调模型参数。如果模型本身在测试集上准确率已经很高,但现场不行,先回头检查图像质量,不要一上来就重新训练。
5.3 识别性能优化
识别速度决定了道闸体验。整个识别链路包括:图片上传(如果摄像头在浏览器端)、预处理、检测、字符识别几个环节。我的实测数据供参考:
- YOLOv8n检测单张图片CPU推理约80到150ms
- PaddleOCR整图识别CPU推理约200到400ms
- 加上图像传输和接口开销,单次识别总耗时约1秒左右
1秒的延迟对停车场道闸来说勉强能用,但如果要更快,有两个方向:第一,把检测模型转成TensorRT或者OpenVINO格式,GPU或Intel芯片上推理速度能提升3到5倍;第二,PaddleOCR识别前先把车牌区域裁剪出来,只对小图做OCR,速度能快很多——因为OCR整个页面的耗时和图像面积正相关,车牌区域往往只占全图的百分之几。
实际部署时还可以做一个优化:入场和出场摄像机位固定,画面区域基本不变,可以在配置里设定一个识别区域(ROI),只在ROI范围内做检测,跳过了大量背景区域的运算。
6. 系统部署方案与权限设计
6.1 用户登录与角色权限
停车场管理系统不能谁都能操作,必须有登录和权限控制。我用Flask-JWT-Extended做JWT令牌认证,前端登录后把Token存在本地,每次请求带着Token过去。
角色我分了三种:
| 角色 | 权限范围 |
|---|---|
| 管理员 | 全部权限,包括费率设置、用户管理、手动进出场 |
| 收费员 | 进出场操作、收费记录查询 |
| 访客 | 仅查看数据看板信息 |
权限控制的实现方式是写一个装饰器,在需要权限的接口上声明角色,校验当前用户的角色是否在允许列表里。
from functools import wraps from flask_jwt_extended import verify_jwt_in_request, get_jwt def role_required(*roles): def decorator(fn): @wraps(fn) def wrapper(*args, **kwargs): verify_jwt_in_request() claims = get_jwt() if claims.get('role') not in roles: return jsonify({'code': 403, 'msg': '权限不足'}), 403 return fn(*args, **kwargs) return wrapper return decorator @app.route('/api/fee-setting', methods=['POST']) @role_required('admin') def update_fee_setting(): # 只有管理员可以修改费率 pass前端路由守卫也要做对应控制,router.beforeEach里根据用户角色判断能不能进入某个页面。后端权限是安全底线,前端路由守卫只是体验优化,不能只做前端不做后端。
6.2 打包与部署
前端项目构建:
npm run build构建产物在dist/目录,里面是打包好的静态文件。部署方式有两种:一种是直接用Nginx托管静态文件,再把/api路径反向代理到Flask服务;另一种是把dist目录放到Flask的静态目录里,Flask同时承担静态文件和API服务。
我推荐用Nginx反向代理,原因是生产环境Flask自带的开发服务器性能很差,并发稍微上来就顶不住,Nginx能用更少的资源处理更多静态请求,而且代理配置很简单:
server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/parking-frontend/dist; index index.html; # 前端路由history模式,找不到文件时回退到index.html location / { try_files $uri $uri/ /index.html; } # API反向代理到后端的Gunicorn location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }后端用Gunicorn启动,多worker提升并发处理能力:
gunicorn -w 4 -b 127.0.0.1:8000 app:app别忘了HTTPS。没有证书就在Nginx里配置免费证书,用certbot自动申请和续期,之前已经说过,摄像头的getUserMedia在非HTTPS环境下根本调不起来,这一步是硬性要求,不是可选项。
7. 常见问题排查与踩坑实录
7.1 摄像头调用报错排查
摄像头问题在开发阶段出现频率最高,我整理了一张排查表:
| 错误现象 | 可能原因 | 解决办法 |
|---|---|---|
NotAllowedError | 非HTTPS环境或用户拒绝授权 | 部署HTTPS;检查浏览器权限设置 |
NotFoundError | 设备没有摄像头或驱动异常 | 检查设备管理器,换浏览器测试 |
NotReadableError | 摄像头被其他程序占用 | 关闭其他调用摄像头的应用,比如会议软件 |
| 视频黑屏但没报错 | playsinline属性缺失(移动端) | video标签加上playsinline |
| 视频画面卡顿 | 分辨率设置过高或带宽不足 | 降低分辨率到640x480 |
| 镜像画面(自拍感) | 前置摄像头默认镜像 | CSS加transform: scaleX(-1) |
其中摄像头被占用这个坑特别隐蔽。我调试过程中遇到过明明设备没问题但浏览器一直报NotReadableError,查了半天发现是后台挂着一个视频会议应用占用了摄像头。遇到这类问题先把无关程序关掉再测试。
7.2 识别结果不稳定的排查思路
识别不稳定分为几种典型表现,每个的排查方向不一样:
**同一个车牌有时候识别对有时候识别错。**这种情况大概率是图像质量波动,比如光照变化、车辆位置不完全一致。先把同一辆车的多次抓拍图片保存下来,对比看光线和车牌角度差异,再决定是加预处理还是调整摄像头角度。
**蓝牌识别好,绿牌经常漏检。**数据不均衡问题。训练集里蓝牌样本远多于绿牌,模型对绿牌特征学习不够。补充绿牌样本重新训练,或者单独针对绿色车牌调HSV阈值做辅助检测。
**识别结果里汉字总是错。**汉字笔画复杂,在模糊图片里最容易混淆。而且汉字类别多,相似字多,像“鲁”和“鱼”、“京”和“就”这种。可以增加汉字识别分支的置信度阈值,或者在业务上对省简称做白名单约束——国内车牌省份汉字就三十几个,识别结果不在白名单里的直接重试或转人工。
**夜间识别率明显下降。**补光不足是第一原因,其次是没有做夜间图像增强。我实际的做法是抓拍图片后先判断整体亮度,低于阈值时自动做亮度提升和去噪,再送识别链路。
7.3 数据库并发与数据一致性问题
如果系统只需要单机运行,SQLite足够了。但如果要支持多台电脑同时操作,SQLite并发写入的问题会非常明显,典型的报错是database is locked。
我把数据库切换到了MySQL之后,还把连接方式改成了连接池,避免每次请求都重新建立数据库连接。
数据库层面的一个关键一致性问题:同一辆车出场时如果同时有两个请求,可能会导致重复更新或者出场记录错乱。解决办法是在出场逻辑里加事务和行级锁,或者直接用SELECT ... FOR UPDATE锁定记录,处理完再提交事务。
7.4 计费计算边界情况
计费看似简单,但我测试的时候发现了好几个边界case:
- 入场时间和出场时间相同(停车不满一分钟),费用按0还是按最低收费算?
- 跨零点停车的封顶逻辑,是按自然日还是按停车周期?
- 免费时长恰好等于停车时长时,free字段怎么算?
- 手动补录入场的车辆,计费起始时间以什么为准?
这些问题在写代码之前就要跟需求方确认清楚,不然上线之后每一单都可能是错的。费率的调整也要记录操作日志,停车场投诉费用问题时能追溯是谁改的、什么时候改的。
8. 一套能给后来者省时间的避坑清单
把所有做过的坑总结成清单,按优先级排列:
- **接口文档先于代码写。**前后端各写各的,联调时接口路径、字段名不一致,浪费的时间远超写文档的时间。
- **车牌数据一定要做正则校验再入库。**不要相信识别结果百分之百准确,脏数据进库之后再想清理,成本高得多。
- **图片文件按日期分目录存储。**不要把所有抓拍图片都堆在一个目录里,时间久了文件数量上百万,列出目录都卡半天,备份也麻烦。
- **识别接口要记录识别日志。**包括上传时间、识别耗时、置信度、识别结果、最终入库结果。出现纠纷时有据可查,优化效果时也有数据支撑。
- **摄像头分辨率别一上来就拉满。**1920x1080的视频流传输和处理都很吃力,识别场景720p完全够用,性能和效果平衡更好。
- **部署一定要用HTTPS。**不止是摄像头调用的问题,整个系统的传输数据包含车牌号、费用记录这些隐私信息,明文传输不应该出现在正式系统里。
- **模型文件要固定版本。**模型升级之后洗一批新测试数据验证效果再换上去,不要每次训练完就往线上推,模型表现差异和样本分布有关,你以为的“优化”可能只是换了一种错误的方式。
- **提前准备离线模型包。**PaddleOCR、YOLO首次运行都要下载模型文件,生产服务器如果无法连外网,得提前把模型文件拷贝进去,不然服务起不来。
这个项目最大的价值不只是把车牌识别跑通,而是把“图像识别”和“业务系统”这两个本来相差很远的领域完整地串在了一起。做完之后我的体会是:算法准只是基础,业务闭环和数据管理才是真正让一个demo变成能用的系统的地方。对着需求把异常流程、权限控制、计费规则这些硬骨头一块块啃下来,比单纯调一个高精度的模型更花时间,但也更有成就感。
如果你以后也要做类似的项目,建议先花一天时间把业务流程图梳理清楚——入场、出场、异常补录、月卡处理、计费规则,每条分支都确认过再动手写代码。代码可以重写,业务逻辑理不清才是真的灾难。