当前位置:   article > 正文

微信小程序开发考试答题监控系统_培训小程序 开源 过程抓拍

培训小程序 开源 过程抓拍

记录下两年前开发小程序答题监控功能的思路。因时间久远,同时删除了部分业务代码,没讲清楚的请多多包涵。

需求

  • 用户进入考试须知页面,人脸识别成功后,点击开始考试进入答题页面
  • 考试须知页面和答题页面控制考试总时长,控制每道题时长最多60秒
  • 考试过程中启用摄像头对用户进行抓拍监控,比对人脸数据

方案选择

        开始采用的方案是分别开发“考试须知”和“考试”两个独立页面,后因“考试”页面采用了tensorflow人脸识别,初始相机和加载模型耗时过长,遂将两个页面合并,让用户提前加载模型,同时两个页面是考试时长控制也统一起来了。其中最关键的视图容器就是page-container

主要容器和组件

1.page-container

功能描述:

        页面容器。

        小程序如果在页面内进行复杂的界面设计(如在页面内弹出半屏的弹窗、在页面内加载一个全屏的子页面等),用户进行返回操作会直接离开当前页面,不符合用户预期,预期应为关闭当前弹出的组件。 为此提供“假页”容器组件,效果类似于 popup 弹出层,页面内存在该容器时,当用户进行返回操作,关闭该容器不关闭页面。返回操作包括三种情形,右滑手势、安卓物理返回键和调用 navigateBack 接口。

        根据官方文档描述,我们使用page-container来构建一个类似弹出层的假的“子页面”。它的特性可以用于禁止用户在考试过程中返回上一页,如果是两个独立页面,很难实现这一点。

2.camera

功能描述:

        系统相机。扫码二维码功能,需升级微信客户端至6.7.3。需要用户授权 scope.camera。 2.10.0起 initdone 事件返回 maxZoom,最大变焦范围,相关接口 CameraContext.setZoom

        请注意原生组件使用限制

 页面结构预览

1.页面原型

2.整体结构 

  1. <page-meta page-style="{{ popShow||containerShow ? 'overflow: hidden;' : '' }}">
  2. <pageload id="pageload" bind:initPage="initPage" ...>
  3. <view>
  4. 考试须知页面
  5. </view>
  6. </pageload>
  7. <page-container show="{{containerShow}}" z-index="100" id="container"
  8. duration="{{duration}}"
  9. position="right"
  10. bind:enter="pageenter"
  11. bind:beforeleave="beforeleave"
  12. bind:afterleave="afterleave"
  13. >
  14. <pageload bind:initPage="initExamPage" requesting="true" id="examPage">
  15. <block wx:if="{{cameraShow}}">
  16. <view class="camera-container-out">
  17. <view class="camera-container-in">
  18. <camera
  19. device-position="front"
  20. flash="off"
  21. frame-size="small"
  22. binderror="cameraError"
  23. class="camera"
  24. ></camera>
  25. <canvas id="mark-canvas" class="mark-canvas" type="2d"></canvas>
  26. </view>
  27. </view>
  28. </block>
  29. <view class="titlebar">
  30. <view>
  31. 剩余答题时间:
  32. </view>
  33. </view>
  34. <view class="pageBody pageBody_sty">
  35. <view>
  36. 考试题目
  37. </view>
  38. <scroll-view scroll-y="true" scroll-top="{{top}}" style="height:{{viewHeight}}rpx;" show-scrollbar="false">
  39. <view class="item_con" wx:for="{{curr_question.trunk.options}}">
  40. 试题选项
  41. </view>
  42. </scroll-view>
  43. </view>
  44. <view class="foot-nav-panel">
  45. <view class="foot-nav-ul">
  46. <view class="li">
  47. <van-button type="primary" block custom-class="foot-btn"
  48. bind:click="showPop">
  49. 答题板
  50. </van-button>
  51. </view>
  52. <view class="li leftBorder">
  53. <van-button type="primary" block custom-class="foot-btn"
  54. data-id="{{curr_question.nextId}}"
  55. bind:click="nextQuestion">
  56. {{!curr_question.nextId?'交卷':'下一题'}}
  57. </van-button>
  58. </view>
  59. </view>
  60. </view>
  61. <van-popup show="{{popShow}}" closeable position="bottom" custom-style="height: 60%" bind:close="hidPop">
  62. <view class="serialNum">
  63. 答题板详情
  64. </view>
  65. </van-popup>
  66. </pageload>
  67. </page-container>
  68. </page-meta>

 3.局部分析

