☰
Python景区票务人脸识别系统:刷脸入园源码与部署实践
2026/10/27 2:40:02 网站建设 项目流程

去年有几个做景区信息化的朋友陆续问我同一个问题:想给票务系统加一套刷脸入园,Python 能不能扛得住,有没有现成的源码可以参考。问的人多了,我干脆把这些年在这类项目上踩过的坑、调过的参数、改过的架构整理成一篇,围绕"基于 Python 的景区票务人脸识别系统"这个主题,从需求拆解一直讲到源码落地,能直接照着搭一套跑起来的东西。这套系统的定位很明确:它不是通用的人脸识别平台,而是嵌在票务业务里的一环,解决的是"票和人对不上"的问题。适合两类人看,一类是想快速做出可演示原型的技术爱好者,一类是真要往景区现场部署、需要提前知道哪些地方会翻车的实施同学。

1. 先把需求钉死在闸机口:这套系统到底在解决什么问题

我见过太多项目一开始就冲着"做人脸识别"去,模型调得很开心,结果到现场发现业务部门要的根本不是这个。景区票务场景里,人脸识别只是手段,业务目标其实只有三个:票证合一、防倒票、提通行效率。这三件事对技术的要求差别非常大,混在一起谈就会做出一个四不像的系统。

1.1 三个真实场景,对应三套完全不同的技术路线

第一种是线上购票、首次入园绑脸。游客在小程序里买票,上传一张自拍或者现场在自助机上拍一张,这张脸和订单绑定。入园时刷身份证或者二维码,系统根据票号取出之前存的人脸特征,和现场抓拍比对。这是标准的1:1 验证,不是 1:N 识别,很多人第一步就理解错了。

第二种是年卡、多次入园。逻辑上和第一种一样,只是票的状态要支持多次核销,得记录剩余次数或者有效区间。技术要求不高,但状态机的坑特别多,后面细讲。

第三种是无感通行,游客什么都不用掏,走到闸机前抬头就过。这才是真正的1:N 检索,要在几万甚至几十万条底库里找最像的那一个。这个方案听着最酷,实际落地成本也最高,误识率随底库规模线性上升,而且一旦误放行,责任很难界定。我个人的建议是,绝大多数景区没有必要上 1:N,双因子(票 + 脸)的体验已经够好,安全性还高一个量级。

现场经验:1:N 方案在底库超过五万人之后,为了把误识率压到可接受范围,阈值必须往上提,提上去之后拒识率又跟着涨,最后变成"该过的过不去、不该过的偶尔过去",游客投诉比人工检票还多。除非有明确的免票人群管理需求,否则别轻易碰。

1.2 一个关键认知:票是凭证,脸是绑定关系

这句话我在需求评审会上说过很多次。系统里真正的主键是票号,人脸特征只是挂在票上的一个属性。核验流程应该是这样的顺序:先读票,票无效直接拦;票有效则取出绑定的特征,再和现场抓拍比对。这个顺序决定了三件事。

第一,比对范围被压缩到 1:1,算力需求断崖式下降。一台四核的工控机满载跑轻量模型,每小时能处理两千多人次,完全够用,不需要 GPU。

第二,阈值可以设得更严格。1:1 场景下你不需要在茫茫人海里找最像的那个,只需要回答"是不是同一个人",所以判定分数可以卡到 0.45 甚至更高,而 1:N 场景往往要放宽到 0.3 左右才能保证召回。

第三,容错路径清晰。票无效就重刷卡,人脸比对不过就走人工通道,每一步都有明确的下一步动作,闸机端的交互逻辑不会变成一团乱麻。

1.3 谁在用这套系统,决定了界面长什么样

自助机上的注册界面和闸机上的核验界面,用户群体完全不同。注册端多是年轻人,愿意配合,可以引导做眨眼、转头这类动作;闸机端是混合客流,有老人、有小孩、有推着婴儿车的、有戴着遮阳帽和墨镜的,交互必须做到"零学习成本"。我见过一个项目在闸机上做了三步引导动画,结果高峰期队伍堵了三十米,第二天就被要求改成"抬头看一眼就行"。

