做一个人脸录入+人脸识别的功能,难吗?如果是从头训练一个模型,确实难,但放到今天,选一条“uni-app 做小程序端 + 百度云做算法端”的组合路线,一个普通前端开发也能在两三天内把功能打通。我之前做过一个园区访客登记小程序,核心就是来人先拍照录脸,以后刷脸签到、刷脸开门。整个过程踩了不少坑,尤其是微信小程序的摄像头权限、图片压缩、百度云token这些细节,今天我把完整实现思路和能直接抄作业的代码一起整理出来,给正准备做同类功能的同学一个参考。
1. 项目背景与整体方案选型
1.1 为什么选 uni-app 而不是原生微信小程序
这个小程序最终需要同时覆盖微信端和后续可能的 App 端,所以我直接用 uni-app 开发。uni-app 的语法接近 Vue,一套代码可以编译到微信小程序、H5、App,后续如果要扩展支付宝小程序或抖音小程序,改动成本也低。人脸录入这种页面虽然涉及相机操作,但 uni-app 已经把 camera 组件、uni.compressImage、uni.request 这些常用能力封装好了,不用为每个平台单独写一套原生逻辑。
如果只做微信小程序,原生开发当然也行,但考虑到团队后续还要做管理后台和员工端 App,统一技术栈能省不少事。uni-app 在跨端的一致性上做得不错,特别是摄像头、文件上传这类高频能力,官方文档覆盖得比较全,遇到问题也能在社区找到类似案例。
1.2 为什么人脸算法交给百度云
人脸识别听起来很唬人,但企业自己训练模型不现实,数据量、算力、模型迭代都是成本。市面上成熟的人脸识别服务不少,我选百度云主要看中三点:
- 接入简单:创建应用后拿 API Key 和 Secret Key,换取 Access Token 就能调用,几行代码搞定。
- 功能完整:人脸检测、人脸注册、人脸搜索、人脸比对、活体检测全都有,不需要自己拼多个服务。
- 免费额度够用:个人开发和中小项目测试阶段基本够用,QPS 不高但前期完全没问题。
这里要明确分工:uni-app 负责前端采集人脸照片和展示结果,百度云负责从图片里找脸、提特征、建人脸库、比对打分。前端不需要关心特征向量怎么算,只要把合规的图片传上去,读返回的分数和 face_token 就行。
1.3 整体业务流程设计
人脸功能在业务里一般分两个阶段:
录入阶段:用户打开录入页面,配合摄像头拍一张正脸照片,前端先做基础质量判断,再把照片传给百度云人脸注册接口,服务端把人脸特征存入指定的人脸库(group),同时把返回的 face_token 和用户业务ID绑定。
识别阶段:用户再次进入时拍一张照片,传给百度云人脸搜索接口,从人脸库里找出 top1 的人,返回匹配分数,再结合业务规则决定是放行、签到还是报警。
百度云的人脸库不是简单的用户表,它分为 group_id 和 user_id 两级结构。groupId 可以理解成一个业务分区,比如“园区员工”“访客”“会员”,每次搜索时指定在哪个 group 里找人,避免全量匹配导致性能下降和误识别率上升。这个设计非常关键,初期很多人会把所有用户塞进一个组,等数据量大了再拆就很痛苦。
2. 百度云 AI 开放平台接入准备
2.1 创建应用与获取密钥
去百度云 AI 开放平台控制台,找到“人脸识别”服务,创建应用后,你会拿到三个关键信息:API Key、Secret Key、应用 ID。API Key 和 Secret Key 就是调用接口的身份证,后续获取 Access Token 必须用到。
创建应用时注意勾选服务权限,默认会把人脸检测、人脸搜索、人脸库管理等权限都带上。如果后续 403 或提示 not exist,先回这里检查是否忘记开通对应服务。
密钥是敏感信息,前端代码里不能直接写死。规范做法是请求自己的后端,由后端保存密钥并转发请求,或者至少在后端完成 token 的获取和缓存。微信小程序是客户端代码,任何人反编译都能看到你的密钥,直接写在 uni.request 里等于把账号送给别人刷。我在项目里是把密钥放在一个 Node 中间层,小程序每次只向后端要 token。
2.2 Access Token 的获取与缓存
百度云人脸接口统一使用 Bearer Token 认证,官方称为 Access Token。获取方式很简单,用 API Key 和 Secret Key 调用 OAuth 接口:
curl -i -X POST 'https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=你的APIKey&client_secret=你的SecretKey'返回结果里最重要的字段是 access_token,默认有效期大概 30 天。这里有个巨大的坑:如果前端每次请求都去换一次 token,不仅浪费请求次数,还会因为并发换 token 导致部分请求失败。
我在项目里做了一个 token 管理模块,用一个本地缓存加过期时间,保证同一时刻只有一个请求在刷新 token:
// utils/token.js let tokenPromise = null function getAccessToken() { const cached = uni.getStorageSync('bd_access_token') const expire = uni.getStorageSync('bd_token_expire') if (cached && expire && expire - Date.now() > 60 * 60 * 1000) { return Promise.resolve(cached) } if (tokenPromise) { return tokenPromise } tokenPromise = new Promise((resolve, reject) => { uni.request({ url: 'https://aip.baidubce.com/oauth/2.0/token', method: 'POST', data: { grant_type: 'client_credentials', client_id: '你的APIKey', client_secret: '你的SecretKey' }, success: (res) => { if (res.data.access_token) { uni.setStorageSync('bd_access_token', res.data.access_token) uni.setStorageSync('bd_token_expire', Date.now() + res.data.expires_in * 1000) resolve(res.data.access_token) } else { reject(res.data) } }, fail: reject, complete: () => { tokenPromise = null } }) }) return tokenPromise } module.exports = { getAccessToken }注意 token 没过期时,我还留了 1 小时的缓冲期,避免刚好卡在过期临界点导致调用失败。这个缓冲设置很重要,微信小程序里网络波动比较多,卡点请求很容易出问题。
2.3 接口域名与本地调试
微信小程序对网络请求域名有强校验,上线版本必须把请求域名加入到 request 合法域名,且要求 HTTPS。百度云接口域名是 https://aip.baibce.com,在微信公众平台“开发管理-开发设置-服务器域名”里加上这个地址即可。
本地开发阶段,在 HBuilderX 运行到微信开发者工具时,可以在微信开发者工具右上角“详情-本地设置”里勾选“不校验合法域名”,这样本地调试不会被域名拦。但真机预览时还是要按真机模式处理,如果域名没配好,真机上所有请求都会直接报 url not in domain list,排查半天。
3. 人脸录入功能实现
3.1 前端拍照页面搭建
人脸录入页面我用了 camera 组件,因为 chooseImage 只能从相册选,相册里的照片极可能是翻拍、P过的,质量不可控,录入效果很差。camera 组件可以让用户直接对着摄像头取景,体验更像“采集人脸”。
页面布局很简单,上半部分摄像头实时预览,下半部分放操作按钮和提示文案。关键代码:
<template> <view class="face-register"> <camera v-if="showCamera" class="camera" device-position="front" flash="off" @error="onCameraError" /> <view class="tip">请将面部置于取景框内,保持正脸、光线充足</view> <button @click="takePhoto">拍照录入</button> </view> </template>这里 device-position 我用的 front,人脸录入场景前置摄像头更自然。如果项目需要支持后置摄像头,参数改成 back 就行。
拍照动作通过 createCameraContext 触发:
const ctx = uni.createCameraContext() ctx.takePhoto({ quality: 'high', success: (res) => { const tempImagePath = res.tempImagePath this.handleImage(tempImagePath) } })拍照成功后拿到临时路径,先做一次本地预览让用户确认,再继续后续处理。不要刚拍完就上传,用户表情没摆好、闭眼了、口罩没摘,传上去也是浪费请求次数。
3.2 图片压缩与 base64 转换
百度云人脸接口要求传图片 base64,且 base64 编码后大小建议不超过 1M,否则容易超限。手机拍出来的照片动不动 3M、5M,直接转 base64 肯定超,所以一定先压缩。
uni-app 提供了 uni.compressImage,可以控制压缩质量:
uni.compressImage({ src: tempImagePath, quality: 80, success: (res) => { this.compressPath = res.tempFilePath this.convertToBase64(res.tempFilePath) } })转 base64 用 uni.getFileSystemManager().readFile,编码格式选 base64:
const fs = uni.getFileSystemManager() fs.readFile({ filePath: filePath, encoding: 'base64', success: (res) => { const base64 = res.data // 这里可以直接调用百度接口了 } })注意 readFile 在部分安卓机上有临时目录读取失败的问题,如果遇到,可以先 uni.saveFile 持久化一下再读。另外 base64 字符串里不能有换行符,如果自己拼 data URI 的时候出现了 \n,要 replace 掉,否则百度接口会报 image format error。
3.3 人脸检测与图片质量校验
图片传给人脸库之前,强烈建议先调一次百度云人脸检测接口,确认图片里确实有人脸、人脸质量达标再入库。这一步能挡住大量低质量录入,比如逆光、闭眼、侧脸、遮挡、多人脸。
人脸检测接口是 detect,我一般这样传参:
uni.request({ url: `https://aip.baidubce.com/rest/2.0/face/v3/detect?access_token=${token}`, method: 'POST', data: { image: base64, image_type: 'BASE64', face_field: 'quality,angle', max_face_num: 1, face_type: 'LIVE' }, success: (res) => { const result = res.data.result if (!result || !result.face_num) { uni.showToast({ title: '未检测到人脸', icon: 'none' }) return } const faceList = result.face_list const quality = faceList[0].quality if (quality.blur < 0.5 && quality.illumination > 40 && quality.completeness > 1) { // 质量合格,继续注册 this.doRegister(base64) } else { uni.showToast({ title: '图片质量不合格,请重拍', icon: 'none' }) } } })face_field 里的 quality 会返回 blur(模糊程度)、illumination(光照)、completeness(完整度)、occlusion(遮挡)等指标,angle 会返回人脸的三维旋转角度。这些阈值没有绝对标准,我在测试时的经验是 blur 越低越好,illumination 大概 40 以上,completeness 接近 2 才是正常。
实际业务里,为了提升用户体验,我做了前端二次校验,检测到问题后直接提示“请正面面对屏幕”“光线太暗”“有遮挡”,而不是只弹一个“质量不合格”。虽然要多写几行判断,但用户被拒绝时的感受完全不一样。
3.4 调用人脸注册接口
质量校验通过后,调用人脸注册接口,把图片加入人脸库并关联用户:
function doRegister(base64, userId, groupId, userInfo) { return getAccessToken().then((token) => { return new Promise((resolve, reject) => { uni.request({ url: `https://aip.baidubce.com/rest/2.0/face/v3/faceset/user/add?access_token=${token}`, method: 'POST', data: { image: base64, image_type: 'BASE64', group_id: groupId, user_id: userId, user_info: userInfo, quality_control: 'NORMAL', liveness_control: 'NORMAL' }, success: (res) => { if (res.data.error_code === 0) { const faceToken = res.data.result.face_token resolve(faceToken) } else { reject(res.data) } }, fail: reject }) }) }) }quality_control 和 liveness_control 是百度云内置的兜底开关。quality_control 设为 NORMAL 或 HIGH,百度会在注册时自动拒绝模糊、低质量图片;liveness_control 设为 NORMAL 或 HIGH,会做一定的活体判断,减少用照片翻拍注册的风险。注册和搜索接口都可以传这两个参数,我在业务里统一用 NORMAL,避免过严导致正常用户注册失败。
一个用户可以被重复注册,同一 user_id 多次 add 不会报错,而是追加人脸。需要防止重复录入的话,可以在业务层先查这个 user_id 是否已有 face_token,或者用百度云的 face/delete 接口先清理旧人脸再重新注册。
3.5 人脸库分组策略
group_id 的设计会影响整个系统的扩展性。我在访客项目中按角色分了三组,员工、访客、黑名单人员。搜索时根据当前业务场景指定 group_id_list,比如门禁只搜员工组,访客登记只搜访客组,黑名单单独一组用于拦截。
同一个 user_id 在不同 group 里是独立的,比如一个人既可以是员工又可以是访客,那他在两个组里会有两份人脸记录。用 user_id 管理业务身份,用 face_token 管理人脸实例,两者解耦,后面调整权限时不用动人脸数据。
4. 人脸识别功能实现
4.1 人脸搜索接口调用
识别阶段的核心接口是 search,就是拿一张新照片去指定的人脸库里找最像的人:
function faceSearch(base64, groupIdList) { return getAccessToken().then((token) => { return new Promise((resolve, reject) => { uni.request({ url: `https://aip.baidubce.com/rest/2.0/face/v3/search?access_token=${token}`, method: 'POST', data: { image: base64, image_type: 'BASE64', group_id_list: groupIdList.join(','), quality_control: 'NORMAL', liveness_control: 'NORMAL', max_user_num: 1 }, success: (res) => { if (res.data.error_code === 0) { resolve(res.data.result) } else { reject(res.data) } }, fail: reject }) }) }) }返回结果里最关键的是 user_list,格式类似:
{ "face_token": "人脸标识", "user_id": "用户业务ID", "group_id": "所属分组", "score": 87.36 }score 是相似度分数,范围 0~100。我在项目里定的经验阈值是 80,超过 80 才认为匹配成功,低于 80 直接提示“未识别”。这个阈值不是固定的,要结合场景调:门禁这种安全要求高的场景,阈值可以提到 85 甚至 90;会员识别的场景,80 左右用户感知更好,不至于经常识别失败。
4.2 人脸比对接口与活体检测
除了 search,还有一个常用的 match 接口,用于两张人脸图的比对,比如身份证照片和现场自拍是不是同一个人。match 接口一次最多传 4 组图片对,适合做人工审核辅助。
我实际用得比较多的是在访客预约场景:访客在小程序里先上传一张自拍完成预注册,到现场时再拍一张,用 match 比对两张脸,这样可以防止别人拿预注册照片代打卡。
关于活体检测,很多人在这一步会忽略。百度云的 liveness_control 参数做的是图像层面的活体判断,能拦住一部分用手机照片翻拍的情况,但拦不住视频实时翻拍。如果业务对安全性要求高,建议接入百度云的身份验证产品或者在端上做动作活体检测,比如眨眼、摇头,再配合后端接口验证。H5视频活体检测需要单独申请能力,流程稍微复杂一点,但安全等级高很多。
4.3 识别结果与业务系统的联动
识别成功拿到的 user_id 只是业务主键,真正要做的是把它映射回自己的业务系统。我在后端维护了一张用户表,user_id 存业务主键,再关联姓名、手机号、角色、通行权限这些字段。前端识别出 user_id 后,调自己的后端查询用户详情和权限,决定是开门、签到、弹欢迎语还是告警。
这里有个需要注意的问题:search 接口返回的 user_list 可能包含多个候选,当 top1 分数过低时,不要直接拒绝,可以先返回一个“未匹配,请重试”的提示,给用户重新拍摄的机会。我在测试时发现,光线变化对识别分数影响非常大,同一个人的分数可能在 78 到 92 之间波动。连续三次失败再走人工核验流程,是兼顾体验和安全最好的方式。
另外每个用户的识别日志一定要记录,包括请求时间、图片、返回分数、阈值判断结果。这对后续调优阈值和做安全审计都很有用。
5. 常见问题与避坑指南
5.1 微信小程序合法域名与真机调试问题
这是新人最容易卡住的问题。本地开发者工具里一切正常,一上真机所有请求全部失败,报错 url not in domain list。原因就是本地调试勾了“不校验合法域名”,真机没有这个待遇。
解决方式就是提前在微信公众平台配置 request 合法域名,把 https://aip.baidubce.com 加进去。注意域名的协议必须是 HTTPS,且不能带路径。配置提交后一般几分钟生效,不用重新发布小程序。
还有一个容易踩的小坑:如果你把请求转发到自己的后端,后端再调百度云,那小程序域名只配置你自己的后端域名即可,百度云相关内容只出现在服务端,安全性也更高。我推荐有条件的新项目直接用这个模式,而不是小程序直连百度云。
5.2 base64 图片过大与压缩参数选择
百度云人脸接口虽然有免费额度,但单张 base64 超过限制会直接报错,而且图片过大会拖慢上传速度,用户等待时间变长,体验很差。
我在项目里把压缩质量控制在 70 到 80 之间,压缩后的图片大概 100-300KB,转 base64 后也在 1M 内。质量太低会影响人脸检测的准确率,太高又容易超限。不同机型拍摄的原图尺寸差异很大,压缩时最好按宽高限制而不是只压质量。uni.compressImage 的 compressedWidth 和 compressedHeight 参数可以限制尺寸,做到 720 宽基本够人脸识别用了。
还要注意 Android 和 iOS 的相机输出格式差异,部分安卓机拍出的原图可能有 EXIF 旋转信息,压缩后方向是正的,但 width 和 height 会变,后端存图或者前端展示时都要统一处理。
5.3 Access Token 过期与刷新策略
百度云的 Access Token 有效期 30 天,但并不是说 30 天内就一直有效,如果频繁调用或账号异常也可能被提前失效。我在代码里做了 token 失效自动重试一次的逻辑:
function requestWithRetry(options) { return getAccessToken().then((token) => { options.url = options.url + '?access_token=' + token return uniRequest(options) }).catch((err) => { if (err.error_code === 110 || err.error_code === 111) { // token失效,清缓存再试一次 uni.removeStorageSync('bd_access_token') uni.removeStorageSync('bd_token_expire') return getAccessToken().then((token) => { options.url = options.url + '?access_token=' + token return uniRequest(options) }) } throw err }) }error_code 110 是 token 无效,111 是 token 过期。这两种情况都直接清掉本地缓存,重新获取后再请求一次。注意重试次数只能一次,防止循环重试把请求打爆。
5.4 相机权限与用户引导
微信小程序里 camera 组件首次渲染会触发授权弹窗,用户拒绝后组件会黑屏或者报错。在进入人脸录入页面时,提前检查授权状态很重要。
我用的是 uni.getSetting 加 uni.authorize 组合:
uni.getSetting({ success: (res) => { if (!res.authSetting['scope.camera']) { uni.authorize({ scope: 'scope.camera', success: () => this.showCamera = true, fail: () => { uni.showModal({ title: '提示', content: '需要摄像头权限才能完成人脸录入', confirmText: '去设置', success: (res) => { if (res.confirm) { uni.openSetting() } } }) } }) } else { this.showCamera = true } } })用户拒绝授权后跳设置页的方法非常好用,我把它封装成了一个公共方法,凡是涉及相机、相册、位置这些敏感权限的页面都能复用。这里顺便提醒一下,不要在用户刚进入页面就弹一堆授权,最好是在点“拍照录入”按钮时再触发,用户更愿意配合。
5.5 几张常用表格,建议直接收藏对照
百度云人脸接口虽然官方文档很全,但字段太多,我这里整理了一张常用的参数速查表,方便你做参数选择时快速判断:
| 接口 | 核心参数 | 用途 |
|---|---|---|
| detect | image, face_field, max_face_num | 检测人脸位置与质量 |
| faceset/user/add | group_id, user_id, image | 注册人脸到指定人脸库 |
| faceset/user/delete | group_id, user_id, face_token | 删除用户或人脸 |
| search | image, group_id_list | 在指定人脸库中搜索相似人 |
| match | image, image (多组) | 1:1 人脸比对 |
| faceset/group/getlist | 无 | 查询当前账号下全部分组 |
关于阈值,我也给一个经验参考区间,实际以你自己的业务验证为准:
| 场景 | 匹配阈值区间 | 建议 |
|---|---|---|
| 门禁/支付 | 88-92 | 宁可误拒绝,不可误放 |
| 员工考勤 | 82-88 | 均衡安全与体验 |
| 会员/访客 | 78-84 | 体验优先,匹配失败走人工 |
我先把这个阈值设置成一个配置文件,而不是写死在业务代码里。上线后观察一周识别率和误识率,再动态调整,效果会稳很多。
6. 上线前必须做的几项检查
人脸功能最容易出问题的不是开发阶段,而是上线后的边界情况。我在项目上线前会把下面这些项挨个过一遍:
第一项,测试机型覆盖。微信小程序跑在各种各样的安卓机和 iPhone 上,摄像头能力和性能差异很大。至少要测 iPhone 各代、主流 Android 厂商的高中低端机型,特别关注拍照后压缩、base64 转换和 camera 组件黑屏问题。
第二项,网络环境兼容。弱网环境下,图片上传可能超时。我为人脸请求单独设置了 10 秒超时,超时后给用户明显的错误提示,而不是默默失败。如果公司有自己的服务端,建议做异步重试任务,失败的人脸请求自动重试一次,成功率能提高不少。
第三项,灰度发布。人脸功能不要一把梭全量上线,先开放给内部员工或者少量测试用户,观察接口报错率、平均响应耗时、用户投诉,稳定后再放量。我之前经历过一次没做灰度,上线当天预览版域名配置失效,所有用户刷脸全部失败,紧急回滚很狼狈。
第四项,数据合规与用户隐私。人脸信息属于敏感个人信息,小程序端要有人脸信息采集告知弹窗,明确说明收集目的、使用范围、存储方式。后端要控制人脸数据的访问权限,不要随便允许前端拉取他人人脸照片或特征。在百度云后台定期清理没用的 face_token,及时注销用户人脸数据。
7. 实际操作中的一点体会
这个项目做下来,我最深的一个感受是:调用第三方 AI 接口,核心功夫全在接口之外。百度云的人脸接口本身很简单,难的是把图片质量、token 管理、活体策略、用户引导、阈值调优这些周边环节串起来。只要每一步的质量都过关,人脸识别率自然就高;反过来,任何一环偷懒,比如图片不压缩、质量不校验、token 不缓存,最后都会变成线上一个又一个的报错工单。
给大家一个最实在的建议:先别急着写业务代码,花半天时间把百度云控制台里所有的测试页面都点一遍,把 detect、search、add、match 这几个接口的请求参数和返回结构摸熟了再动手。接口通了,前端只是组装参数和渲染结果的事。
如果后续你打算把功能做成产品化,可以考虑把人脸录入和识别封装成一个 uni-app 的公共模块,通过配置项切换百度云账号、人脸库分组和匹配阈值。这样新项目接入时,只需要传业务 ID 和回调函数就能跑通,团队里其他人也不用重新踩一遍我踩过的坑。