☰
基于Django的车牌识别智能停车场收费系统设计与实现
2026/10/27 16:12:29 网站建设 项目流程

简介:一套基于Python与Django的智能停车场收费系统实现方案,面向计算机专业毕业设计及停车场管理开发者。系统覆盖车辆进出登记、费用自动计算、数据统计分析、车牌识别等核心流程,技术深度适中,可作为课程设计或工程参考。资源包共235个文件,总大小3.82MB,主要包括25个Python源码文件、15个HTML页面、15个JavaScript脚本、10个CSS样式,以及SQL数据库脚本、图片素材和项目备份文件,代码与素材分类清晰,便于按模块查阅。压缩包中附有完整的数据库设计脚本,支持规范化存储与高效检索;车牌识别模块基于图像处理算法实现,具备较好识别准确率。整体项目已在本地环境编译验证,技术评审评分超过95分,说明架构与实现具有较高可信度。学习者可通过研读源码掌握Django Web开发、数据库建模、图像识别技术在真实业务中的整合方法。目前已有58人学习使用,适合需要快速搭建同类系统的人群参考。

1. 智能停车场收费系统:把车牌识别和Django连起来能省多少事

上个月帮朋友改一个小型停车场的收费系统,原来用的是单个Python脚本加SQLite,摄像头拍下照片后人工核对车牌,再手输金额。问题很明显:车一多就容易漏计,出场时找不到入场记录,月底对账全靠Excel。换用Python与Django重新实现后,车牌识别结果直接入库,车辆进出自动匹配,费用按费率表实时计算,整个流程从原来的人工干预变成了半自动。这篇笔记就把这套基于Django的智能停车场收费系统拆开讲,从数据模型、车牌识别接入、计费逻辑到部署验证,尽量写出能照着复现的细节。

如果你是正在做停车场收费系统毕业设计的学生,或者想给小型停车场做一套能跑起来的Django管理后台,这个方案应该能省下不少踩坑时间。项目本身不算复杂,但车牌识别、计费规则和并发处理这几个点,刚好是Python开发者容易忽略的地方。

2. 数据模型先行:车牌、订单、费率的三表设计与索引实践

2.1 为什么停车场数据不能用一张表存

很多新手拿到“停车场收费系统”这个需求,第一反应是建一张进出表,字段里写车牌号、入场时间、出场时间、费用。看起来没问题,跑起来就会翻车:同一个车牌可以多次进出,如果只存一条记录,第二次入场时旧记录就被覆盖了;费率调整时也要改动历史数据。所以我把数据拆成几个核心表:车辆表(或者直接用唯一车牌)、进出场记录表、费率规则表,再加一张订单表存最终结算结果。

车辆表只保存车牌和车辆类型,进出场记录表保存每一次入场的识别图片和入场时间,出场时更新出场时间和应缴金额。订单表在出场结算时生成,记录车牌、入场时间、出场时间、总时长、金额、支付状态。费率表单独存,方便后台改价格而不影响历史订单。

用Django的模型类表达,自然就是三个关联的Model。这样设计的好处是:查询某辆车的历史进出记录非常快,改费率只影响后续新订单,对账时按订单表汇总即可。

2.2 Django模型定义与迁移命令

直接写代码。下面是我常用的模型定义,省去了部分校验代码,保留核心字段。

# parking/models.py from django.db import models class Vehicle(models.Model): plate = models.CharField(max_length=10, unique=True) # 车牌号,唯一索引 vehicle_type = models.CharField(max_length=20, default='car') # 小型车/大型车 class Rate(models.Model): name = models.CharField(max_length=50, default='标准费率') free_minutes = models.IntegerField(default=15) # 免费时长(分钟) base_price = models.DecimalField(max_digits=6, decimal_places=2, default=5.0) # 起步价 base_minutes = models.IntegerField(default=60) # 起步时长(分钟) unit_price = models.DecimalField(max_digits=6, decimal_places=2, default=2.0) # 超时每单位价格 unit_minutes = models.IntegerField(default=30) # 超时计费单位(分钟) class ParkingRecord(models.Model): plate = models.ForeignKey(Vehicle, on_delete=models.CASCADE) entry_time = models.DateTimeField(auto_now_add=True) entry_image = models.ImageField(upload_to='entry_images/') # 入场照片 exit_time = models.DateTimeField(null=True, blank=True) exit_image = models.ImageField(upload_to='exit_images/', null=True, blank=True) amount = models.DecimalField(max_digits=8, decimal_places=2, default=0) status = models.CharField(max_length=10, default='parking') # parking/paid

这段代码里,plate在Vehicle里设了唯一索引,避免同一车牌重复建档;ParkingRecord用外键关联车牌,而不是直接存字符串——以后如果要在车牌上挂其他属性(比如月租车标记)就不用改记录表。Rate表单独存放费率,免费时长、起步价、单价都分开,改起来比硬编码在视图里干净得多。