所以整套系统的设计重心是:注册端尽量把质量做高,核验端尽量把门槛做低。注册时多花五秒钟拍一张合格的照片,能省掉闸机口九成的麻烦。这个思路贯穿后面所有的代码设计。

2. Python 侧的方案选型:几条路线的实测对照

Python 在人脸识别这块的生态确实丰富,丰富到容易选错。我把实际项目里用过的几条路线摊开对比一下,都是在一台四核八线程、无独显的工控机上跑出来的数据,不是实验室环境。

2.1 四条技术路线各自的适用边界

路线典型栈单次全流程耗时模型体积离线可用适用场景
传统特征 + 轻量分类器OpenCV LBPH60-120ms几 MB是演示、极小规模、光照可控
dlib 系 128 维特征dlib + 某封装库180-400ms约 100MB是原型验证、样本量小
ArcFace 系 512 维特征ONNX Runtime + 轻量检测器80-200ms20-60MB是生产环境推荐
云端比对接口HTTP 调用200-600ms(含网络)无否无现场算力、对时延不敏感

第一行那条 LBPH 的路线,现在基本只用来做课堂演示。它对光照极度敏感,闸机口那种一半在阴影一半在阳光下的场景,识别率能掉到六成。第二行基于 dlib 的方案上手最快,几行代码就能出结果,缺点是 128 维特征的区分度有限,而且 dlib 的 CNN 检测器在纯 CPU 上跑一张 1080P 的图要两百多毫秒,高峰期会顶不住。

真正适合生产的是第三行。检测用轻量的人脸检测模型(SCRFD 或者 RetinaFace 的移动端版本),对齐之后送进 ArcFace 系列的特征提取网络,输出 512 维向量。整套模型量化成 int8 之后加起来不到 60MB,ONNX Runtime 在 CPU 上单张推理能压到 100ms 以内。

第四行云端接口,我只在一种情况下推荐:景区闸机点位极少、网络条件很好、且不接受本地维护模型。除此之外都不太合适,闸机口断网五分钟就是几百人的拥堵,这个风险不值得冒。

2.2 为什么最终选了 ONNX Runtime 而不是直接调框架

一个很实际的原因:部署环境往往是 Windows 工控机,而且装不了太多东西。PyTorch 的完整包加上 CUDA 依赖动辄两个 G,在一台只有 128G 固态的闸机主机上装完,系统盘就满了。ONNX Runtime 的 CPU 版本压缩包解出来二十多兆,纯 Python 依赖,扔进虚拟环境就能跑。

另一个原因是版本锁定。训练侧用什么框架都行,导出成 ONNX 之后就冻结了,现场不会因为某人升级了个包导致推理结果变化。这一点在做验收的时候特别重要——同一张照片,三个月后重新跑一遍,分数必须一模一样,否则出了问题根本说不清。

还有一个隐性好处:ONNX 模型可以按需裁剪。注册端需要完整的检测 + 对齐 + 特征提取,闸机端其实可以只要检测和特征提取,把一些后处理砍掉,进一步压时间。这套按点位裁剪模型的能力,在框架里做起来要费不少劲。

2.3 检测器和特征模型要分开选

新手常犯的错误是拿一个大而全的模型做完所有事。实际工程里,检测和特征提取的诉求是相反的:检测要在各种角度、遮挡、光照下都能找到人脸框,追求的是召回;特征提取要求输入是正脸、对齐好的,追求的是区分度。

我的组合通常是:轻量级人脸检测器负责出框和五个关键点,用关键点做仿射变换把人脸摆正、裁到 112×112,再送进特征网络。这个流程听起来多了一步,实际上因为特征网络的输入变小了,总耗时反而比"一张大图直接端到端"更低。仿射变换的参数用标准模板点算,OpenCV 的estimateAffinePartial2D一行就能出结果。

3. 数据模型与状态流转:底库设计错了后面全白搭