1)最外层组件page-meta

        页面属性配置节点,用于指定页面的一些属性、监听页面事件。只能是页面内的第一个节点。page-style属性:页面根节点样式,页面根节点是所有页面节点的祖先节点,相当于 HTML 中的 body 节点。

<page-meta page-style="{{ popShow||containerShow ? 'overflow: hidden;' : '' }}">

        当弹出层(答题板)或者子页面(答题页)显示时,父页面溢出隐藏,防止滑动操作时父页面滚动。

2)自定义组件pageload

功能描述

        页面初始化。打开页面后执行initPage方法,通过回调来判断展示正确或错误的页面信息

 wxml
  1. <view>
  2. <view wx:if="{{requesting===false}}">
  3. <view wx:if="{{status===false}}" class="error">
  4. <image class="error_image" src="{{errImg}}"></image>
  5. <view class="error_text">{{errText?errText:'未知错误'}}</view>
  6. <view class="error_btn">
  7. <!-- <view bindtap="refresh" class="refresh">
  8. <image class="icon" src="/assets/image/replay.png"/>
  9. </view> -->
  10. <button bindtap="refresh" class="btnDefault">刷新</button>
  11. <button bindtap="goback" class="btnBack marginTop2">返回</button>
  12. </view>
  13. </view>
  14. <view wx:if="{{status===true}}">
  15. <slot>
  16. </slot>
  17. </view>
  18. </view>
  19. </view>
 js
  1. Component({
  2. options: {
  3. addGlobalClass: true
  4. },
  5. properties: {
  6. requesting: {
  7. type: Boolean,
  8. value: false,
  9. observer: 'requestingEnd',
  10. },
  11. status: {
  12. type: Boolean,
  13. value: false
  14. },
  15. errText: {
  16. type: String,
  17. value: ''
  18. },
  19. errImg: {
  20. type: String,
  21. value: '/assets/image/empty-image-error.png'
  22. },
  23. cancelLoading:{
  24. type: Boolean,
  25. value: false
  26. }
  27. },
  28. data: {
  29. },
  30. methods: {
  31. /**
  32. * 监听 requesting 字段变化
  33. */
  34. requestingEnd(newVal, oldVal) {
  35. if(!this.data.cancelLoading){
  36. if (oldVal === true && newVal === false) {
  37. if(this.data.status===false&&this.data.errText==''){
  38. //兼容request.js中拦截500和404响应,toast提示不能立马关闭
  39. setTimeout(() => {
  40. wx.hideLoading()
  41. }, 2000);
  42. }else{
  43. wx.hideLoading()
  44. }
  45. } else {
  46. wx.showLoading({
  47. title: '加载中',
  48. mask: true
  49. })
  50. }
  51. }
  52. },
  53. init: function(){
  54. //防止重复调用
  55. if(this.data.requesting){
  56. return;
  57. }
  58. //插件初始化
  59. this.setData({
  60. requesting : true,
  61. status: false,
  62. errText: ''
  63. });
  64. this.network().then(() => {
  65. this.triggerEvent('initPage',{
  66. callback: res=>{
  67. this.setData(res);
  68. }
  69. });
  70. }).catch(res => {
  71. this.setData({
  72. requesting : false,
  73. status : false,
  74. errText : res
  75. });
  76. })
  77. },
  78. refresh: function(){
  79. this.init();
  80. },
  81. network: function() {
  82. return new Promise((resolve, reject) => {
  83. wx.getNetworkType({
  84. success: res => {
  85. if (res.networkType != 'none') {
  86. resolve();
  87. } else {
  88. reject("网络异常");
  89. }
  90. },
  91. })
  92. })
  93. },
  94. goback: function(){
  95. wx.navigateBack();
  96. }
  97. },
  98. pageLifetimes: {
  99. show: function() {
  100. // 页面被展示
  101. },
  102. hide: function() {
  103. // 页面被隐藏
  104. }
  105. },
  106. lifetimes: {
  107. attached: function() {
  108. // 在组件实例进入页面节点树时执行
  109. if(!this.data.requesting){
  110. this.init();
  111. }
  112. },
  113. }
  114. })
  115. /*
  116. 使用该组件的页面js中添加如下方法:
  117. initPage(e){
  118. let that = this;
  119. let callback = e.detail.callback;
  120. let url = app.globalData.url + "/...";
  121. request.requestPostApi(url,{},this,succRes=>{
  122. callback({
  123. status : succRes.status,
  124. errText : succRes.message
  125. });
  126. if(succRes.status){
  127. doSuccess...
  128. }
  129. },failRes=>{
  130. callback({
  131. status : false,
  132. errText : '未知错误'
  133. });
  134. },completeRes=>{
  135. callback({requesting:false});
  136. });
  137. }
  138. */