模型定义完之后执行迁移:

python manage.py makemigrations parking python manage.py migrate

这里有个习惯:每次新增字段或索引,都用makemigrations --dry-run先看一眼要生成的SQL,确认没有误删数据。

2.3 索引选择:车牌号、入场时间与状态字段怎么建索引

Django会为外键自动建索引,但ParkingRecord.status这类查询频繁的字段不会自动加。出场时最常见的一条查询是:找一个车牌当前未结算的记录。

# views.py rec = ParkingRecord.objects.filter(plate__plate=plate, status='parking').order_by('-entry_time').first()

如果停车场规模大,status和entry_time组合查询会很慢。我在生产环境里给ParkingRecord加了一个联合索引:

class Meta: indexes = [ models.Index(fields=['status', 'plate', '-entry_time']), ]

注意-entry_time表示倒序索引,Django 3.2 以后支持。加了索引以后,查询当前停车中的记录就从全表扫描变成走索引。经验是:车牌精确匹配用唯一索引,状态字段加普通索引,时间范围查询用联合索引。不要在status字段上单独建索引,区分度太低,没太大用处。

3. 车牌识别链路:OpenCV预处理与tesseract-ocr在Django里的整合

3.1 摄像头抓拍与图片入库的常见做法

停车场道闸摄像头一般输出JPEG图片,通过HTTP接口把图片POST到Django。我在项目中写了一个最简单的上传接口,接收file和plate字段,plate允许为空——识别在前端做,后端只存图。这样哪个环节出问题都好排查。

# parking/views.py from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt @csrf_exempt def upload_image(request): if request.method != 'POST': return JsonResponse({'code': 1, 'msg': 'method not allowed'}) file = request.FILES.get('file') if not file: return JsonResponse({'code': 1, 'msg': 'no file'}) plate = request.POST.get('plate', '') # 保存图片,稍后异步识别 rec = ParkingRecord(entry_image=file, status='parking') if plate: vehicle, _ = Vehicle.objects.get_or_create(plate=plate.upper()) rec.plate = vehicle rec.save() return JsonResponse({'code': 0, 'msg': 'ok', 'record_id': rec.id})

csrf_exempt是因为摄像头没有CSRF token,生产环境建议放到内网,外网接口加签名校验。我先保存图片,再异步识别车牌,这样识别挂了也不影响入场记录。

3.2 识别前预处理:灰度、二值化、边缘检测

车牌照片经常受光线和角度影响,直接扔给OCR识别率很低。我一般先做三步预处理:转灰度、高斯模糊去噪、用Sobel算子或Canny找车牌区域。这一步用OpenCV做,也就是常说的cv2库。

