最近在做移动端H5项目时接了个很头疼的需求要在普通H5网页和微信公众号网页里做人脸识别做身份核验和活体检测但又要求不能把用户的面部数据传到服务器上而且还得免费最好连第三方付费接口都不用。我第一反应是这不现实后来花了两个周末把纯前端人脸识别方案在Vue3里跑通了。这个方案的核心思路是给视频流实时识别本质上是在浏览器端完成人脸检测、特征提取、人脸对比、活体检测所有计算都靠设备本地的算力摄像头数据不出当前页面。不依赖任何服务器接口也不用付一分钱完全符合题目的需求。下文就结合我自己踩过的坑从原理讲起再到可复用的Vue3插件源码和完整案例。1. 项目需求与纯前端方案选型1.1 这个项目到底要解决什么问题先说清楚适用场景。H5端的人脸识别需求常见于政务类小程序、银行开户、企业内部的员工认证、在线教育考试系统等页面跑在手机浏览器或微信公众号内置浏览器里。这类场景有两个硬条件一是不能跳转到App二是绝不能把结构化人脸特征上传服务器因为人脸属于敏感生物信息很多业务方对合规要求极其严格。另外“免费”这个关键词也很关键。市面上成熟的人脸核身API几乎都是按次收费的操作一次要几毛钱到几块钱量大起来是一笔不小支出。而且如果业务内网环境特殊无法访问公共云接口那纯本地识别几乎是唯一出路。所以这个项目的核心目标是在H5页面中调用手机摄像头在本地完成人脸检测、人脸质量校验、人脸特征抽取、1:1人脸比对、活体动作检测最终返回核验结果。不联网也能稳定跑。1.2 为什么选择纯前端的人脸识别库我当时调研了几个方向先否定的是“调用原生摄像头加服务端识别”还要维护一套服务端加前端通信数据上传下传非常繁琐也不免费。另一个方向是使用设备原生的人脸识别SDK比如某些手机厂商提供的能力但是普通H5无法调用兼容性几乎等于零。最后一个方向就是浏览器端运行推理引擎。目前成熟的人脸识别开源库有face-api.js、mediapipe等其中face-api.js基于TensorFlow.js模型体积适中提供了人脸检测、72点关键点定位、人脸特征描述子Descriptor提取而且API设计比较直白适合快速集成在Vue3项目里。用face-api.js最大的考量是封装程度高底层将模型部署和推理细节都隐藏起来了。而且模型文件可以放到自己的静态服务器或者CDN用的时候动态加载这部分是纯前端的。活体检测我们可以不用依赖任何后端算法基于关键点坐标变化自己实现“眨眼检测”“张嘴检测”“左右转头检测”就可以有效防照片攻击和简单的视频重放攻击。很多人会担心在浏览器用神经网络识别性能不够。实测下来在主流中端安卓手机上使用tiny模型做人脸检测和特征提取单次推理大约在100ms到300ms之间H5页面可接受。配合降帧策略后体验上是流畅的。2. 人脸识别与活体检测核心原理拆解2.1 人脸特征与人脸对比的计算逻辑人脸识别的本质不是“把照片拼起来比对像素”而是把一张人脸映射为一个高维数学向量。face-api.js里的LabeledFaceDescriptors和FaceMatcher就是基于这种人脸描述子。每个人脸图像通过卷积神经网络提取出128维特征向量同一人的脸在特征空间距离很近不同人的脸距离很远。在实现人脸比对的核验点击前需要提前准备一张基准照片比如用户提前拍摄的身份证照片或者从某个库里取出的照片。当现场摄像头捕捉到人脸后提取出当前视频帧的128维特征向量。接下来计算两个向量的欧几里得距离距离越小代表相似度越高。出来一个经验阈值在face-api.js的默认模型下距离超过0.6基本可以判定不是同一个人0.5到0.6之间是有风险区间需要结合其他因素判断0.4以下基本稳定。不过实际使用时不要盲目套用建议先采集同一人的正脸照片测试多次看距离分布再动态设阈值。我的经验是设成0.45做现场比对配合活体检测误识率大幅下降。还有一个细节人脸对比时不同照片光照、角度、表情都会有影响。所以做1:1核身时需要先对模板照片做一次人脸检测如果检测不到人脸直接终止流程。考虑到大部分基准照片是身份证相质量参差不齐加载后可能需要做一个缩放或者调整亮度处理。2.2 活体检测的两种实现路线活体检测是防止有人拿照片或者视频冒充用户。完整的活体检测涉及纹理分析、红外深度、光流法等但在纯前端环境里最可靠且实现成本最低的就是“动作指令活体检测”。动作指令活体检测的逻辑是这样的系统随机生成一组动作序列比如先眨眼、再张嘴、再左右摇头。用户需要按照指令顺序完成动作系统通过人脸关键点实时分析对应部位的动作状态。每完成一个动作标记已完成并切换下一个指令。最后所有动作完成就认为当前是活体且整个过程中不会让用户离开摄像头视角。以眨眼检测为例我们需要用到左右眼关键点的纵坐标比值。人脸72个关键点中6个点标定左眼6个点标定右眼比如眼角内外侧和上下眼眶。当眼睛睁开时上下眼眶距离较大闭眼时距离明显缩小。计算一个“眼睛纵横比”EAR低于阈值并持续超过若干帧就判定为一次闭眼再配合从闭眼恢复到睁眼的过程算一次完整眨眼。张嘴检测则是取嘴部上下轮廓点计算嘴角宽度和上下唇高度之比。正常闭嘴时比值较小张大嘴时比值明显增大。模拟一个阈值比如上下嘴角点距离超过左右嘴角点距离的三分之二就认为张开了嘴。左右点头检测则是计算鼻尖到两耳连线中点的偏移或者直接用68点中标定的鼻尖点到左右嘴角连线的垂直距离变化当头部转动时这个距离会发生变化。下面的实操就是围绕动作活体检测展开的。它不依赖服务器计算也不需要额外的硬件设备在公众号网页里也能正常跑。3. Vue3插件开发完整步骤3.1 初始化Vue3项目与安装依赖我使用Vite作为构建工具写Demo时创建了一个普通Vue3项目。在项目根目录安装人脸识别相关依赖npm install vitejs/plugin-vue npm install face-api.js0.22.2有一点需要特别注意face-api.js的版本迭代比较慢目前中文字典API基本稳定。我用的是0.22.2版本这个版本在移动端H5环境下的兼容性比较理想。项目中可能还会用到Buffer相关polyfill因为face-api.js内部依赖一些Node端才有的模拟API而浏览器环境没有。那就在index.html或者main.js里做一下polyfill关键是引入buffernpm install buffer然后在vite.config.js中配置define和resolve给浏览器环境提供node全局变量模拟。不过后来我引入了global和Buffer具体配置我放到完整源码里。考虑到Vite构建时对Node polyfill支持不是很好最简单的做法是在index.html里预挂载script var global globalThis /script然后再在入口文件里引入buffer并挂到全局import { Buffer } from buffer window.Buffer Buffer这样处理的逻辑是为了确保face-api.js内部那些模块能够正常加载。很多开发者看到报错就以为是库坏了其实只是没处理好底层依赖。3.2 初始化模型与环境人脸识别库需要加载的模型有五个文件。最主要的是face_landmark_68_model关键点模型、face_recognition_model特征提取模型、tiny_face_detector_model人脸检测模型。有些方案还会加载年龄性别模型和表情模型但这里我们只做人脸对比和活体没必要全部加载加载越多越拖慢首屏。我把模型文件放在项目的public/models目录下。模型文件可以从face-api.js的官方GitHub仓库中下载。重点说一下模型文件必须和前端静态服务器同源或者支持跨域否则浏览器会因为权限拦截加载失败。在Vue3组件中写一个初始化方法示例代码如下import * as faceapi from face-api.js async function loadModels() { const MODEL_URL ${window.location.origin}/models await faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL) await faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL) await faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL) }这里还做了一层保护如果模型已经重复加载过就不必再走一遍加载流程。用loaded标志位判断。3.3 开启摄像头与实时视频帧处理在H5页面里调用摄像头只能使用navigator.mediaDevices.getUserMedia。这个方法在非本地安全上下文下会失效也就是必须要求页面运行在HTTPS环境或本机localhost。公众号内的网页默认是HTTPS基本没问题。开启摄像头的核心代码const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: user, width: { ideal: 640 }, height: { ideal: 480 } }, audio: false }) videoRef.value.srcObject stream await videoRef.value.play()我建议把视频分辨率设置到640x480就够用了过高的分辨率不仅增加计算量在低端机上还会导致推理帧率下降。要特别留意的是在iOS上部分浏览器播放摄像头流时如果元素尺寸与视频比例不一致会出现画面拉变形所以组件里最好固定按4:3比例显示。实时检测不能每一帧都全速推理这样既费电又卡。我一般用requestAnimationFrame做循环但会加一个帧节流逻辑比如每两帧检测一次或者每隔100ms检测一次。人脸检测只需要处理关键位置少了浪费算力多了容易发热。我的设计是100ms调度一帧帧间隔溢出时丢弃。3.4 封装Vue3模块组件设计为了让这个方案可复用我把所有逻辑封装成了一个组合式工具近似插件。它暴露了startFaceDetect、captureDescriptor、compareFace、startLiveness、stopAll几个方法。业务页面只需要根据自己的流程调用对应函数不需要关心底层推理细节。3.5 活体检测动作校验实现活体检测部分我实现了一个状态机。每次从视频帧中检测到人脸关键点就根据当前活体阶段校验目标动作是否完成。动作检测结果是以“帧状态”形式推送给状态机的。比如检测“眨眼”我把动作拆成“眼睛睁开”和“眼睛闭合”两个状态从睁眼到闭眼再到睁眼才计一次有效眨眼。这样防止有人用照片一直挡住也能蒙混过关。除非照片里的人本来就是闭眼的但那样距离对比也不会通过。至于张嘴动作我提取上嘴唇中心点、下嘴唇中心点和左嘴角、右嘴角四个点计算上下间距与左右间距的比值。比值超过阈值并持续至少3帧我才认为“张嘴动作完成”。转头检测稍微复杂一点核心思路是判断鼻尖偏移位置。当人正对摄像头时鼻尖位置基本处于两耳或面部中间区域当向左转头时鼻尖向右移动比例会发生改变。通过左右嘴角连线的中点作参考计算鼻尖横坐标相对这一中点的偏移量。偏移幅度连续超过某个像素阈值并保持几帧就认为转头动作发生。每条活体指令都设置了一个超时时间默认8秒。如果用户在8秒内没有完成目标动作就判定活体检测失败。全部动作完成后才允许执行最后的人脸特征提取和对比。这里要谈一个我自己踩过的坑动作序列千万不要固定不变最好每次请求会话都随机打乱指令顺序。否则攻击者可以提前录好一套固定动作视频用于重放。动态随机指令虽然也防不住高级的三维面具但对付“照片录屏”这种初级攻击完全够用。3.6 人脸比对与人脸核身流程整合人脸核身一般分两步第一步是存基准照片或从请求中获取基准图第二步是调用live摄像头采集现拍人脸提取描述子最后计算距离。我把第一个步骤封装为一个从图片元素或ImageBitmap中获取描述子的函数async function getDescribeFromImageSrc(imgSrc) { const img await faceapi.fetchImage(imgSrc) const detection await faceapi.detectSingleFace(img, new faceapi.TinyFaceDetectorOptions()) .withFaceLandmarks().withFaceDescriptor() if (!detection) { throw new Error(基准照片未检测到人脸) } return detection.descriptor }第二个步骤在用户完成活体动作后从视频流中截取当前帧再次执行detectSingleFace确保人脸清晰无遮挡然后提取描述子。最后计算欧氏距离。注意提取前一定要检查detection.score是否达到阈值如果置信度太低说明画面模糊或侧脸过多应当提示用户重新对准。实际核身通过的判定条件有两个距离小于预设阈值人脸置信度大于0.6。两个条件同时满足才认为核身通过。4. 完整示例源码与使用说明4.1 核心数据结构设计在封装插件时定义了一个FaceDetectResult对象用于保存检测状态const faceDetectState reactive({ detecting: false, hasFace: false, faceScore: 0, landmarks: null, descriptor: null, videoWidth: 0, videoHeight: 0, currentAction: , })活体检测动作序列的数据结构为const actionSequence [ { type: blink, label: 请在2秒内眨眨眼 }, { type: openMouth, label: 请张嘴 }, { type: turnHead, label: 请左右转头 } ]数据结构分离的优点是UI和逻辑解耦状态是响应式对象页面可以实时绑定指令提示非常方便。4.2 核心逻辑完整关键代码以下代码节选自组件的useFaceRecognition组合式函数中的重要部分。受限于篇幅不能把全流程都贴出来但主要逻辑都在下方。import * as faceapi from face-api.js import { reactive, readonly } from vue const MODEL_URL ${window.location.origin}/models const state reactive({ isModelLoaded: false, isVideoReady: false, currentAction: null, livenessPassed: false, captureDescriptor: null }) function loadFaceModels() { return Promise.all([ faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL), faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL), faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL) ]).then(() { state.isModelLoaded true }) }活体检测关键代码let earValue 0 let mouthAspectRatio 0 let noseOffset 0 function computeEar(landmarks) { const leftEye landmarks.leftEye const rightEye landmarks.rightEye const leftEAR (distance(leftEye[1], leftEye[5]) distance(leftEye[2], leftEye[4])) / (2 * distance(leftEye[0], leftEye[3])) const rightEAR (distance(rightEye[1], rightEye[5]) distance(rightEye[2], rightEye[4])) / (2 * distance(rightEye[0], rightEye[3])) return (leftEAR rightEAR) / 2 }这里landmarks.leftEye和landmarks.rightEye获取到的坐标数组是我在测试过程中通过调试面板抓到的真实使用时直接读取即可。distance函数就是普通的欧氏距离。4.3 页面调用与参数调整建议业务页面使用很简单在Vue模板中引入组件后挂载时调用初始化onMounted(async () { await loadFaceModels() await browserAssistantForCamera() })用户进入页面后先加载模型模型加载完成后打开摄像头随后展示“开始人脸核身”按钮。参数调整上最重要的两个数值是TinyFaceDetectorOptions的输入尺寸和人脸置信度阈值。我的移动端参数是const options new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.5 })inputSize越小推理速度越快但越小就越容易漏检小尺寸人脸。320是一个比较均衡的值。如果手机性能不错可以改为416检测更准确。scoreThreshold建议设置在0.4到0.6之间过低会把很多误检当成真人过高又会把正常侧脸滤掉。4.4 对应H5页面和微信公众号网页的适配说明公众号内置浏览器对摄像头支持的协议和普通浏览器基本一致只需要在发布时保证公众号JS接口的安全域名已经配置好页面本身使用HTTPS链接就可以正常调用摄像头。在iOS系统的公众号网页里注意视频元素的播放必须由用户点击行为触发所以初始化按钮不能自动播放摄像头必须让用户手动点击“开始检测”按钮后执行getUserMedia。如果遇到“摄像头权限无法获取”通常是因为用户此前拒绝了权限。我的处理办法是在调用失败时捕获错误并引导用户去浏览器设置里重新开启摄像头权限。由于是纯静态方案不涉及微信授权API所以对接前要注意判断前端是否处于微信环境如果不在微信环境也可以使用普通H5模式。5. 真实场景里踩过的坑与排查技巧5.1 摄像头启动黑屏但不报错出现这种问题大概率不是代码逻辑错误而是摄像头取流后视频元素属性设置不对。需要设置playsinline属性防止iOS将视频全屏化同时在loadedmetadata事件回调后再播放视频。否则一些安卓浏览器会出现只有声音或完全黑屏却不报错的情况。还可以加一个失败兼容检测到视频流但视频元素显示宽高为0时说明视频流布局未生效需要手动给video元素设置宽度和高度。5.2 模型加载失败或超时模型文件放在静态服务器后如果项目配置了CDN加速需要确认CDN是否对模型文件.weights、.json做了正确的Content-Type解析。有些CDN会拦截未识别的二进制文件导致加载失败。对策是查看浏览器网络面板确认模型文件是否成功返回200。如果本地路径加载太慢还可以改成先下载模型List后缓存到浏览器。但是浏览器缓存方案在首次加载时依然需要下载所以建议把模型压缩一下把tiny模型控制在几MB以内移动网络下首次加载不会太慢。5.3 照片攻击和视频重放能被通过如果只做静态人脸对比拿一张用户照片放在摄像头前很容易通过。再加上活体检测也不行因为如果活体检测只是要求“看镜头”照片也能完成看镜头。这就是为什么我要把动作活体检测设计成随机序列而不是固定顺序。同时我还会对多帧检测结果做连续性校验防止攻击者用手机播放预先录好的视频。具体做法是活体检测过程中要求用户的动作不能瞬间跳变比如上一帧是睁眼下一帧闭眼再进行一次睁眼这之间的间隔必须在合理范围内否则视为异常。额外的保护是在执行人脸对比时对比的必须是活体检测完成后最后一帧的画面不能允许攻击者中途切换画面。5.4 部分安卓手机上人脸关键点偏移严重有些手机的自拍预览画面会镜像翻转导致人脸关键点坐标在左右方向是反的特别是转头动作检测时会判定方向相反。我在处理时将视频帧画面一律先做一次水平翻转不仅让用户看起来成像方向正常也能保证关键点方向坐标与算法预期一致。翻转用canvas绘制或直接CSS变换都可以但要保证在同一个坐标系下做关键点计算。5.5 低端机发热和卡顿手机端做神经网络推理热是不可避免的。性能优化最有效的一招是减少检测频率不要把每一帧视频都送进模型。在活体检测阶段由于需要快速识别眨眨眼从闭眼到睁眼最多只有几百毫秒所以建议保持10FPS的检测频率。在人脸对比阶段则降低到5FPS即可因为这时候人已经站在镜头前静止了。另一个优化方式是将视频画面绘制到小尺寸的canvas上再进行检测。比如640x480的视频画面被缩小到240x180后送进模型推理速度提升一倍以上。如果对检测精度要求高用原尺寸但要做节流。6. 性能优化与隐私合规细节6.1 模型缓存与离线使用方案落地部署时模型文件的加载是首屏最重的部分。实践后的做法是把模型文件打包进项目static目录并让容器服务器端设置缓存头让浏览器缓存长期有效。如果不能使用本地路径从CDN加载时要考虑跨域。另外一旦模型加载成功后就不再卸载切页面时插件实例销毁但不影响全局模型缓存下次进入时秒开。如果是私有化部署环境可以把模型文件作为静态资源预置在服务器前端配置的MODEL_URL指向相对路径完全离线运行。这样就能做到真正的零网络依赖不触发任何外部请求即使业务内网与外网隔离也能正常使用。6.2 隐私合规与官方审核注意事项纯前端人脸识别虽然在技术上不收集用户数据但依然需要遵循平台规则。在公众号网页或用微信浏览器环境如果做涉及人脸识别的业务建议配上单独的“人脸识别服务协议”弹窗明确写明数据仅在用户设备本地处理不上传服务器。为了合规可以再提供“退出识别”按钮。有些小程序或公众号平台有内容审核可能会要求人脸识别能力必须由平台提供。此时需要看业务实际调用的场景如果是作为身份核验的一种辅助手段配合用户手动授权后才能使用一般问题不大。但如果涉及金融级或法定身份证核实就可能需要权威第三方那纯前端方案只能作为普通身份确认参考不能作为唯一依据。所以建议在实际生产环境中将人脸识别结果作为“用户自证”而不是“权威认证”在页面上明确提示风险。6.3 代码体积与加载分包优化face-api.js库本身加上模型总体积比较大。为了首屏加载不卡顿我把包含人脸识别的组件做成了异步组件配合Vue Router的路由懒加载只有用户真正进入核身页面时才加载对应JS和模型文件。核心组件还使用了动态import()。这样的效果是主页首包体积骤降只有进入核身流程才会出现模型加载进度条。这个策略对移动端用户体验非常友好建议所有使用该方案的人都要加。还有一个小技巧是模型文件统一放在同一目录并使用静态import加载所有模型文件。理论上可以按需加载但实测如果分开加载中途用户断网会导致后续模型全部白屏所以还是一起加载比较稳妥。7. 结尾一点个人经验之谈纯前端人脸识别方案在H5端能跑通主要得益于两部分一个是浏览器运行深度学习模型能力成熟另一个是开源项目把复杂的模型转换和推理封装得很好。实际投入生产时难点反而不在模型选型而在于活体检测策略是否可靠、模型是否加载顺畅、摄像头兼容性是否处理好。我最终交付的示例源码里最重要的部分并不是face-api.js调用而是基于关键点坐标自定义的那套动作活体检测状态机以及对应的错误处理逻辑。建议你自己动手跑一遍先在电脑浏览器上用USB摄像头测试再搬到手机上测。你会发现很多坑只在真机上才会出现比如权限弹窗、镜像翻转、性能功耗。活体检测的阈值也不是绝对固定的必须根据自己的目标设备调参。比如我用不同款手机测同一个张嘴阈值在某些手机上要放大到0.75才识别稳定在另一些手机上0.5就误触发了。所以最终上线前一定要覆盖主要机型做一轮实机测试。我最后再分享一个小技巧给活体检测加一个“检测中”的进度状态让用户在做了某个动作但没有被系统识别到时看到“动作未识别请重试”的提示而不是静默失败。这个提示对用户引导体验的提升极其明显比任何算法调优都管用。如果你也在做类似方案建议把这段逻辑写在最外层它会让整个流程顺滑很多。