3)倒计时组件van-count-down 

本文使用的UI组件为Vant Weapp - 轻量、可靠的小程序 UI 组件库

  1. <view>剩余时间:
  2. <van-count-down use-slot
  3. class="control-count-down"
  4. time="{{ examInfo.remainingTime }}"
  5. bind:change="remainingTimeChange"
  6. bind:finish="remainingTimeFinish">
  7. <text class="time" wx:if="{{vantRemainingTime.days>0}}">{{ vantRemainingTime.days }}天</text>
  8. <text class="time" >{{ vantRemainingTime.hours>9?vantRemainingTime.hours:'0'+vantRemainingTime.hours }}时</text>
  9. <text class="time" >{{ vantRemainingTime.minutes>9?vantRemainingTime.minutes:'0'+vantRemainingTime.minutes }}分</text>
  10. <text class="time" >{{vantRemainingTime.seconds>9?vantRemainingTime.seconds:'0'+vantRemainingTime.seconds}}秒</text>
  11. </van-count-down>
  12. </view>

通过组件绑定事件remainingTimeChange来处理倒计时问题。除了计算整场考试剩余时间,可调用自定义函数去控制单个题目显示时长。

  1. remainingTimeChange(e) {
  2. this.setData({
  3. 'vantRemainingTime':e.detail
  4. });
  5. //自定义函数执行业务逻辑
  6. this.timeGo(e.detail);
  7. }

当小程序切换到后台,js定时器会停止,考试剩余时间就需要重新从服务端获取,可以在onShow中来触发。

  1. onHide(){
  2. this.setData({pageHide:true})
  3. },
  4. onShow(){
  5. this.setData({pageHide:false});
  6. //请求服务端更新考试剩余时间
  7. this.updateRemainingTime();
  8. }

4)父页面js