import cv2 import numpy as np def preprocess(image_path): img = cv2.imread(image_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 转灰度 gray = cv2.bilateralFilter(gray, 11, 17, 17) # 双边滤波保留边缘 edged = cv2.Canny(gray, 170, 200) # Canny边缘检测 # 找轮廓,取面积最大的矩形作为候选车牌区域 contours, _ = cv2.findContours(edged, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) contours = sorted(contours, key=cv2.contourArea, reverse=True)[:10] for cnt in contours: approx = cv2.approxPolyDP(cnt, 0.018 * cv2.arcLength(cnt, True), True) if len(approx) == 4: x, y, w, h = cv2.boundingRect(cnt) if 2.0 < w / h < 5.0: # 车牌宽高比大致范围 roi = gray[y:y + h, x:x + w] roi = cv2.resize(roi, (500, 120)) _, roi = cv2.threshold(roi, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) return roi return None

bilateralFilter参数11, 17, 17是我在室内停车场照片上调出来的,室外强光下可以把前两个参数适当地调大。w/h范围限制可以过滤掉很多窗户、反光板之类的干扰矩形。注意OpenCV返回的roi是numpy数组,后续给OCR用。

3.3 调用Tesseract识别车牌并校验格式

预处理之后用pytesseract做OCR。直接识别会出来很多杂字符,需要按车牌格式过滤。国内蓝牌常见格式是省份汉字+字母+数字,总共7位或8位。Tesseract对汉字的识别要额外加载语言包,这对英文数字没问题,但汉字容易错。我一般只取英文数字,再跟入场登记的车牌做模糊匹配。

import re import pytesseract def recognize_plate(roi): text = pytesseract.image_to_string(roi, config='--psm 7 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') text = re.sub(r'[^A-Z0-9]', '', text.upper()) if 5 <= len(text) <= 8: # 去掉省市汉字后的常规长度 return text return ''

--psm 7表示将结果当作单行文本处理,对车牌这种一行字符最合适。whitelist限定只识别字母数字,避免把边框识别成竖线。识别出来的字符串没有省份汉字,所以对外展示时要结合车辆表里存的完整车牌。我这个项目里识别结果只用于匹配,最终入场记录里还是以第一次识别并经过管理员确认的车牌为准。

4. 计费与业务状态机:免费时长、跨日计费和补缴逻辑的实现

4.1 费率模型与计费公式

停车费计算不是简单的“每小时X元”,至少要考虑免费时长、起步价、超时单价,还有大型车和小型车的系数。我用的费率表模拟线下停车场常见的阶梯计费:15分钟免费,1小时内收5元,超过1小时每30分钟加2元,不满30分钟按30分钟算。

# billing.py from datetime import timedelta def calc_amount(rate, entry_time, exit_time): if exit_time <= entry_time: return 0 diff = exit_time - entry_time total_minutes = int(diff.total_seconds() // 60) if total_minutes <= rate.free_minutes: return 0 billable_minutes = total_minutes - rate.free_minutes if billable_minutes <= rate.base_minutes: amount = rate.base_price else: extra_minutes = billable_minutes - rate.base_minutes # 每 unit_minutes 为一个计费单位,向上取整 units = (extra_minutes + rate.unit_minutes - 1) // rate.unit_minutes amount = rate.base_price + units * rate.unit_price return amount

这里有两个细节值得注意:total_minutes用整型分钟,不要直接拿浮点小时计算,否则会有精度问题;units的向上取整写法是(extra_minutes + unit_minutes - 1) // unit_minutes,很多初学Python的人会写成math.ceil,但整数运算写在一起更直观。

4.2 进场、出场、结算的业务状态转换

停车记录有几种状态:parking表示场内,paid表示已经结算。出场时先查未结算记录,再计算金额,然后生成订单。状态机虽然简单,但必须保证“同一辆车同一时间不能有两条parking记录”。我在模型里加了unique_together约束,但不适用于逻辑删除的情况。更稳的做法是在数据库上加条件唯一索引,但Django不支持带条件的唯一索引,所以我在视图里用事务和行锁来处理。

from django.db import transaction from .models import ParkingRecord, Order @transaction.atomic def exit_vehicle(request): plate = request.POST.get('plate', '').upper() with transaction.atomic(): # 锁住处于parking状态的记录,防止并发重复出场 rec = (ParkingRecord.objects.select_for_update() .filter(plate__plate=plate, status='parking') .order_by('-entry_time').first()) if not rec: return JsonResponse({'code': 1, 'msg': 'no parking record'}) rec.exit_time = request.POST.get('exit_time') rec.exit_image = request.FILES.get('exit_image') rec.amount = calc_amount(rate, rec.entry_time, rec.exit_time) rec.status = 'paid' rec.save() return JsonResponse({'code': 0, 'amount': str(rec.amount)})

select_for_update()在事务里会对选中的行加锁,两个请求同时出场时,后一个会等前一个提交。对于高峰期双车道出口这个并发级别够用了。

4.3 跨日停车与免费时长的边界处理

跨日停车本质上就是时间差计算,用datetime相减没问题。但有一个坑:如果停车超过几天,免费时长只算一次,不是每天送15分钟。我见过有人把每天免费时长累加进总额,结果停车3天免费了45分钟。正确做法是只把入场的第一个时间段减去免费时长。另外,夜间费率如果有不同价格,需要按时间段分段计算。我通常把费率表拆成多个时段条目,每条包含start_time和end_time,跨日时先按天切分,再分别计算每一段的费用。

def calc_daily_segments(entry_time, exit_time): segments = [] current = entry_time day_end = (current + timedelta(days=1)).replace(hour=0, minute=0, second=0) while current < exit_time: segment_end = min(day_end, exit_time) segments.append((current, segment_end)) current = segment_end day_end = current + timedelta(days=1) day_end = day_end.replace(hour=0, minute=0, second=0) return segments

这个函数把停车时长按自然日切片,在每个片段内再套用时段费率。边界条件要特别注意replace(hour=0)后的day_end如果恰好等于exit_time,会多切出一个空段,我在循环里加了current < exit_time判断。

5. 避坑排查:并发重复订单、图片识别错乱和数据回滚的七个案例

5.1 同一个停车记录被重复结算

现象:出场时摄像头拍了两次,或者前端按钮点了两下,同一辆车生成了两笔订单。

原因:两次请求都查询到了status='parking'的记录,都算完金额后更新状态,最后都生成订单。

解决:使用select_for_update()锁行,并在订单表对parking_record加外键。如果已经发生,手工把后一条订状态改为“作废”,并保留退款信息。从那以后我在所有结算接口都强制开启事务和行锁。

5.2 车牌识别把0认成O、把1认成I

现象:入场时车牌被识别成“皖A0D123”,实际是“皖AOD123”,出场时识别的是正确OCR结果,匹配不上。

原因:OCR对字母和数字的混淆率本来就高,尤其在低分辨率图片下。

解决:在匹配时不要求完全相等,先做字符替换表,把O->0、I->1、Z->2等做归一化。出场时先在归一化后的集合里找记录,找不到就返回人工处理队列。我一般不会自动改数据库车牌,而是把“疑似一样”的结果列给管理员确认。

5.3 免费时长导致金额为负

现象:停车5分钟,出场时算出来金额是负的。

原因:我在某次改动中把billable_minutes算成了total_minutes - rate.free_minutes,然后判断billable_minutes <= rate.base_minutes就返回基础价格,结果忘了先把billable_minutes归零。

解决:在计算开始前加一句if total_minutes <= rate.free_minutes: return 0。这条判断必须在所有计费逻辑之前,不能只靠后续分支兜底。现在每个发版我都用下面的测试用例跑一遍:

assert calc_amount(rate, entry, entry + timedelta(minutes=10)) == 0

5.4 并发入场时Django ORM的update丢失

现象:两辆车同时入场,都往ParkingRecord表插入记录,结果发现有一条记录的外键plate是空的。

原因:代码里用了get_or_create,但在两个线程里同时查到了同一个Vehicle不存在,然后都执行create,其中一个会收到IntegrityError,代码没捕获,导致外部记录保存时用了空对象。

解决:不要依赖get_or_create在高并发下保证唯一,先捕获IntegrityError再重试查询。或者直接create(plate=vehicle)而不经过get_or_create,让数据库唯一索引来兜底。

5.5 图片保存到Django的SITE_ROOT之外

现象:上传的入场图片在开发环境能用,用runserver访问图片404。

原因:MEDIA_ROOT设置不正确,或者没有在urls.py里配static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)。