我见过最难收拾的一个项目,是特征向量直接以 JSON 字符串存在票表的一个字段里。上线三个月,票表涨到两百万行,查询慢得没法看,模型升级想重算特征更是无从下手。数据模型这块值得单独拿出来讲。

3.1 五张表撑起整个系统

不需要太复杂,核心就五张:

表名作用关键字段
visitor游客档案id、证件号哈希、手机号哈希、创建时间
ticket_order订单id、订单号、景区编码、人数、支付状态
ticket票id、票号、订单id、有效期、剩余次数、状态
face_feature人脸特征id、visitor_id、向量二进制、质量分、模型版本
verify_log核验记录id、票号、闸机号、比对分数、结果、抓拍图key、时间

几个设计意图要说明白。

特征向量单独成表,不要塞进票表。一张票对应一个游客,一个游客可能有多张票(比如年卡续费),特征应该跟着人走而不是跟着票走。这样年卡续期的时候不用重新采集人脸,直接复用 visitor 关联的最新特征就行。

向量存二进制而不是 JSON。512 维 float32,按二进制存是 2048 字节,转成 JSON 文本大概 10KB 到 12KB,差了五倍。两百万条记录,一个是 4GB 一个是 24GB,差别肉眼可见。Python 里用np.asarray(vec, dtype=np.float32).tobytes()存,读的时候np.frombuffer(blob, dtype=np.float32),一行进一行出。

必须带模型版本号。这个字段现在看着多余,等你换了特征模型那天就知道它救命了。老版本特征和新版本特征不在同一个向量空间里,直接比对分数会乱掉。正确做法是核验时先看版本,版本不一致就走重新注册流程,或者在后台批量重算。没有这个字段,你只能全表清空重来。

质量分要落库。注册时算出来的质量评分,不只是用来卡注册的,后面调阈值的时候它就是分层依据。质量分高的样本可以用更严的阈值,质量分低的放宽一点,整体误识率反而更低。

3.2 票的状态机,坑最多的地方

状态流转大致是:待支付 → 已支付 → 已绑脸 → 已核销 → 已过期,中间可能插入已退款、已作废。听起来简单,实际最容易出问题的是两个地方。

第一个是绑脸失败的回滚。游客上传的照片质量不合格,特征没入库,但票的状态已经改成"已绑脸"了,结果入园时取不到特征,闸机报错。解决方式是绑脸操作必须在一个事务里完成:特征入库成功才更新票状态,任何一步失败整体回滚,票退回"已支付"等待重试。

第二个是多次入园的计数。年卡场景下"已核销"不是一个终态,而是剩余次数减一。这里如果用了"状态"字段而不是"计数"字段,就会出现第二次入园判断不出来的情况。我的做法是 ticket 表里同时保留 status 和 remaining_count,入园时先原子性地做remaining_count = remaining_count - 1 where remaining_count > 0,看影响行数,为 0 说明次数用完了,为 1 才继续走后面的核验。这个原子更新在高并发下比先查后改靠谱得多。

3.3 核验日志不要吝啬字段

verify_log 这张表在项目初期往往被当成"可选项",我强烈建议从第一天就完整记录。至少要存:票号、闸机编号、现场抓拍图的存储 key、比对分数、判定结果、失败原因码、耗时、时间戳。

原因很实在——上线之后所有的调优都靠这张表。阈值定高了还是低了,看分数分布;某个闸机总是失败,看是不是摄像头脏了;某张票反复比对不过,看是不是底库照片本身有问题。没有这些数据,调参全靠拍脑袋。

抓拍图不要直接存进数据库,存到对象存储或者本地文件系统,表里只留 key。一天两万人次的景区,抓拍图一天就是几十个 G,塞数据库里会把整张表拖垮。

4. 代码主干拆解:注册、建库、核验三段链路

前面铺垫够了,这一节直接上代码。整体思路是三个模块:质量校验、特征提取、比对判定。每个模块单独一个文件,方便替换。

4.1 注册端:先卡质量,再谈特征

