微信小程序使用camera + wx.faceDetect 人脸识别录制人脸视频

写在前面:

注意:小程序官方不支持采集人脸等用户隐私信息,采集用户信息建议使用官方提供的人脸核身接口,其他任何第三方的都审核不过。人脸核身需要一些资质(部分类目的小程序)。

参考官方说明:
1.微信人脸核身接口能力说明
2. 腾讯人脸核身SDK接入

本案例是另外一种实现方式,使用了<camera>组件和wx.faceDetect小程序API,只是作代码演示,并不建议在生产中使用,因为可能过不了审核。

录制思路:

  1. 使用小程序的 <camera>组件 和 CameraContext.startRecord等接口开启摄像头录制。

  2. 使用wx.faceDetect()人脸识别接口对摄像头的视频流帧进行识别(检测是否是人脸且是正脸)。

需要注意的是:

  1. 用户是否授权摄像头和录音。
  2. 用户的微信版本是否可以调用wx.faceDetect接口(基础库:2.18.0)。
  3. 用户人脸移出/不是正脸取消录制,并在正脸时重新录制。
  4. 准备录制-录制中-录制完成几种状态文案切换,还有一句录制中倒计时提示。
  5. 视频帧检测调用函数节流,防止调用wx.faceDetect过于频繁引起卡顿(影响识别到人脸的时间)。
  6. 开发者工具开启增强编译,修改成你的appid,且需要真机预览,调试和在pc模拟器中会报错。

截图案例 (非真实截图):

录像组件核心代码:

<!--components/camera-face/index.wxml-->
<!--人脸识别录像组件-->
<view class="page-body">
    <view class="camera-box">
        <camera
            mode="normal"
            device-position="{{devicePosition}}"
            frame-size="{{frameSize}}"
            resolution="{{resolution}}"
            flash="{{flash}}"
            binderror="error"
            bindstop="stop"
            class="camera">
        </camera>
        <view class="img-view">
            <image mode="scaleToFill" class="mask-img" src="../../static/images/mask.png"></image>
        </view>
    </view>
    <view class="bottom-view">
        <view wx:if="{{!bottomTips}}" class="bottom-btn" bindtap="readyRecord">准备录制人脸</view>
        <view wx:else class="bottom-tips">{{bottomTips}}</view>
    </view>
</view>

// components/camera-face/index.js

import { getAuthorize, setAuthorize, throttle, checkVersion } from './utils'

// 提示信息
const tips = {
  ready: '请确保光线充足,正面镜头',
  recording: '人脸录制中..',
  complete: '已录制完成',
  error: '录制失败'
}