解决:检查settings里:

import os BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') MEDIA_URL = '/media/'

这是个低频坑,但每个用Django做图片上传的新手都踩过。我在清单里强制加上这一条。

5.6 后台任务线程用了过期的数据库连接

现象:用threading.Thread做异步OCR识别,结果数据库查询报lost connection to MySQL server。

原因:Django的数据库连接是和请求线程绑定的,自定义线程里第一次用的时候才会新建连接。如果那个线程在请求结束之后还活着,连接会被池回收,再次查询就报错。

解决:在线程函数开头调用django.db.close_old_connections()。或者干脆用Celery,本地小项目用上面这句就够。

5.7 事务里混入非原子HTTP请求

现象:出场结算时,先更新记录,再调用某个第三方支付接口,结果支付接口超时返回,本地订单状态已经变成paid。

原因:transaction.atomic()只控制数据库事务,不控制外部API调用。代码里把HTTP请求放进了原子块,但HTTP请求不会随事务回滚。

解决:先把数据库状态改成closing之类的中间态,提交事务;再调用支付;支付成功后再更新为paid。或者用apply_async异步支付回调。从那以后我写结算接口都养成一个习惯:任何外部IO都放在事务之外。

6. 部署与验证:从Django开发服务器到Nginx反向代理的完整落点

6.1 用本地模拟相机图片验证全流程

不接真实摄像头也能验证整套系统。写一个本地脚本模拟相机抓拍,往Django接口传图,再模拟出场时的识别结果。

# simulate_camera.py import requests url = 'http://127.0.0.1:8000/parking/upload/' with open('car.jpg', 'rb') as f: resp = requests.post(url, files={'file': f}, data={'plate': '皖A12345'}) print(resp.json()) # 模拟出场 out_url = 'http://127.0.0.1:8000/parking/exit/' with open('car_exit.jpg', 'rb') as f: resp = requests.post(out_url, files={'exit_image': f}, data={'plate': '皖A12345'}) print(resp.json())

用一张清晰的车牌照片跑通,然后换逆光、模糊、倾斜的照片测识别率。我在本地预留了20张测试图,每次改识别逻辑都用这20张回归一遍。

6.2 部署检查清单

我把常见问题列成清单:DEBUG关闭、ALLOWED_HOSTS配置、STATIC_ROOT收集、MEDIA_ROOT权限、用Gunicorn启动、Nginx转发动态请求和静态文件。

python manage.py collectstatic --noinput

Nginx配置里关键一点是上传文件大小限制,摄像头图片一般不到1MB,但有的旧设备会拍出2-3MB的图。

client_max_body_size 10m;

部署完成后打开管理后台,任意创建一个测试车位,走一遍进场和出场流程。重点观察后台的Log entry里有没有IntegrityError或超时记录。

我从这套项目里总结出的最大教训是:车牌识别并不难,难的是让整条链路在并发和极端输入下不丢数据。从那以后我每次改完计费或状态转换代码,都会先跑一遍模拟并发压测——不敢说系统完美,至少再也没出现过重复订单或金额为负。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询