注册这一步是整个系统质量的天花板。底库照片不合格,后面再怎么调阈值都是白搭。所以我做的第一件事不是提特征,而是给每一帧打分。

import cv2 import numpy as np def check_quality(img_bgr, box, landmarks=None): """ box: (x1, y1, x2, y2) 返回 (是否合格, 原因码, 质量分 0~100) """ h, w = img_bgr.shape[:2] x1, y1, x2, y2 = [int(v) for v in box] x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(w, x2), min(h, y2) face_area = (x2 - x1) * (y2 - y1) if face_area <= 0: return False, "no_face", 0 # 1. 人脸占比:太小说明离得远,特征不可靠 ratio = face_area / float(w * h) if ratio < 0.06: return False, "face_too_small", 20 if ratio > 0.75: return False, "face_too_close", 30 roi = img_bgr[y1:y2, x1:x2] gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) # 2. 清晰度:拉普拉斯方差,低于阈值认为是糊的 blur_score = cv2.Laplacian(gray, cv2.CV_64F).var() if blur_score < 60: return False, "blur", 30 # 3. 曝光:均值过暗或过亮都不行 mean_v = gray.mean() if mean_v < 50: return False, "too_dark", 30 if mean_v > 210: return False, "too_bright", 30 # 4. 对比度:标准差太低说明平光,人脸没立体感 std_v = gray.std() if std_v < 25: return False, "low_contrast", 40 # 5. 姿态(有 landmark 才做) pose_penalty = 0 if landmarks is not None: left_eye, right_eye, nose = landmarks[0], landmarks[1], landmarks[2] eye_dx = abs(right_eye[0] - left_eye[0]) eye_dy = abs(right_eye[1] - left_eye[1]) if eye_dx < 1e-3: return False, "pose_invalid", 30 roll = abs(eye_dy / eye_dx) if roll > 0.25: return False, "head_tilted", 40 pose_penalty = min(20, roll * 60) score = 100 - pose_penalty score -= max(0, (60 - blur_score)) * 0.3 return True, "ok", round(score, 1)

代码不长,但每个阈值都是踩出来的。blur_score < 60这条线,定在 40 会放进来一堆糊图,定在 100 又会让用户在自助机前反复重拍,60 是实测下来通过率和可用性比较平衡的点。ratio < 0.06是因为低于这个占比之后,人脸区域的像素数不到 80×80,特征提取的区分度断崖式下跌。

还有一个细节:注册端要允许重试,但不能无限重试。我一般设三次上限,三次都不过就让用户走人工柜台。不设上限的话,有人会在自助机上耗十分钟,后面排队的人能急死。

4.2 特征提取:对齐比模型更重要

拿到合格的人脸框之后,先做对齐再做特征提取。这一步很多人偷懒直接把人脸框 resize 到 112×112,结果就是侧脸、歪头的照片特征质量很差。

import numpy as np import cv2 import onnxruntime as ort # 标准五点模板(112x112 输入) REF_POINTS = np.array([ [38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041], ], dtype=np.float32) class FaceExtractor: def __init__(self, model_path, providers=None): providers = providers or ["CPUExecutionProvider"] self.sess = ort.InferenceSession(model_path, providers=providers) self.input_name = self.sess.get_inputs()[0].name def align(self, img_bgr, landmarks): src = np.array(landmarks[:5], dtype=np.float32) M, _ = cv2.estimateAffinePartial2D(src, REF_POINTS, method=cv2.LMEDS) if M is None: return None return cv2.warpAffine(img_bgr, M, (112, 112), borderValue=0.0) def extract(self, aligned_bgr): if aligned_bgr is None: return None # BGR -> RGB, 归一化到 [-1, 1] rgb = cv2.cvtColor(aligned_bgr, cv2.COLOR_BGR2RGB).astype(np.float32) rgb = (rgb - 127.5) / 128.0 blob = np.transpose(rgb, (2, 0, 1))[None, ...] feat = self.sess.run(None, {self.input_name: blob})[0][0] # L2 归一化,后面比对直接用点积就是余弦相似度 norm = np.linalg.norm(feat) return (feat / norm).astype(np.float32)