在onLoad中初始化子页面的滚动区域大小,在onReady中初始化tfjs插件(注:20230828,鸿蒙系统无法使用,暂未解决。该插件主要为在小程序端追踪人脸,实时提示用户保持头像居中,可整体移除。具体的考试监控由相机拍照上传至服务端后,异步执行)。tfjs插件使用教程可自行搜索。

  1. const app = getApp();
  2. const faces = require("../../../utils/face-storage.js");
  3. const request = require("../../../utils/request.js");
  4. const util = require("../../../utils/util.js");
  5. const examApi = require("../../../utils/exam.js");
  6. var fetchWechat = require('fetch-wechat');
  7. var tf = require('@tensorflow/tfjs-core');
  8. var webgl = require('@tensorflow/tfjs-backend-webgl');
  9. var plugin = requirePlugin('tfjsPlugin');
  10. const faceDetection = require('@tensorflow-models/face-detection');
  11. const log = require('../../../utils/log.js')
  12. onLoad(options) {
  13. util.triggerEvent('resetIsClick');
  14. this._cup2model = app.globalData.cup2model;
  15. var that = this;
  16. that.initViewHeight();
  17. //防止 setTimeout 和 setInterval 在页面返回后继续执行
  18. var taskId = setInterval(() => {
  19. let timeout;
  20. if(!util.isEmpty(that.data.examInfo)){
  21. timeout = that.data.examInfo.timeout;
  22. }
  23. if(timeout!=1){
  24. that.updateRemainingTime();
  25. }else{
  26. clearInterval(taskId);
  27. }
  28. }, 1000*60*10)
  29. that.setData({taskId,taskId});
  30. },
  31. initViewHeight: function(){
  32. var that = this;
  33. wx.getSystemInfoAsync({
  34. success: function(res) {
  35. let {windowHeight,windowWidth} = res;
  36. //页面高度减头部底部按钮高度,结合自身页面情况计算
  37. let rpxHeight = Math.floor(750*(windowHeight-105)/(windowWidth || 375))-280;
  38. that.setData({viewHeight:rpxHeight});
  39. }
  40. });
  41. },
  42. async onReady(){
  43. if(this.data.useTfjs){
  44. this.initTfjsPlugin();
  45. this.loadmodel();
  46. }
  47. },
  48. initTfjsPlugin(){
  49. let config = {
  50. // polyfill fetch function
  51. fetchFunc: fetchWechat.fetchFunc(),
  52. // inject tfjs runtime
  53. tf,
  54. // inject webgl backend
  55. webgl,
  56. // provide webgl canvas
  57. canvas: wx.createOffscreenCanvas()
  58. }
  59. plugin.configPlugin(config);
  60. },
  61. async loadmodel(){
  62. try {
  63. let start = new Date();
  64. const FILE_STORAGE_PATH = 'zjk_face_model';
  65. const fileStorageHandler = plugin.fileStorageIO(FILE_STORAGE_PATH,wx.getFileSystemManager());
  66. const model = faceDetection.SupportedModels.MediaPipeFaceDetector;
  67. const detectorConfig = {
  68. maxFaces: 2,
  69. runtime: 'tfjs'
  70. };
  71. detectorConfig.detectorModelUrl = this._modelUrl;
  72. this._model = await faceDetection.createDetector(model, detectorConfig);
  73. let end = new Date();
  74. log.info("下载模型成功,耗时:"+ (end-start)+" ms");
  75. if(!this._cup2model){
  76. this._model.estimateFaces(
  77. {
  78. data:new Uint8Array(),
  79. width:1,
  80. height:1
  81. },
  82. {flipHorizontal: false}
  83. ).then(e=>{
  84. log.info("模型运行成功,耗时:"+ (new Date()-end)+" ms")
  85. if(this._modelLoad){
  86. log.info("成功后关闭等待框")
  87. wx.hideLoading();
  88. }
  89. this._modelLoad = true;
  90. this._cup2model = true;
  91. app.globalData.cup2model = true;
  92. console.log("第一次模型运行成功")
  93. }).catch(e=>{
  94. log.error("模型运行失败,耗时:"+ (new Date()-end)+" ms")
  95. if(this._modelLoad){
  96. log.error("失败后关闭等待框")
  97. wx.hideLoading();
  98. }
  99. this._modelLoad = true;
  100. console.log("模型运行失败",e)
  101. })
  102. }
  103. } catch (error) {
  104. log.error("捕捉到异常信息"+JSON.stringify(error))
  105. if(this._modelLoad){
  106. log.error("捕捉到异常信息后关闭等待框")
  107. wx.hideLoading();
  108. }
  109. this._modelLoad = true;
  110. }
  111. //代码运行失败
  112. /* try{
  113. console.log("加载本地模型")
  114. detectorConfig.detectorModelUrl = fileStorageHandler;
  115. this._model = await faceDetection.createDetector(model, detectorConfig);
  116. }catch(e){
  117. console.log("加载本地模型失败",e)
  118. detectorConfig.detectorModelUrl = this._modelUrl;
  119. this._model = await faceDetection.createDetector(model, detectorConfig);
  120. console.log("加载网络模型成功")
  121. try{
  122. this._model.detectorModel.save(fileStorageHandler);
  123. }catch(e){
  124. console.log("保存本地模型失败",e)
  125. }
  126. } */
  127. }
  128. onShow(){
  129. this.setData({pageHide:false});
  130. this.updateRemainingTime();
  131. },
  132. onHide(){
  133. this.setData({pageHide:true})
  134. },
  135. onUnload(){
  136. if(this.data.taskId){
  137. clearInterval(this.data.taskId);
  138. }
  139. let countdown = this.selectComponent('.control-count-down');
  140. if(countdown){
  141. //返回上一页必须关闭定时器,否则会一直执行
  142. countdown.pause();
  143. }
  144. },
  145. /** 页面初始化,获取考试信息*/
  146. initPage(e){
  147. let that = this;
  148. let callback = e.detail.callback;
  149. let url = app.globalData.url + "/.../getExamInfo";
  150. request.requestPostApi(url,{},this,succRes=>{
  151. callback({
  152. status : succRes.status,
  153. errText : succRes.message
  154. });
  155. if(succRes.status){
  156. that.setData({
  157. examInfo : succRes.result
  158. });
  159. if(!that.data.examInfo.examing){//如果后台已经提交或者无考试,重置本地考试完成状态
  160. examApi.finish(false);
  161. }
  162. that.setData({localFinish:examApi.finish()})
  163. }
  164. },failRes=>{
  165. callback({
  166. status : false,
  167. errText : '未知错误'
  168. });
  169. },completeRes=>{
  170. callback({requesting:false});
  171. if(!this._cup2model&&!this._modelLoad){
  172. this._modelLoad = true;
  173. wx.showLoading({
  174. title: '加载中',
  175. mask: true
  176. })
  177. }
  178. });
  179. },
  180. timeBeforeChange(e) {
  181. this.setData({
  182. timeBefore: e.detail
  183. });
  184. },
  185. timeBeforeFinish(){
  186. this.setData({
  187. 'examInfo.timeout':0
  188. })
  189. },
  190. remainingTimeChange(e) {
  191. this.setData({
  192. 'vantRemainingTime':e.detail
  193. });
  194. this.timeGo(e.detail);
  195. },
  196. remainingTimeFinish(){
  197. this.setData({
  198. 'examInfo.timeout':1
  199. })
  200. if(this.data.examInfo.examing){
  201. this.submitExam();
  202. }
  203. },
  204. updateRemainingTime(){
  205. var that = this;
  206. if(!util.isEmpty(that.data.examInfo)&&that.data.examInfo.timeout!=1&&!(that.data.examInfo.answerCountCurrent==that.data.examInfo.answerCountMax&&!that.data.examInfo.examing&&that.data.examInfo.isSubmit)){
  207. request.requestPostApi(app.globalData.url + "/.../validateRemainingTime",{},this,succRes=>{
  208. if(succRes.status){
  209. that.updateExamInfo(succRes.result);
  210. }else{
  211. console.log("刷新时间失败:"+succRes.message);
  212. }
  213. },failRes=>{
  214. console.log("刷新时间失败");
  215. });
  216. }
  217. }