Component({

  // 组件的属性列表
  properties: {
    // 人脸整体可信度 [0-1], 参考wx.faceDetect文档的res.confArray.global
    // 当超过这个可信度且正脸时开始录制人脸, 反之停止录制
    faceCredibility: {
      type: Number,
      value: 0.5
    },
    // 人脸偏移角度正脸数值参考wx.faceDetect文档的res.angleArray
    // 越接近0越正脸,包括p仰俯角(pitch点头), y偏航角(yaw摇头), r翻滚角(roll左右倾)
    faceAngle: {
      type: Object,
      value: { p: 0.5, y: 0.5, r: 0.5 }
    },
    // 录制视频时长,不能超过30s
    duration: {
      type: Number,
      value: 3000
    },
    // 是否压缩视频
    compressed: {
      type: Boolean,
      value: false
    },
    // 前置或者后置 front,back
    devicePosition: {
      type: String,
      value: 'front'
    },
    // 指定期望的相机帧数据尺寸 small,medium,large
    frameSize: {
      type: String,
      value: 'medium'
    },
    // 分辨率 low,medium,high
    resolution: {
      type: String,
      value: 'medium'
    },
    // 闪光灯 auto,on,off,torch
    flash: {
      type: String,
      value: 'off'
    },
    // 检测视频帧的节流时间,默认500毫秒执行一次
    throttleFrequency: {
      type: Number,
      value: 500
    }
  },

  // 组件页面的生命周期
  pageLifetimes: {
    // 页面被隐藏
    hide: function() {
      this.stop()
    },
  },
  detached: function() {
    // 在组件实例被从页面节点树移除时执行
    this.stop()
  },

  // 组件的初始数据
  data: {
    isReading: false, // 是否在准备中
    isRecoding: false, // 是否正在录制中
    isStopRecoding: false, // 是否正在停止录制中
    bottomTips: '', // 底部提示文字
  },

  /**
   * 组件的方法列表
   */
  methods: {

    // 开启相机ctx
    async start() {
      const result = await this.initAuthorize();
      if (!result) return false;
      if (!this.ctx) this.ctx = wx.createCameraContext();
      return true;
    },

    // 准备录制
    async readyRecord() {
      if (this.data.isReading) return
      this.setData({ isReading: true })
      wx.showLoading({ title: '加载中..', mask: true })
      // 检测版本号
      const canUse = checkVersion('2.18.0', () => {
        this.triggerEvent('cannotUse')
      })
      if (!canUse) {
        wx.hideLoading()
        this.setData({ isReading: false })
        return
      }

      // 启用相机
      try {
        const result = await this.start()
        if (!result || !this.ctx) throw new Error()
      } catch (e) {
        wx.hideLoading()
        this.setData({ isReading: false })
        return
      }
      console.log('准备录制')
      this.setData({ bottomTips: tips.ready })
      // 视频帧回调节流函数
      let fn = throttle((frame) => {
        // 人脸识别
        wx.faceDetect({
          frameBuffer: frame.data,
          width: frame.width,
          height: frame.height,
          enableConf: true,
          enableAngle: true,
          success: (res) => this.processFaceData(res),
          fail: (err) => this.cancel()
        })
      }, this.properties.throttleFrequency);

      // 初始化人脸识别
      wx.initFaceDetect({
        success: () => {
          const listener = this.listener = this.ctx.onCameraFrame((frame) => fn(frame));
          listener.start();
        },
        fail: (err) => {
          console.log('初始人脸识别失败', err)
          this.setData({ bottomTips: '' })
          wx.showToast({ title: '初始人脸识别失败', icon: 'none' })
        },
        complete: () => {
          wx.hideLoading()
          this.setData({ isReading: false })
        }
      })
    },

    // 处理人脸识别数据
    processFaceData(res) {
      if(res.confArray && res.angleArray) {
        const { global } = res.confArray;
        const g = this.properties.faceCredibility;
        const { pitch, yaw, roll } = res.angleArray;
        const { p, y, r } = this.properties.faceAngle;
        console.log('res.confArray.global:', global)
        console.log('res.angleArray:',  pitch, yaw, roll)
        const isGlobal = global >= g;
        const isPitch = Math.abs(pitch) <= p;
        const isYaw = Math.abs(yaw) <= y;
        const isRoll = Math.abs(roll) <= r;
        if( isGlobal && isPitch && isYaw && isRoll ){
          console.log('人脸可信,且是正脸');
          if (this.data.isRecoding || this.data.isCompleteRecoding) return
          this.setData({ isRecoding: true });
          this.startRecord(); // 开始录制
        }else {
          console.log('人脸不可信,或者不是正脸');
          this.cancel()
        }
      }else {
        console.log('获取人脸识别数据失败', res);
        this.cancel()
      }
    },

    // 开始录制
    startRecord() {
      console.log('开始录制')
      this.ctx.startRecord({
        success: (res) => {
          this.setRecordingTips();
          this.timer = setTimeout(() => {
            this.completeRecord()
          }, this.properties.duration)
        },
        timeoutCallback: (res) => {
          // 超过30s或页面 onHide 时会结束录像
          this.stop();
        },
        fail: () => this.stop()
      })
    },
    // 设置录制中的提示文字和倒计时
    setRecordingTips() {
      let second = (this.properties.duration / 1000);
      if (this.interval) clearInterval(this.interval);
      this.interval = setInterval(() => {
        console.log('xxxxxx', second);
        this.setData({
          bottomTips: tips.recording + second-- + 's'
        })
        if (second <= 0) clearInterval(this.interval);
      }, 1000)
    },

    // 完成录制
    completeRecord() {
      console.log('完成录制');
      this.setData({ isCompleteRecoding: true })
      this.ctx.stopRecord({
        compressed: this.properties.compressed,
        success: (res) => {
          this.setData({ bottomTips: tips.complete })
          // 向外触发完成录制的事件
          this.triggerEvent('complete', res.tempVideoPath)
        },
        fail: () => this.stop(),
        complete: () => {
          this.listener.stop();
          wx.stopFaceDetect();
          clearInterval(this.interval);
          this.setData({ isCompleteRecoding: false })
        }
      })
    },
    // 人脸移出等取消录制
    cancel() {
      console.log('取消录制');
      // 如果不在录制中或者正在录制完成中就不能取消
      if (!this.data.isRecoding || this.data.isCompleteRecoding) return
      clearTimeout(this.timer);
      clearInterval(this.interval);
      this.ctx.stopRecord({
        complete: () => {
          console.log('取消录制成功');
          this.setData({ bottomTips: tips.ready, isRecoding: false });
        }
      });
    },
    // 用户切入后台等停止使用摄像头
    stop() {
      console.log('停止录制');
      clearTimeout(this.timer);
      clearInterval(this.interval);
      if(this.listener) this.listener.stop();
      if (this.ctx && !this.data.isCompleteRecoding) this.ctx.stopRecord()
      wx.stopFaceDetect();
      setTimeout(() => {
        this.setData({ bottomTips: '', isRecoding: false })
      }, 500)
    },
    // 用户不允许使用摄像头
    error(e) {
      // const cameraName = 'scope.camera';
      // this.triggerEvent('noAuth', cameraName)
    },

    // 初始相机和录音权限
    async initAuthorize() {
      const cameraName = 'scope.camera';
      const recordName = 'scope.record';
      const scopeCamera = await getAuthorize(cameraName);
      // 未授权相机
      if (!scopeCamera) {
        // 用户拒绝授权相机
        if (!(await setAuthorize(cameraName))) this.openSetting();
        return false;
      }
      const scopeRecord = await getAuthorize(recordName);
      if (!scopeRecord) {
        // 用户拒绝授权录音
        if (!(await setAuthorize(recordName))) {
          this.openSetting();
          return false;
        }
      }
      return true;
    },

    // 打开设置授权
    openSetting() {
      wx.showModal({
        title: '开启摄像头和录音权限',
        showCancel: true,
        content: '是否打开?',
        success: (res) => {
          this.triggerEvent('noAuth', '打开设置授权')
          if (res.confirm) {
            wx.openSetting();
          }
        }
      });
    }
  }
})