注意最后一步的 L2 归一化。归一化之后,两个向量的点积就等于余弦相似度,比对的时候省掉一次除法,一百万次比对能省下可观的时间。而且归一化之后的向量存进数据库,将来换模型重新算的时候也方便。

estimateAffinePartial2D这里用的是部分仿射,只有旋转、缩放、平移,没有错切,这样能避免一些人脸被拉变形的问题。cv2.LMEDS是带鲁棒估计的,个别关键点检测偏了也不会把整个变换带歪。

4.3 核验端:从读票到放行的完整链路

闸机端的主流程我写成下面这样,每一段都有明确的失败出口,任何一步出问题都不能继续往下走。

import time import numpy as np class VerifyResult: PASS = "pass" RETRY = "retry" MANUAL = "manual" def cosine(a, b): # 两边都已 L2 归一化,点积即余弦 return float(np.dot(a, b)) def verify(ticket_no, frame_bgr, detector, extractor, feature_repo, cfg): t0 = time.time() result = { "ticket_no": ticket_no, "score": None, "code": None, "cost_ms": 0, } # 1. 读票,取底库特征 feat_record = feature_repo.get_by_ticket(ticket_no) if feat_record is None: result["code"] = "no_feature" return VerifyResult.MANUAL, result if feat_record.model_version != cfg.MODEL_VERSION: result["code"] = "version_mismatch" return VerifyResult.MANUAL, result db_feat = np.frombuffer(feat_record.vector, dtype=np.float32) # 2. 现场抓拍检测 faces = detector.detect(frame_bgr) if len(faces) == 0: result["code"] = "no_face_live" return VerifyResult.RETRY, result if len(faces) > 1: # 多人入镜,取面积最大的,但记录告警 faces.sort(key=lambda f: (f["box"][2]-f["box"][0])*(f["box"][3]-f["box"][1]), reverse=True) result["code"] = "multi_face" face = faces[0] ok, reason, q = check_quality(frame_bgr, face["box"], face["landmarks"]) if not ok: result["code"] = reason return VerifyResult.RETRY, result # 3. 对齐 + 提特征 aligned = extractor.align(frame_bgr, face["landmarks"]) live_feat = extractor.extract(aligned) if live_feat is None: result["code"] = "extract_failed" return VerifyResult.RETRY, result # 4. 比对与判定 score = cosine(db_feat, live_feat) result["score"] = round(score, 4) if score >= cfg.PASS_THRESHOLD: result["code"] = "ok" decision = VerifyResult.PASS elif score >= cfg.RETRY_THRESHOLD: result["code"] = "low_score" decision = VerifyResult.RETRY else: result["code"] = "mismatch" decision = VerifyResult.MANUAL result["cost_ms"] = int((time.time() - t0) * 1000) return decision, result

PASS_THRESHOLD和RETRY_THRESHOLD这两个参数是整个系统的调节旋钮。我的经验起点是 0.45 和 0.35,但具体数值一定要用真实数据重新标定,不同模型的分数分布差别很大,照抄别人的数字十有八九要出事。

判定结果分三档而不是两档,这个设计很关键。分数卡在中间区间的时候,直接拒绝容易误伤,直接放行又有风险,所以给一次重试机会——让游客调整一下站位、摘掉帽子、正对摄像头,再拍一次。实测下来这个中间区间大概占全部核验的 3% 到 5%,给一次重试能把其中七成救回来,剩下的走人工,人力压力就很小了。

5. 闸机现场的工程细节:活体、逆光、断网与并发

代码在办公室里跑通,跟能在景区闸机口连续跑三个月,完全是两回事。这一节讲的都是现场的活。

5.1 活体检测:不做不行,做重了也不行

拿一张打印的照片去骗闸机,是这类系统最基本的攻击方式。要不要做活体、做到什么程度,需要权衡。