5)子页面js

  1. pageenter(){
  2. //打开子页面,执行initPage绑定方法
  3. let examPage = this.selectComponent("#examPage");
  4. examPage.setData({requesting:false});
  5. examPage.init();//执行的是initExamPage
  6. },
  7. beforeleave(){
  8. //隐藏子页面,停止摄像头
  9. this._listener&&this._listener.stop();
  10. this.setData({
  11. curr_question: {},
  12. containerShow:false
  13. })
  14. },
  15. afterleave(){
  16. // 可在该事件内阻止用户返回
  17. // if(!this.data.localFinish&&this.data.examInfo.timeout==0){
  18. // this.setData({containerShow:true});
  19. // setTimeout(() => {
  20. // request.toast("请保持答题页面至提交试卷")
  21. // }, 300);
  22. // }
  23. },
  24. initExamPage(e){
  25. let that = this;
  26. that.setData({isLoadingExam:false});
  27. let callback = e.detail.callback;
  28. let{questions,answers,currQuestion} = examApi.getAll();
  29. //中途退出,直接从本地缓存取题目
  30. if(!util.isEmpty(questions)&&!util.isEmpty(currQuestion)){
  31. console.log("从缓存中获取试题数据")
  32. var count = 0;
  33. for(let key in questions){
  34. count++;
  35. }
  36. that.setData({
  37. questions:questions,
  38. listCount: count,
  39. userAnswers: answers,
  40. curr_question:questions[currQuestion.id]
  41. })
  42. //作用:显示剩余时间后再callback
  43. if(!util.isEmpty(that.data.vantRemainingTime)){
  44. that.timeGo(that.data.vantRemainingTime);
  45. }
  46. callback({
  47. status : true,
  48. errText : '',
  49. requesting : false
  50. });
  51. //初始化相机
  52. that.initCamera();
  53. return;
  54. }
  55. let url = app.globalData.url + "/.../getExamQuestions";
  56. request.requestPostApi(url,{},this,succRes=>{
  57. callback({
  58. status : succRes.status,
  59. errText : succRes.message
  60. });
  61. if(succRes.status){
  62. let qs = succRes.result;
  63. let temp_questions = {};
  64. for(var i=0;i<qs.length;i++){
  65. temp_questions[qs[i].id]=qs[i];
  66. }
  67. that.setData({
  68. questions:temp_questions,
  69. listCount: qs.length
  70. })
  71. that.showQuestion(qs[0]["id"]);//显示第一题,在本地保存questions和curr_question
  72. that.updateRemainingTime();
  73. //初始化相机
  74. that.initCamera();
  75. }
  76. },failRes=>{
  77. callback({
  78. status : false,
  79. errText : '未知错误'
  80. });
  81. },completeRes=>{
  82. callback({requesting:false});
  83. });
  84. }
  85. initCamera(){
  86. console.log("显示相机和画布");
  87. this.setData({cameraShow:true});
  88. setTimeout(() => {//必须setTimeout,等待页面渲染完成
  89. try {
  90. if(!this._camera){
  91. console.log("创建相机")
  92. this._camera = wx.createCameraContext();
  93. this._camera.setZoom({
  94. zoom:1
  95. })
  96. }else{
  97. this._camera.setZoom({
  98. zoom:1
  99. })
  100. }
  101. if(this.data.useTfjs){//判断是否使用tfjs
  102. this.initCanvas();//初始画布,用于显示人脸追踪框
  103. }
  104. this.addCameraListener();
  105. this._listener.start();
  106. } catch (error) {
  107. console.log("exception:",error)
  108. }
  109. }, 1500);
  110. },
  111. initCanvas(){
  112. if(!this._canvas){
  113. console.log("创建画布")
  114. const query = wx.createSelectorQuery();
  115. query.select('#mark-canvas')
  116. .fields({ node: true, size: true })
  117. .exec((res) => {
  118. this._canvas = res[0].node;
  119. const canvas = res[0].node;
  120. const canvasContext = canvas.getContext('2d')
  121. const systemInfo = wx.getSystemInfoSync()
  122. //设备像素比
  123. const dpr = systemInfo.pixelRatio
  124. //画布像素
  125. canvas.width = res[0].width * dpr
  126. this._canvasWidthPix = canvas.width;
  127. canvas.height = res[0].height * dpr
  128. this._canvasHeightPix = canvas.height
  129. this._dpr = dpr;
  130. canvasContext.lineWidth = 3
  131. canvasContext.strokeStyle = 'red'
  132. canvasContext.fillStyle = 'yellow'
  133. this._canvasContext = canvasContext;
  134. })
  135. }
  136. },
  137. addCameraListener(){
  138. if(!this._listener){
  139. console.log("创建相机监听")
  140. this._listener = this._camera.onCameraFrame(async frame=> {
  141. //当使用tfjs时,每隔1秒(60帧)判断一次人脸位置
  142. if(this.data.useTfjs&&this.data.containerShow){
  143. if(this._frameWidth!=frame.width || this._frameHeight != frame.height){
  144. this._frameWidth = frame.width;
  145. this._frameHeight = frame.height;
  146. this._xRatio = Math.round(this._canvasWidthPix*1000 / frame.width)/1000;
  147. //等比例缩放后画布高的像素值
  148. let tempCanvasnHeightPix = Math.floor(frame.height*this._xRatio);
  149. //画布沿y轴偏移量
  150. this._yoffset = Math.floor((this._canvasHeightPix-tempCanvasnHeightPix)/2);
  151. console.log("初始化缩放倍数和偏移量",this._frameWidth,this._frameHeight,this._xRatio,this._yoffset)
  152. }
  153. this._count++;
  154. if (this._count === 60) {
  155. this.clearMarkCanvas();
  156. if(this._model){
  157. const res = await this.detectFace(frame);
  158. this.validateFace(res);
  159. }
  160. this._count = 0;
  161. }else if(this._count>60){
  162. this._count = 0;
  163. }
  164. }
  165. });
  166. }
  167. },
  168. async detectFace(frame) {
  169. const image = {
  170. data: new Uint8Array(frame.data),
  171. width: frame.width,
  172. height: frame.height
  173. }
  174. const estimationConfig = {flipHorizontal: false};
  175. return await this._model.estimateFaces(image, estimationConfig);
  176. },
  177. validateFace(res){
  178. //this._canvasContext.strokeRect(0,0,this._canvasWidthPix,this._canvasHeightPix);
  179. let msg = "";
  180. if(res.length<1){
  181. msg = '请保持头像居中';
  182. this.setData({faceError:msg});
  183. return;
  184. }
  185. /* if(res.length>1){
  186. msg = '检测到多人';
  187. this.setData({faceError:msg});
  188. return;
  189. } */
  190. const face = res[0];
  191. //画关键点
  192. // const keypoints = face.keypoints
  193. // for(let i=0; i<6; ++i){
  194. // const point = this.transformPoint([keypoints[i].x,keypoints[i].y])
  195. // this._canvasContext.fillRect(point[0],point[1],6,6)
  196. // }
  197. const box = face.box;
  198. const start = this.transformPoint([box.xMin,box.yMin]);
  199. const end = this.transformPoint([box.xMax,box.yMax]);
  200. let size = [end[0] - start[0], end[1] - start[1]]
  201. //画人脸预测框
  202. //this._canvasContext.strokeRect(start[0], start[1], size[0], size[1]);
  203. /* if (size[0] < 0.2 * this._canvasWidthPix){
  204. msg = '距离太远';
  205. this.setData({faceError:msg});
  206. return;
  207. } */
  208. if (size[0] > this._canvasWidthPix*1.05){
  209. msg = '距离太近';
  210. this.setData({faceError:msg});
  211. return;
  212. }
  213. if(start[0] < -0.06 * this._canvasWidthPix){
  214. msg = "头像偏左,";
  215. }else if(end[0] > 1.06 * this._canvasWidthPix){
  216. msg = "头像偏右,";
  217. }else if(start[1] < 0.14 * this._canvasHeightPix){
  218. msg = "头像偏上,";
  219. }else if(end[1] > 1.06 * this._canvasHeightPix){
  220. msg = "头像偏下,";
  221. }
  222. if(msg){
  223. this.setData({faceError:msg+'请保持头像居中'});
  224. return;
  225. }
  226. this.setData({faceError:""});
  227. },
  228. transformPoint(point){
  229. const x = Math.floor(point[0] * this._xRatio);
  230. const y = Math.floor(point[1] * this._xRatio)+this._yoffset;
  231. return [x,y]
  232. },
  233. clearMarkCanvas(){
  234. this._canvasContext&&this._canvasContext.clearRect(0,0,this._canvasWidthPix,this._canvasHeightPix)
  235. },
  236. cameraError(e){
  237. request.toast("摄像头打开失败,请退出重试或更换手机!")
  238. }