页面使用核心代码:

<!-- pages/page2/index.wxml -->
<view class="page">
  <!-- 父元素一定要有高度 -->
  <camera-face id="cameraFace" bind:noAuth="handleNoAuth" bind:complete="handleComplete" bind:cannotUse="handleCannotuse" />
  
  <view class="preview-tips">预览视频</view>
  <video wx:if="{{videoSrc}}" class="video" src="{{videoSrc}}"></video>
</view>
// pages/page2/index.js
Page({

  onHide() {
    // 在录制中退出后台页面隐藏,返回上一页,确保重新进入当前页
    // 防止在录制中退出后台导致下次重新录制失败 "operateCamera:fail:is stopping"
    console.log('页面隐藏')
    if (this.data.isBack) wx.navigateBack()
  },

  onShow() {
    console.log('页面显示')
    this.setData({ isBack: true })
  },

  data: {
    videoSrc: '', // 录制的视频临时路径
    isBack: false // 是否返回上一页,用于页面隐藏时判断
  },

  // 当取消授权或者打开设置授权
  handleNoAuth(res) {
    console.log("用户拒绝授权:", res);
    // 因为在设置里授权摄像头不会立即生效,所以要返回上一页,确保重新进入当前页使摄像头生效
    setTimeout(() => {
      wx.navigateBack()
    }, 500)
  },

  // 版本号过低的回调
  handleCannotuse() {
    console.log('版本号过低无法使用, 组件内已经弹窗提示过了');
    wx.navigateBack()
  },

  // 视频录制完成
  handleComplete(e) {
    console.log('视频文件路径:', e.detail)
    // e.detail: 视频临时路径
    this.setData({ videoSrc: e.detail, isBack: false })

    // 打印视频信息文件
    wx.getFileInfo({
      filePath: e.detail,
      success: (res) => {
        const { size } = res
        console.log("视频文件大小M:", size / Math.pow(1024, 2));
      },
      fail: (err) => {
        console.log("获取视频文件失败", err);
      }
    })
  }
})

完整代码示例:github仓库

总结:

  1. 自定义的<camera-face>组件向外触发了 noAuth用户未授权摄像录音、cannotUse不可使用、complete录制完成事件,你也可以自定义修改组件触发更多的事件。

  2. wx.faceDetect只是进行人脸匹配检测,目前(2021.11)没有活体检测/身份识别功能,如果需要,简单的活体检测可以自己写写摇头抬头;或者更靠谱的上传人脸视频到后端处理/直接采用第三方的活体检测/身份识别接口功能。

  3. wx.faceDetect是否存在平台兼容/手机差异问题?目前没发现,但社区有人遇到过问题,需要详细测试。

  4. 如果代码发现可优化,欢迎提issue改进。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容