轻量方案是动作配合,让游客眨眨眼、张张嘴。优点是几乎不占算力,缺点是非常影响通行速度,高峰期一个人多花两秒,队伍就能排到马路牙子上。而且老人小孩不一定能听懂提示,配合度很低。

稍好一点的是静默活体,用一个小模型对单帧或连续几帧做真假判断,不要求用户配合。模型很小,几兆的量级,推理时间增加 20ms 左右。缺点是面对高质量的屏幕翻拍有时候会漏判。

我的建议是分场景配置:普通工作日的闸机开静默活体,节假日高峰期可以临时降级成"抓拍 + 人工抽检"。这个开关要做成配置项,能在后台随时切,不要写死在代码里。真要上高安全级别的场景,还可以加红外摄像头做双目活体,但那属于硬件改造,成本另算。

5.2 逆光、强光和口罩帽子

景区闸机口最恶劣的光照条件,是"背对出口、面向户外"那个方向——人站在阴影里,背景是刺眼的天空,摄像头自动曝光一压,人脸直接变成剪影。这种情况用标准流程去提特征,分数会莫名其妙地低。

我通常做三层处理。第一层在硬件上,给摄像头加个遮光罩,或者把闸机位置挪一挪,避开直射方位,这一层的效果比任何算法都好。第二层在预处理上,对人脸 ROI 单独做直方图均衡,用 CLAHE 而不是全局均衡,限制对比度放大的倍数,避免把噪点也放大。第三层在判定上,如果检测到当前帧的亮度均值极低(比如低于 40),自动降一档阈值,同时把这个"降档放行"的记录单独标记出来,便于事后审计。

墨镜和口罩是另一个麻烦。墨镜会遮挡眼睛,五点关键点检测直接崩掉,对齐失败,这种只能提示摘掉。口罩遮挡下半脸,传统特征模型的效果会掉三到四成,如果景区场景里口罩是常态(比如秋冬季节),得专门准备一个口罩场景的特征模型,或者把核验区域限定在眼眉到鼻梁这一段,用局部特征比对。帽子相对好办,只要不压到眉毛,影响不大。

5.3 断网:闸机端必须能离线跑

这个是我认为最容易被低估的设计点。景区很多闸机点位在山区、在隧道口、在地下停车场入口,网络抖一下是家常便饭。如果比对逻辑放在云端,网络一断,整条通道就瘫了。

正确的架构是特征前置下发。每天凌晨或者票务系统有新绑定的时候,把当天有效票的特征向量同步到闸机本地的一个小数据库里(SQLite 就够),闸机比对完全在本地完成,网络只用来上传核验日志。

import sqlite3 import numpy as np class LocalFeatureStore: def __init__(self, db_path): self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.execute(""" CREATE TABLE IF NOT EXISTS local_feature ( ticket_no TEXT PRIMARY KEY, vector BLOB NOT NULL, model_version TEXT NOT NULL, valid_until TEXT NOT NULL ) """) self.conn.execute("CREATE INDEX IF NOT EXISTS idx_valid ON local_feature(valid_until)") self.conn.commit() def upsert_many(self, rows): self.conn.executemany( "REPLACE INTO local_feature(ticket_no, vector, model_version, valid_until) VALUES (?,?,?,?)", rows ) self.conn.commit() def get(self, ticket_no): cur = self.conn.execute( "SELECT vector, model_version FROM local_feature WHERE ticket_no = ?", (ticket_no,) ) row = cur.fetchone() if row is None: return None return np.frombuffer(row[0], dtype=np.float32), row[1]

同步策略上有个坑要提醒:不要一次性全量下发。一个五万人次的景区,一天的票就是五万条,五千条一批地推,闸机端用 REPLACE 做幂等写入,推完再推一个版本号过去,闸机收到版本号确认才算同步完成。中途断了下次接着推,不会出现半截数据。

日志上传则用本地队列,网络恢复了批量补传。队列要设上限,比如十万条,超了就丢最老的,同时记一条告警。不然一台闸机断网一周,本地磁盘能给你塞满。