如果要使用tfjs,请参考下面摄像头和画布的关系。画布透明地覆盖在摄像头上。如果要求隐形监控的话,可以将相机组件长宽设置为1px,同时,在onCameraFrame中截取视频帧图片进行上传,以达到隐形效果。

因本案例无特殊要求,所以采用了最简单的方式实现监控:每间隔一道题拍一次照片上传至服务端。这样做的弊端是,苹果用户拍照会有系统提示声(甲方没说就懒得改了...)

  1. <block wx:if="{{cameraShow}}">
  2. <view class="camera-container-out">
  3. <view class="camera-container-in">
  4. <camera
  5. device-position="front"
  6. flash="off"
  7. frame-size="small"
  8. binderror="cameraError"
  9. class="camera"
  10. ></camera>
  11. <canvas id="mark-canvas" class="mark-canvas" type="2d"></canvas>
  12. </view>
  13. </view>
  14. </block>
  1. .camera-container-out {
  2. position: absolute;
  3. top:100rpx;
  4. right: 30rpx;
  5. display: flex;
  6. flex-direction: column;
  7. align-items: center;
  8. z-index: 101;
  9. }
  10. .camera-container-in {
  11. display: flex;
  12. justify-content: flex-start;
  13. }
  14. .camera{
  15. width: 50px;
  16. height: 70px;
  17. z-index: 101;
  18. }
  19. .mark-canvas{
  20. position: absolute;
  21. width: 50px;
  22. height: 70px;
  23. z-index: 102;
  24. }

