简介:面向计算机相关专业毕业设计及Django实战学习者,这份资源实现了基于人脸识别的景区票务系统,涵盖在线购票、人脸验票、票务统计与后台管理等完整业务流程,能帮助学生快速落地一个融合AI与Web开发的综合项目。压缩包大小117.81MB,主要包含Django前后端源码、MySQL数据库脚本、详细说明文档、学习指南(LW)以及答辩演示PPT,文件类型覆盖py、html、sql、docx与pptx等,便于从源码研读到文档参考。目前已有69人学习下载,资源整体结构清晰,适合作为毕业设计选题参考或项目实践素材。说明文档对系统架构、功能模块和操作流程做了系统梳理,学习指南则还原了从设计到编码的完整过程,配合PPT可直观展示项目亮点,能帮助读者快速理解并二次开发。
1. 景区票务系统把“人脸识别”放在哪一环,才算没选错
景区票务和普通电商票务最大的差别在于“入场核销”这个动作。线上买票、线下排队换票、闸机扫码,这条链路里最容易出问题的不是支付,而是换票排队和二次入园的身份核验。人脸识别票务系统要拆解的,不是“用摄像头认人”这个单一功能,而是把购票、绑定人脸、检票比对、订单存储串成一条完整的数据流。
一个典型的人脸票务系统由四层组成:Django 负责业务逻辑和接口,MySQL 存订单和人脸特征数据,HTML 模板加少量 JavaScript 处理页面交互,人脸识别的核心算法则是个独立的服务模块,通过 API 被 Django 调用。标题里“基于 Python 的 Django-html”强调的是完整前后端,而“人脸识别”则是业务上的差异化功能,它并不是系统的主线,而是嵌入到“注册、检票、记录”这条流程里的一个能力组件。
所以看这套源码(或要自己复刻一套)之前,先想清楚一个问题:人脸识别不是给景区省摄像头的,是为了把“人、票、场次、订单”绑定在一起,让闸机前的比对结果能作为唯一放行依据。下面从最落地的一条主线讲起来:人脸怎么在 Django 里被识别进业务逻辑,票务数据怎么建模,页面怎么跟摄像头交互,最后这套东西怎么才不会在一台普通 Windows 或 Linux 机器上跑崩。
2. 在 Django 里接入人脸识别,先定好“比对服务”而不是直接跑算法
2.1 为什么不能把模型调用硬塞进视图函数
常见毕业设计里把人脸识别写在views.py里,请求来了就加载模型、做特征提取、再比对返回。这套写法在小并发下能跑通,但有两个硬伤:第一,模型加载和预处理非常耗时,每次请求都重新加载会导致明显的卡顿;第二,Django 的同步视图会被阻塞,闸机入口同时来了几个请求,进程就假死了。更稳妥的做法是把人脸识别能力封装成一个独立的服务模块,进程启动时加载一次模型,后续通过函数调用复用。
人脸识别在票务场景里只需要两个能力:注册时录入人脸照片,提取特征向量;检票时拍一张照片,提取特征向量并与该订单绑定的人脸做相似度比对。特征向量的提取可以用常见的人脸识别算法,比如基于深度学习的人脸特征提取模型,输出一个 128 维或 512 维的浮点数组。MySQL 里不需要存图片的二进制大文件,存特征向量字符串和缩略图路径就够了。
一个可复用的比对模块大致划分为加载模型、提取特征、计算相似度三个步骤。提取特征的函数设计成接收图片路径或bytes,返回归一化后的特征向量;比对函数接收两个向量,调用余弦相似度或欧氏距离得出得分,然后由业务层决定阈值。
# recognition.py import numpy as np import cv2 class FaceService: """封装特征提取与人脸比对,进程内复用模型""" _instance = None def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.model = _load_feature_model() cls._instance.threshold = 0.65 # 相似度阈值,可调 return cls._instance def extract_feature(self, image) -> np.ndarray: """输入 BGR 图像,返回 L2 归一化特征向量""" face = _align_face(image) # 人脸检测 + 对齐 if face is None: raise ValueError("no face detected") feat = self.model.predict(face) # 形状为 (1, 512) 之类 feat = feat / np.linalg.norm(feat) return feat.reshape(-1) def compare(self, feat1, feat2) -> float: return float(np.dot(feat1, feat2)) # 归一化后余弦相似度 _face_service = FaceService()模型加载用单例模式保证进程里只加载一次。_align_face负责从原始画面里定位人脸并按模型要求裁剪缩放,常见实现是 OpenCV 的 Haar 级联或更稳的 MTCNN。相似度阈值决定了放行严格程度,景区闸机这类场景 0.6 到 0.7 比较合适,低于 0.5 会导致误识率升高,而高于 0.75 又容易因为发型、口罩、光线造成误拒。
2.2 Django 视图与识别服务之间加一层“特征串”转换
Django 的视图函数拿到上传图片后,调用FaceService.extract_feature,得到的是numpy.ndarray,不能直接存 MySQL。需要把浮点数组序列化成定长字符串。常见做法是用base64编码float32的tobytes()结果,字段类型设为TEXT或BLOB;更省空间的做法是直接存为逗号分隔的浮点数字符串,但解析效率低一些。这里推荐二进制转 base64 的方式,长度可控且方便还原。
import base64 import numpy as np def serialize_feature(feat: np.ndarray) -> str: data = feat.astype(np.float32).tobytes() return base64.b64encode(data).decode("ascii") def deserialize_feature(s: str) -> np.ndarray: raw = base64.b64decode(s.encode("ascii")) return np.frombuffer(raw, dtype=np.float32)serialize_feature用在注册接口,deserialize_feature用在检票比对时读取库里的底库特征。之所以强调这层转换,是因为很多人在 MySQL 里直接保存numpy数组的对象字符串,不仅乱码而且换 Python 版本后读取困难。特征序列化后,检票接口的逻辑就变成:先取订单绑定的特征串,解析为向量,再与刚拍到的照片提取的向量做点积,超过阈值即放行。
2.3 人脸注册与检票接口的 Django 视图实现
注册接口接收图片文件和订单号,核心操作是检测人脸是否存在、提取特征、更新订单字段。检票接口接收图片和订单号,做比对。这两个接口都属于写操作密集场景,不需要实时返回大量数据,JSON 响应足够。
# views.py import json from django.views.decorators.http import require_POST from django.http import JsonResponse from recognition import FaceService, serialize_feature, deserialize_feature from .models import Order face_service = FaceService() @require_POST def register_face(request, order_no): img_file = request.FILES.get("face_image") if not img_file: return JsonResponse({"code": 1, "msg": "face_image required"}) try: image = decode_image(img_file.read()) feat = face_service.extract_feature(image) except ValueError: return JsonResponse({"code": 2, "msg": "no face detected"}) order = Order.objects.select_for_update().get(order_no=order_no) order.face_feature = serialize_feature(feat) order.face_status = 1 order.save(update_fields=["face_feature", "face_status"]) return JsonResponse({"code": 0, "msg": "ok"}) @require_POST def check_face(request, order_no): img_file = request.FILES.get("face_image") try: feat = face_service.extract_feature(decode_image(img_file.read())) order = Order.objects.get(order_no=order_no) except Order.DoesNotExist: return JsonResponse({"code": 3, "msg": "order not found"}) except ValueError: return JsonResponse({"code": 2, "msg": "no face detected"}) ref = deserialize_feature(order.face_feature) score = face_service.compare(feat, ref) if score >= face_service.threshold: return JsonResponse({"code": 0, "msg": "pass", "score": score}) return JsonResponse({"code": 4, "msg": "reject", "score": score})select_for_update在注册场景里是为了避免同一订单并发重复写入;检票接口没加锁,因为入场本身有一个状态机来控制,防止并发检票需要配合 MySQL 行锁或 Redis 锁做二次校验,稍后在第 3 章处理。这里的decode_image需要兼容上传的图片格式,cv2.imdecode从文件字节读取时要用np.frombuffer转一次,否则中文路径或内存文件会读不出来。
关键参数threshold最好不要写死在代码里,存入 Django 的settings或数据库配置表,因为不同闸机的补光条件差异很大。我一般会在配置里加一个FACE_THRESHOLD环境变量,部署时调试人员可以直接调整而不需要重新发布代码。
3. 票务系统 MySQL 建模:订单、场次与人脸底库怎么避坑
3.1 从 ER 设计看票务状态流转
票务系统的核心不是人脸表,而是订单表和检票记录表。景区票务场景里常见的票型有成人票、儿童票、联票和场次票,场次票必须绑定时间,联票允许一天内多次入园,这就导致“买到票”和“能入园”不是一回事。数据库模型至少要覆盖四个实体:场次表、订单表、检票记录表、游客人脸信息表。
订单表是主表,关联用票人信息和场次。人脸信息单独拆表而不是直接挂在订单上,是因为联票的一个人可能对应多次入园记录,而且注册人脸失败时不需要删除整张订单。两个模型之间用一对一或一对多关联,一般设计为一张订单对应一个人脸底库记录,但允许多个订单共享一张人脸(比如同一游客买了两日票)。
CREATE TABLE spectacle_session ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, stock INT NOT NULL DEFAULT 0, UNIQUE KEY uk_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE visitor_order ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, session_id INT UNSIGNED NOT NULL, visitor_name VARCHAR(64) NOT NULL, phone VARCHAR(20), ticket_type TINYINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已入场 3已退款', face_status TINYINT NOT NULL DEFAULT 0 COMMENT '0未注册 1已注册', face_feature TEXT NULL COMMENT 'base64编码的特征向量', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_session_status (session_id, status), KEY idx_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE check_in_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, session_id INT UNSIGNED NOT NULL, device_no VARCHAR(32) NOT NULL, score DECIMAL(5,4) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_time (order_no, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单表里的face_feature和独立的visitor_order表耦合在这里,是因为这套系统的核心是在线购票后绑定人脸,同一个游客通常只有一条人脸特征记录。如果景区是做“散客现场购票立即入园”的模式,则建议把face_feature拆到单独的visitor_face表,原因是现场购票可能存在多人共用一部手机提交,事务粒度不同。
3.2 防止重复检票的行锁与状态机
闸机场景最大的并发风险是同一订单同一秒被两个入口同时请求。Django 层做了任何判断都存在时间窗口,正确做法是用数据库行锁,在检票接口里先锁住订单行再判断状态。
from django.db import transaction @transaction.atomic def check_in(order_no, device_no, score): order = Order.objects.select_for_update().get(order_no=order_no) if order.status >= 2: return False, "already checked" if order.face_status != 1: return False, "face not registered" order.status = 2 order.save(update_fields=["status", "updated_at"]) CheckInLog.objects.create( order_no=order_no, session_id=order.session_id, device_no=device_no, score=score, ) return True, "ok"把状态判断和日志写入放进同一个事务,select_for_update保证两个并发请求到达数据库时后一个会被阻塞,等前一个提交后再读取到最新状态status=2,从而拒绝重复检票。注意status >= 2这个条件涵盖了2已入场和3已退款两种不可检票状态,防止退款后被误放行。
3.3 场次库存在高并发下的扣减方式
景区票务的场次票有库存限制,常见误用是“先查库存,再 UPDATE 扣减”。在高并发下会超卖。正确的做法是把库存扣减放到更新语句的条件里,让数据库自己保证原子性。Django ORM 的写法是使用F表达式。
from django.db.models import F affected = Session.objects.filter( id=session_id, stock__gt=0 ).update(stock=F("stock") - 1) if affected == 1: # 扣减成功,继续创建订单 pass else: raise ValueError("sold out")update返回受影响行数,只有当过滤条件满足stock > 0且更新成功时才是 1,这个操作为原子操作,不需要显式上锁。需要特别注意的是,不要把这个更新放在select_for_update的事务里和订单创建写成一个长事务,否则闸机入口的并发扣减会被不相关的读操作阻塞。更稳妥的方案是把库存操作独立成一个短事务,订单创建另起一个事务,中间通过消息或回调衔接。
关于 MySQL 安装配置,本地开发时常见痛点有两个:一是字符集不是 utf8mb4,导致人脸特征特征字符串里的+和/没影响,但订单备注里的 emoji 存不进去;二是 MySQL 8 默认的caching_sha2_password认证插件在用 PyMySQL 时会报错。Django 连接 MySQL 时推荐使用mysqlclient,若在 Windows 上安装困难,则可以临时用pymysql并在__init__.py里执行pymysql.install_as_MySQLdb(),注意这只适合本地开发,生产环境应解决编译依赖并换回mysqlclient。
4. 前端与 Django 模板协作:摄像头采集、上传和识别结果回显
4.1 为什么选用 Django 模板加原生 JavaScript 而不是前后端分离
标题里明确写了 Django-html,意味着这个项目用的是服务端渲染的 Django 模板,而不是 Vue/React 前后端分离架构。维护这类项目时有一条经验:不要把页面逻辑全部堆到模板里,而是把 Django 模板当作”页面骨架“,交互部分用原生 JavaScript 或轻量 jQuery 实现,Django 只负责输出 JSON 和初始数据。
人脸识别页面有一个特殊要求:需要访问摄像头。浏览器通过navigator.mediaDevices.getUserMedia获取视频流,然后把视频帧绘制到 canvas 上截图为 base64 的 JPEG,再通过 Fetch 或 Ajax 上传到 Django 接口。由于摄像头视频流一直是打开的,上传环节不能简单用表单提交,否则每次识别都要重新申请摄像头权限。正确思路是初始化时申请一次视频流,识别时停止视频轨或保持视频常开、不断截帧。
// face_register.js const video = document.getElementById("video-face"); const canvas = document.getElementById("canvas-snapshot"); const btnCapture = document.getElementById("btn-capture"); async function initCamera() { const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 }, audio: false, }); video.srcObject = stream; await video.play(); } btnCapture.addEventListener("click", function () { const ctx = canvas.getContext("2d"); ctx.drawImage(video, 0, 0, 640, 480); const dataUrl = canvas.toDataURL("image/jpeg", 0.9); // dataUrl 形如 data:image/jpeg;base64,... uploadFace(dataUrl.split(",")[1]); }); async function uploadFace(base64Data) { const blob = base64ToBlob(base64Data, "image/jpeg"); const formData = new FormData(); formData.append("face_image", blob, "capture.jpg"); const resp = await fetch("/api/order/20240101001/face/", { method: "POST", body: formData, }); const result = await resp.json(); if (result.code === 0) { document.getElementById("result").innerText = "注册成功"; } else { document.getElementById("result").innerText = result.msg; } }这里的base64ToBlob是把 base64 解码成二进制对象,否则FormData里 append 的字符串会被 Django 当成文本字段而不是文件。Django 侧的request.FILES需要收到一个真正的文件对象,浏览器端常见的坑是append时缺少文件名参数导致Content-Type解析异常,因此必须加上第三个参数"capture.jpg"。getUserMedia在 HTTP 非 localhost 环境下会被浏览器拦截,本地调试必须用http://127.0.0.1:8000访问,部署到公网则必须上 HTTPS,否则摄像头无法调起。
4.2 服务端渲染下的 CSRF 处理
Django 模板渲染的页面会输出{% csrf_token %},但 Fetch 请求时不能直接读取 Cookie 里的csrftoken并放到 Header 里就算完。常见做法是从模板里取隐藏字段的值,或从 Cookie 读取并设置X-CSRFToken请求头。需要特别注意的是,如果 GET 请求页面后长时间不动,Django 会轮换 CSRF Token,此时 Cookie 里的值可能和页面里的隐藏字段不一致,所以每次提交前以隐藏字段为准。
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; fetch("/api/order/20240101001/face/", { method: "POST", headers: { "X-CSRFToken": csrfToken }, body: formData, });如果前端页面通过window.location或<a>标签跳转到登录页后再次提交,务必重新从新页面获取 Token 并更新。这个细节在“注册人脸后立即检票”的测试流程里出现的频率极高,症状是第一次提交成功,第二次提交报 403。
4.3 识别结果的后台管理与页面回显
管理员端通常会用到 Django admin 来查看订单状态和检票日志。默认的 admin 界面在列表页字段较多时体验不理想,常见调整是设置list_display、list_filter和search_fields。虽然标题里没提 admin 的优先级,但源码包中说明文档和 PPT 通常会演示这部分,所以值得在开发时做好。
# admin.py from django.contrib import admin from .models import Order, Session, CheckInLog @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ("order_no", "visitor_name", "session", "status", "face_status") list_filter = ("status", "face_status", "session") search_fields = ("order_no", "visitor_name", "phone") list_per_page = 20admin 美化只是锦上添花,真正要注意的坑是face_feature字段不要加进list_display,base64 字符串会让页面渲染卡顿,且列表页把一整串字符渲染出来没有业务意义。检票记录表同理,列表只展示订单号、设备和相似度得分,不在列表页展示原始图片或特征串。
4.4 人脸识别算法的选型和精度边界
在景区票务这类可控光照场景下,人脸识别算法选型的核心指标不是公开测试集上的准确率,而是:模型文件大小、CPU 推理耗时、对口罩和眼镜的鲁棒性。常见选择有:基于传统方法的 LBPH 和基于深度学习的 FaceNet/ArcFace 及国产的开源模型。LBPH 在姿态变化稍大时误拒率飙升,只适合闸机这类固定机位、高度统一的场景;深度学习模型在嵌入式设备上需要提前转成开放神经网络交换格式并量化,否则 CPU 推理会产生明显的延迟。
现在可以直接使用一些开源商用模型,比如基于 RetinaFace 检测 + ArcFace 特征提取的组合。这类模型的部署方式比较统一:检测模型负责从整张图片里框出人脸,特征模型负责把框出的人脸编码为向量。两个模型可以串行执行,但要注意摄像头采集的 640x480 图片里人脸区域可能只占很小一部分,直接把整图喂给特征模型会导致特征提取质量大幅下降。正确的做法是先由检测模型裁出人脸区域,再缩放到特征模型要求的输入尺寸,比如 112x112 或 160x160。
def _align_face(image): dets = detector.detect(image) if len(dets) == 0: return None x, y, w, h = dets[0] margin = int(0.2 * w) x1 = max(0, x - margin) y1 = max(0, y - margin) x2 = min(image.shape[1], x + w + margin) y2 = min(image.shape[0], y + h + margin) face = image[y1:y2, x1:x2] return cv2.resize(face, (112, 112))特征提取前的人脸对齐非常关键。很多人把票务识别不准确归因于“模型不行”,其实是把带额头的整张自拍照误当成了人脸特写。裁人脸时多留 20% 边距,有助于保留下巴轮廓和部分背景,特征提取效果比严格裁到脸廓更稳定。
5. 完整项目的最小运行步骤与本地部署排错
5.1 从拿到源码到页面跑通的最小命令序列
拿到一个 Django 项目压缩包,第一步不是急着配置数据库,而是先理清三个东西:项目的requirements.txt、根目录下的manage.py所在位置,以及settings.py里的数据库配置。常见的源码包(尤其是毕设类)会附带一个.sql文件或 MySQL 导出文件,这时手动创建数据库并导入比运行migrate更可靠,因为迁移文件在多人协作时很容易缺漏。
# 1. 创建虚拟环境并激活 python -m venv venv # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate # 2. 安装依赖 pip install -r requirements.txt # 3. 创建数据库并导入 mysql -u root -p -e "CREATE DATABASE scenic_ticket DEFAULT CHARACTER SET utf8mb4;" mysql -u root -p scenic_ticket < scenic_ticket.sql # 4. 修改 settings.py 的 DATABASES 配置 # ENGINE: django.db.backends.mysql # NAME: scenic_ticket # 5. 迁移与启动 python manage.py makemigrations python manage.py migrate python manage.py runserver 0.0.0.0:8000先导入 SQL 再跑migrate的顺序需要注意:如果 SQL 文件里已经建好了全部表,迁移可能检测不到变化;如果 SQL 和迁移文件来自同一版本,则可以只导数据不建表,用migrate --run-syncdb的方式补全。Django 项目常见报错之一是Table 'django_session' doesn't exist,这通常是因为跳过了migrate直接访问了后台管理页面,单独运行python manage.py migrate即可恢复。
5.2 Python、Django、MySQL 版本兼容的常用匹配
版本兼容是毕设源码落地时最容易卡住的部分。Python 版本过高会导致部分依赖没有预编译包,过低则 Django 不支持。当前常见匹配是 Python 3.8~3.11 搭配 Django 3.2~4.2,高于 Django 4.2 的版本已部分移除对 Python 3.7 的支持。如果requirements.txt里的依赖列表很老(比如还是 Django 2.x),建议先按原版本跑通,不要一上来就升级 Django,否则urlpatterns里的re_path和django.conf.urls.url会报错。
MySQL 8.0 与旧版 Django 的兼容问题表现在django.db.backends.mysql默认使用的caching_sha2_password认证插件的握手协议不一致。解决方式有两种:一是创建一个使用mysql_native_password的数据库账号,二是升级到 Django 3.2 以上并安装最新mysqlclient。生产服务器上宝塔面板自带的 MySQL 版本需要同步检查,特别是pymysql在 Python 3.10 以上版本存在一些字节编码转换差异,无法正常读写 emoji 字符。
5.3 人脸识别在部署环境里的性能优化与阈值标定
最后落到上线前的两个步骤:性能优化与阈值标定。人脸识别服务在开发环境跑通不等于在现场能用,在 CPU 机器上每张图片的推理耗时超过 1 秒就会导致闸机口排队。常见优化方式是加一个缓存层,把同一订单同一分钟内的识别结果缓存到进程内存或 Redis,避免二次识别。
import time class RateLimiter: def __init__(self): self._cache = {} def allow(self, key, ttl=3): now = time.time() last = self._cache.get(key, 0) if now - last < ttl: return False self._cache[key] = now return True limiter = RateLimiter()这个进程级限流器只适合单机部署,多进程或多节点时需要换 RedisSETEX原子操作。阈值标定的方法是准备一个包含 20 人左右、每人 5 张不同光线和角度照片的测试集,计算相同人的相似度分布和不同人的相似度分布,取两个分布之间的交界点作为初始阈值。注意不要只测相似度得分的绝对数值,要同时对比当前闸机机位的人脸检测成功率,检测不到人脸的情况下,阈值设得再准也无法放行。
> 提示:在项目根目录创建 `static/face_test/` 目录存放校准样本,用下面的脚本输出分布直方图辅助选阈值,比直接在页面里肉眼判断靠谱得多。 def evaluate_threshold(order_no_list): import numpy as np scores = [] for order_no in order_no_list: # 模拟一张新拍照片,调用提取与比对 s = face_service.compare(feat_new, feat_ref) scores.append(s) return np.mean(scores), np.min(scores), np.max(scores)拍摄照片与注册照片的清晰度差异是误差主要来源。现场拍的照片往往存在运动模糊和反光,特征提取结果会和注册时有偏移。在闸机部署时优先选择固定焦距的摄像头,并让设备后台做自动曝光补偿后再传图,而不是把模糊帧直接送进识别模块。最终把threshold和ttl这两个参数做成可在管理后台动态调整的配置项,上线后根据投诉记录微调,比一次性拍脑袋定值更实际。
本文还有配套的精品资源,点击获取