5.4 并发:模型常驻,别每次加载

Flask 默认单进程,FastAPI 配 uvicorn 多 worker,这里有个必须注意的点:模型要在进程启动时加载一次,常驻内存,绝对不能放在请求处理函数里。加载一个几十兆的 ONNX 模型要一两秒,如果每个请求都加载一遍,闸机口直接就堵死了。

from fastapi import FastAPI from contextlib import asynccontextmanager extractor = None detector = None @asynccontextmanager async def lifespan(app: FastAPI): global extractor, detector detector = build_detector("models/det.onnx") extractor = FaceExtractor("models/rec.onnx") yield # 关闭时清理 app = FastAPI(lifespan=lifespan)

用lifespan而不是已废弃的on_event,这是现在推荐的写法。多 worker 的情况下,每个 worker 各加载一份模型,内存占用乘以 worker 数,所以 worker 数不能拍脑袋定,四核机器一般给 2 到 4 个就够,再多反而因为 CPU 争抢变慢。

还有一点,闸机本地的比对是串行的。一台闸机同一时刻只有一个人过,没有必要开多线程比对,串行反而更可控,不会出现两个请求抢摄像头的情况。

6. 上线后的问题清单:分数、特殊人群和高峰期

系统上线之后的第一个月,是问题最集中的时候。下面这些是我反复遇到的,按出现频率排。

6.1 阈值怎么标定才不是拍脑袋

别抄别人的数字。正确做法是攒数据:从线上日志里捞500 对同人样本(比对通过且人工确认是本人的)和500 对不同人样本(票和人明显不匹配的),把两组分数画成两个分布,看重叠区域在哪。

正常情况下,同人分数集中在 0.6 以上,不同人分数集中在 0.2 以下,中间有一段空白。阈值就定在空白区偏下的位置,比如 0.45。如果两组分布重叠得很厉害,说明底库质量有问题,先别调阈值,去查底库照片。

分数区间占比(经验值)处置建议
>= 0.55约 88%直接放行
0.45 ~ 0.55约 8%放行,记录观察
0.35 ~ 0.45约 3%提示重试一次
< 0.35约 1%转人工

这张表里的占比是某景区两周的实际统计,不同场景会有偏差,但量级可以参考。如果一个景区的"重试 + 人工"加起来超过 10%,基本可以断定是注册端质量没控住,回头去看 4.1 节的质量校验是不是阈值放太松了。

调阈值有个原则:宁可误拒,不可误放。误拒的代价是游客多花三十秒走人工通道,误放的代价是一张票被两个人用,涉及票务收入和责任认定。两者的严重程度差着量级。

6.2 老人、儿童、化妆和"长变了"

老人和儿童是这类系统永恒的两大难题。

老人的问题在于底库照片往往太老。很多年卡用户是几年前办卡时拍的照片,那时候和现在的样貌差异已经不小。解决办法是在年卡续费的时候强制重新采集一次,或者每年给一次免费自助更新机会,主动引导用户更新底库。

儿童的问题是长得快。一个七八岁的孩子,半年脸型就能变一圈。同样的策略,儿童年卡设置更短的有效期,比如半年一更新。

化妆和医美属于不可控因素。轻微化妆影响不大,浓妆(尤其是改变眼型和眉形的)确实会把分数拉低。这种情况就是前面说的中间区间重试机制发挥作用的地方——重试一次一般能过。真的遇到整容级别的变化,只能走人工核验 + 后台更新底库。

一个实用技巧:人工通道的核验员手上要有一个"更新底库"的按钮,权限单独控制。现场确认是本人之后,直接重新采集一张,票不用换,下次就能正常刷了。这个小功能能省掉大量客服工单。

6.3 高峰期:宁可降级,不可崩溃

节假日单日客流可能是平日的五倍。这时候系统的压力不只是算力,还有数据库写入、日志上传、图片存储。