6)拍照上传

图片保存使用阿里云oss服务。人脸比对也用的是阿里云服务。

  1. takePhoto() {
  2. const that = this;
  3. if(!this._camera){
  4. setTimeout(() => {
  5. that.takePhoto();
  6. }, 1100);
  7. return;
  8. }
  9. this._camera.takePhoto({
  10. quality: 'high',
  11. success: (res) => {
  12. let localSrc = res.tempImagePath;
  13. let type = localSrc.substring(localSrc.lastIndexOf("."));
  14. console.log("开始上传");
  15. let policy = this._policy;
  16. let signature = this._signature;
  17. let oSSAccessKeyId = this._OSSAccessKeyId;
  18. let date = new Date();
  19. let filePath = that.data.examInfo.themeId+"/"+that.data.examInfo.examUser+"/"+date.Format('yyyyMMddhhmmssS')+type;
  20. let uploadUrl = "https://域名.oss-cn-shanghai.aliyuncs.com";
  21. wx.uploadFile({
  22. url: uploadUrl, // 开发者服务器的URL。
  23. filePath: localSrc,
  24. name: 'file', // 必须填file。
  25. formData: {
  26. key: filePath,
  27. policy: policy,
  28. OSSAccessKeyId: oSSAccessKeyId,
  29. signature: signature
  30. },success: (res) => {
  31. if (res.statusCode === 204||res.statusCode === 200) {
  32. let photos = examApi.photos();
  33. photos.push("/"+filePath);
  34. let photoTimes = examApi.photoTimes();
  35. photoTimes.push(date.getTime());
  36. examApi.updatePhotoDatas(photos,photoTimes);
  37. }
  38. },
  39. fail: err => {
  40. console.log(err);
  41. }
  42. });
  43. },fail: err =>{
  44. console.log(err);
  45. }
  46. })
  47. }

7)注意事项

因tfjs依赖包比较大,考试模块建议采用分包开发。本案例中tfjs依赖包如下:

"dependencies": {

    "node-fetch": "2.6.1",

    "@mediapipe/face_detection": "0.4.1646425229",

    "@tensorflow-models/face-detection": "1.0.1",

    "@tensorflow/tfjs-backend-webgl": "3.20.0",

    "@tensorflow/tfjs-converter": "3.20.0",

    "@tensorflow/tfjs-core": "3.20.0",

    "fetch-wechat": "0.0.3"

  }

其他注意事项:

防止页面重复打开;防止页面返回后定时任务和相机监控继续执行;防止tfjs初始化失败对业务的影响;相机组件要在页面渲染完成后初始化,否则会错位。

最后

自认为这边文章写得很水,感谢阅读x3。如有问题请留意。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/空白诗007/article/detail/923850
推荐阅读
相关标签
  

闽ICP备14008679号