我的降级策略是分级的。一级降级:关闭抓拍图上传,只存比对分数和结果,省掉最大的那块 IO。二级降级:关闭静默活体,只做比对。三级降级:中间区间不再给重试,直接转人工,因为高峰期重试一次就是堵两秒。

这些开关要做成后台可配的,最好还能按时间段自动生效,比如预设"国庆期间 9:00-16:00 自动进入二级降级"。临时手动去改配置,往往来不及。

另外一个容易被忽略的点:闸机本地的 SQLite 在高频写入下会锁表。核验日志用 WAL 模式,并且攒批写入,比如每 50 条或者每 2 秒刷一次盘,不要一条一条 commit。这个改动能让单台闸机的日志写入吞吐提升一个数量级。

7. 源码怎么组织,以及几条部署硬规矩

最后讲讲工程层面。一套能演示的代码和一套能交付的代码,差别主要在目录结构和运维细节上。

7.1 推荐的目录结构

scenic-face/ ├── app/ │ ├── main.py # FastAPI 入口,lifespan 加载模型 │ ├── api/ │ │ ├── register.py # 注册、绑脸接口 │ │ ├── verify.py # 核验接口 │ │ └── admin.py # 底库管理、阈值配置 │ ├── core/ │ │ ├── detector.py # 检测器封装 │ │ ├── extractor.py # 特征提取 + 对齐 │ │ ├── quality.py # 质量校验 │ │ └── matcher.py # 比对与判定 │ ├── models/ # ORM 定义 │ ├── services/ │ │ ├── ticket_service.py # 票状态机 │ │ └── verify_service.py # 核验编排 │ └── settings.py ├── device/ # 闸机端 agent │ ├── agent.py # 本地比对主循环 │ ├── local_store.py # SQLite 缓存 │ └── sync.py # 特征同步 + 日志补传 ├── models/ # onnx 文件(不进代码仓库) ├── scripts/ │ ├── eval_threshold.py # 阈值标定 │ ├── bench.py # 压测 │ └── rebuild_feature.py # 模型升级后重算特征 └── deploy/ ├── docker-compose.yml └── systemd/

几个点说明一下。core目录里的四个模块要能独立测试,每个都有明确的输入输出,这样换模型的时候只改一个文件。device单独成目录,因为它跑在闸机上而不是服务器上,依赖也不一样(不需要数据库驱动,需要摄像头驱动)。scripts里的rebuild_feature.py是我强烈建议从第一天就写的脚本,模型一升级就要用。

7.2 部署时容易翻车的几件事

模型文件不要进代码仓库。几十兆的二进制文件提交进去,仓库很快就爆了。用挂载卷或者单独的制品仓库,部署的时候拉下来放到指定路径,路径写进环境变量。

闸机端的时间必须同步。票的有效期判断依赖时间,闸机时间快了五分钟,可能把还没到有效期的票判成过期。所有闸机接同一个时间源,开机自检里加一条时间偏差告警,超过 30 秒就上报。

日志脱敏。核验日志里不要存原始人脸图片的永久地址,也不要存完整证件号。抓拍图设个保留期,比如 30 天自动清理,存储 key 用不可逆的哈希。这块不是技术问题,是合规问题,前期想清楚能省很多麻烦。

配置文件与代码分离。阈值、模型版本号、降级开关这些都要能从配置文件或者配置中心读,不能硬编码。现场实施的同学不会改代码,但改配置没问题。

留一个"旁路"开关。任何自动化系统都有可能出故障,闸机必须保留纯刷卡或者纯人工的通行模式,物理上或者软件上一键切换。这个开关的存在本身,就是整套系统能让人放心的底气。

我在实际部署里还养成了一个习惯:每台闸机上线前,用一组固定的测试样本(十张同人图、十张不同人图)跑一遍自检,把分数打印到日志里。以后每次怀疑设备有问题,先看这二十个分数有没有漂移。摄像头老化、镜头脏污、补光灯衰减,都能从这组分数里看出端倪。这个土办法救过我好几回,比任何监控面板都直观。

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

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

立即咨询