网站页头seo点击工具
一、需求分析
这次是基于一个Spring Boot +Vue的在线考试系统进行二次开发,添加人脸识别功能以防止学生替考。其他有对应场景的也可按需接入API,方法大同小异。
主要有以下两个步骤:
- 人脸录入:将某个角色(如学生)的人脸绑定其唯一属性(如学号)录入人脸库
- 人脸搜索(人脸识别):传递当前用户唯一属性(如学号)+ 摄像头图像给后台,在人脸库中进行匹配
二、腾讯云官网开通人脸服务
-
注册并进入官网:https://cloud.tencent.com/
-
主页搜索人脸识别,并进入产品控制台开通服务
-
创建人员库(注意人员库ID,后续会使用)
-
阅读查看官网API文档
三、后端开发
依赖(腾讯云核心SDK)
<dependency><groupId>com.tencentcloudapi</groupId><artifactId>tencentcloud-sdk-java</artifactId><version>3.1.830</version></dependency>
配置
tencent:face:secret-id: xxxsecret-key: xxxregion: ap-guangzhougroup-id: exam_stu_face
import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.iai.v20200303.IaiClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;@Configuration
public class TencentCloudConfig {@Value("${tencent.face.secret-id}")private String secretId;@Value("${tencent.face.secret-key}")private String secretKey;@Value("${tencent.face.region}")private String region;@Value("${tencent.face.group-id}")private String groupId;@Beanpublic Credential credential() {return new Credential(secretId, secretKey);}@Beanpublic IaiClient iaiClient() {return new IaiClient(credential(), region);}public String getGroupId() {return groupId;}
}
控制器
import com.mindskip.xzs.service.tencentcloud.FaceService;
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;import java.util.HashMap;
import java.util.Map;@Slf4j
@RestController
@RequestMapping("/api/face")
public class FaceController {@Autowiredprivate FaceService faceService;/*** 人脸注册接口** @param studentId* @param file* @return*/@PostMapping(value = "/register", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)public ResponseEntity<Map<String, Object>> handleRegistration(@RequestParam("studentId") String studentId,@RequestParam("file") MultipartFile file) {Map<String, Object> responseBody = new HashMap<>();try {faceService.registerFace(studentId, file);log.info("人脸录入成功");responseBody.put("code", 200);responseBody.put("message", "人脸录入成功");return ResponseEntity.ok().body(responseBody);} catch (TencentCloudSDKException e) {log.error("Tencent Cloud SDK Exception: ", e);String errorMsg = parseTencentError(e);responseBody.put("code", 500);responseBody.put("message", errorMsg);return ResponseEntity.status(500).body(responseBody);} catch (IllegalArgumentException e) {log.error("参数错误:{}", e.getMessage());responseBody.put("code", 400);responseBody.put("message", e.getMessage());return ResponseEntity.badRequest().body(responseBody);} catch (Exception e) {log.error("系统异常:", e);responseBody.put("code", 500);responseBody.put("message", "系统异常");return ResponseEntity.status(500).body(responseBody);}}/*** 人脸验证接口** @param studentId* @param file* @return*/@PostMapping(value = "/verify", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)public ResponseEntity<Map<String, Object>> handleVerification(@RequestParam("studentId") String studentId,@RequestParam("file") MultipartFile file) {Map<String, Object> responseBody = new HashMap<>();try {boolean isValid = faceService.verifyFace(studentId, file);log.info("人脸验证结果:{}", isValid);responseBody.put("code", 200);responseBody.put("success", isValid);responseBody.put("message", isValid ? "人脸验证成功" : "人脸验证失败");return ResponseEntity.ok().body(responseBody);} catch (TencentCloudSDKException e) {log.error("Tencent Cloud SDK Exception: ", e);String errorMsg = parseTencentError(e);responseBody.put("code", 500);responseBody.put("success", false);responseBody.put("message", errorMsg);return ResponseEntity.status(500).body(responseBody);} catch (IllegalArgumentException e) {log.error("参数错误:{}", e.getMessage());responseBody.put("code", 400);responseBody.put("success", false);responseBody.put("message", e.getMessage());return ResponseEntity.badRequest().body(responseBody);} catch (Exception e) {log.error("系统异常:", e);responseBody.put("code", 500);responseBody.put("success", false);responseBody.put("message", "系统异常");return ResponseEntity.status(500).body(responseBody);}}// 补充错误码解析private String parseTencentError(TencentCloudSDKException e) {// 具体错误码处理逻辑if (e.getMessage().contains("InvalidParameterValue.PersonIdAlreadyExist")) {return "该考生已存在人脸信息";}if (e.getMessage().contains("InvalidParameterValue.FaceNotExist")) {return "人脸信息不存在";}if (e.getMessage().contains("InvalidParameterValue.NoFaceInPhoto")) {return "照片中未检测到人脸";}return "腾讯云服务异常:" + e.getMessage();}
}
服务层
import com.mindskip.xzs.configuration.tencentcloud.TencentCloudConfig;
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
import com.tencentcloudapi.iai.v20200303.IaiClient;
import com.tencentcloudapi.iai.v20200303.models.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;import java.io.IOException;
import java.util.Base64;@Service
public class FaceService {@Autowiredprivate IaiClient iaiClient;@Autowiredprivate TencentCloudConfig config;/*** 录入人脸** @param studentId* @param imageFile* @throws IOException* @throws TencentCloudSDKException*/public void registerFace(String studentId, MultipartFile imageFile)throws IOException, TencentCloudSDKException {// 1. 人脸检测DetectFaceRequest detectRequest = new DetectFaceRequest();detectRequest.setImage(base64Encode(imageFile.getBytes()));DetectFaceResponse detectResponse = iaiClient.DetectFace(detectRequest);// 验证检测结果if (detectResponse.getFaceInfos() == null) {throw new IllegalArgumentException("照片中必须包含且仅包含一张人脸");}// 2. 创建人员并添加人脸CreatePersonRequest createRequest = new CreatePersonRequest();createRequest.setGroupId(config.getGroupId());createRequest.setPersonId(studentId);createRequest.setPersonName("考生_" + studentId);createRequest.setImage(base64Encode(imageFile.getBytes()));iaiClient.CreatePerson(createRequest);}/*** 人脸验证** @param studentId* @param imageFile* @return* @throws IOException* @throws TencentCloudSDKException*/public boolean verifyFace(String studentId, MultipartFile imageFile)throws IOException, TencentCloudSDKException {// 1. 人脸检测DetectFaceRequest detectRequest = new DetectFaceRequest();detectRequest.setImage(base64Encode(imageFile.getBytes()));DetectFaceResponse detectResponse = iaiClient.DetectFace(detectRequest);// 验证检测结果if (detectResponse.getFaceInfos() == null) {throw new IllegalArgumentException("照片中必须包含且仅包含一张人脸");}// 2. 人脸搜索SearchPersonsRequest searchRequest = new SearchPersonsRequest();searchRequest.setGroupIds(new String[]{config.getGroupId()});searchRequest.setImage(base64Encode(imageFile.getBytes()));searchRequest.setMaxPersonNum(1L); // 最多返回1个结果SearchPersonsResponse searchResponse = iaiClient.SearchPersons(searchRequest);// 3. 验证结果if (searchResponse.getResults() != null && searchResponse.getResults().length > 0) {Result result = searchResponse.getResults()[0];if (result.getCandidates() != null && result.getCandidates().length > 0) {Candidate candidate = result.getCandidates()[0];// 判断匹配的用户ID且置信度大于80(阈值可根据需求调整)return studentId.equals(candidate.getPersonId()) && candidate.getScore() > 80;}}return false;}private String base64Encode(byte[] bytes) {return Base64.getEncoder().encodeToString(bytes);}
}
四、前端开发
人脸录入
人脸录入弹窗组件<template><el-dialogtitle="人脸录入":visible.sync="visible"width="800px"@close="handleClose"><div class="capture-container"><div class="capture-layout"><!-- 左侧输入区域 --><div class="input-section"><!-- 摄像头预览 --><div v-show="captureMode === 'camera'" class="camera-preview"><video ref="video" autoplay class="video"></video><canvas ref="canvas" class="canvas" style="display: none;"></canvas><el-buttontype="primary"@click="capture"class="capture-btn">拍照</el-button></div><!-- 图片上传 --><el-uploadv-show="captureMode === 'upload'"class="avatar-uploader"action="#":show-file-list="false":before-upload="beforeUpload":http-request="handleUpload"><img v-if="imageUrl" :src="imageUrl" class="avatar"><div v-else class="uploader-default"><i class="el-icon-plus avatar-uploader-icon"></i><div class="upload-tip">上传清晰正面照(支持JPG/PNG)</div></div></el-upload></div><!-- 右侧预览区域 --><div class="preview-section"><div class="preview-title">照片预览</div><div class="preview-content"><img v-if="imageUrl" :src="imageUrl" class="preview-image"><div v-else class="preview-placeholder"><i class="el-icon-picture-outline"></i><p>预览区域</p></div></div></div></div><!-- 模式切换 --><div class="mode-switch"><el-radio-group v-model="captureMode"><el-radio-button label="camera">摄像头拍摄</el-radio-button><el-radio-button label="upload">图片上传</el-radio-button></el-radio-group></div></div><div slot="footer"><el-button @click="visible = false">取消</el-button><el-buttontype="primary":disabled="!imageData"@click="submitFace">确认提交</el-button></div></el-dialog>
</template><script>
import { registerCamera, stopCamera } from '@/utils/camera'
import { compressImage } from '@/utils/image'
import { post } from '@/utils/request'export default {data () {return {visible: false,captureMode: 'camera',imageUrl: '',imageData: null,studentId: null,mediaStream: null}},methods: {open (studentId) {this.studentId = studentIdthis.visible = truethis.$nextTick(() => {if (this.captureMode === 'camera') {this.initCamera()}})},async initCamera () {try {this.mediaStream = await registerCamera(this.$refs.video)} catch (error) {this.$message.error('摄像头访问失败,请检查权限')this.captureMode = 'upload'}},capture () {const video = this.$refs.videoconst canvas = this.$refs.canvascanvas.width = video.videoWidthcanvas.height = video.videoHeightcanvas.getContext('2d').drawImage(video, 0, 0)canvas.toBlob(async blob => {this.imageData = await compressImage(blob)this.imageUrl = URL.createObjectURL(this.imageData)}, 'image/jpeg', 0.8)},async beforeUpload (file) {const isImage = ['image/jpeg', 'image/png'].includes(file.type)if (!isImage) {this.$message.error('只能上传JPG/PNG格式图片')return false}return true},async handleUpload ({ file }) {try {this.imageData = await compressImage(file)this.imageUrl = URL.createObjectURL(this.imageData)} catch (error) {this.$message.error('图片处理失败')}},async submitFace () {try {const formData = new FormData()formData.append('file', this.imageData)formData.append('studentId', this.studentId)console.log(this.studentId)console.log(formData)const res = await post('/api/face/register', formData)if (res.code === 200) {this.$message.success('人脸录入成功')this.visible = false} else {this.$message.error(res.message || '录入失败')}} catch (error) {this.$message.error('请求失败,请稍后重试')}},handleClose () {if (this.mediaStream) {stopCamera(this.mediaStream)}this.imageUrl = ''this.imageData = null}},watch: {captureMode (newVal) {if (newVal === 'camera') {this.initCamera()} else if (this.mediaStream) {stopCamera(this.mediaStream)this.mediaStream = null}}}
}
</script><style scoped>
.capture-layout {display: flex;gap: 20px;margin-bottom: 20px;
}.input-section,
.preview-section {flex: 1;min-width: 0;
}.preview-section {border: 1px dashed #d9d9d9;border-radius: 6px;padding: 10px;
}.preview-title {color: #606266;font-size: 14px;margin-bottom: 10px;text-align: center;
}.preview-content {height: 340px;display: flex;justify-content: center;align-items: center;
}.preview-image {max-width: 100%;max-height: 100%;object-fit: contain;
}.preview-placeholder {text-align: center;color: #999;
}.preview-placeholder i {font-size: 40px;margin-bottom: 10px;
}.camera-preview {position: relative;height: 360px;border: 1px dashed #d9d9d9;border-radius: 6px;
}.video, .canvas {width: 100%;height: 100%;object-fit: cover;
}.capture-btn {position: absolute;bottom: 20px;left: 50%;transform: translateX(-50%);
}.avatar-uploader {height: 360px;
}.avatar {max-width: 100%;max-height: 400px;
}.uploader-default {text-align: center;
}.upload-tip {margin-top: 10px;color: #999;
}.mode-switch {margin-top: 20px;text-align: center;
}
</style>
摄像头访问/停止js
export const registerCamera = async (videoElement) => {const constraints = {video: {width: { ideal: 1280 },height: { ideal: 720 },facingMode: 'user'}}const stream = await navigator.mediaDevices.getUserMedia(constraints)videoElement.srcObject = streamawait new Promise(resolve => videoElement.onloadedmetadata = resolve)return stream
}export const stopCamera = (stream) => {stream.getTracks().forEach(track => track.stop())
}
图像压缩js
export const compressImage = (file, quality = 0.8) => {return new Promise((resolve, reject) => {const reader = new FileReader()reader.onload = (e) => {const img = new Image()img.onload = () => {const canvas = document.createElement('canvas')const ctx = canvas.getContext('2d')// 限制最大尺寸const maxWidth = 1024const scale = maxWidth / img.widthcanvas.width = maxWidthcanvas.height = img.height * scalectx.drawImage(img, 0, 0, canvas.width, canvas.height)canvas.toBlob(blob => resolve(new File([blob], file.name, { type: 'image/jpeg' })),'image/jpeg',quality)}img.src = e.target.result}reader.readAsDataURL(file)})
}
在自己需要添加人脸录入的页面引入弹窗组件FaceCaptureDialog即可,如:
<template><div class="app-container"><!-- ... --><!-- 呼出弹窗按钮 --><el-button size="mini" type="success" @click="openFaceDialog(row)" class="link-left">录入人脸</el-button><!-- ... --><face-capture-dialog ref="faceDialog" /></div>
</template><script>
import FaceCaptureDialog from '@/components/face/FaceCaptureDialog'// ...// 点击事件(呼出人脸录入弹窗)// row.id -> 学生id,传递到弹窗组件methods: {openFaceDialog(row) {this.$refs.faceDialog.open(row.id)},// ...
</script>
人脸搜索
人脸搜索弹窗<template><el-dialog :title="title" :visible.sync="visible" width="400px" :close-on-click-modal="false":close-on-press-escape="false" :show-close="false"><div v-if="loading" class="loading-container"><i class="el-icon-loading"></i><span>人脸识别中...</span></div><div v-else><video ref="video" width="300" height="200" autoplay playsinline></video><canvas ref="canvas" width="300" height="200" style="display: none;"></canvas><el-button type="primary" @click="capture">点击拍照</el-button></div></el-dialog>
</template><script>
import { post } from '@/utils/request'export default {props: {studentId: {type: String,required: true}},data () {return {visible: false,loading: false,stream: null}},methods: {open () {this.visible = truethis.initCamera()},close () {this.visible = falsethis.stopCamera()},async initCamera () {const constraints = { video: true }try {this.stream = await navigator.mediaDevices.getUserMedia(constraints)this.$refs.video.srcObject = this.stream} catch (error) {this.$message.error('无法访问摄像头,请检查权限设置')}},stopCamera () {if (this.stream) {this.stream.getTracks().forEach(track => track.stop())this.stream = null}},async capture () {this.stopCamera()const canvas = this.$refs.canvasconst video = this.$refs.videocanvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)const imgData = canvas.toDataURL('image/jpeg')const formData = new FormData()formData.append('studentId', this.studentId)formData.append('file', this.dataURLtoBlob(imgData))this.loading = truepost(`/api/face/verify`, formData, {headers: { 'Content-Type': 'multipart/form-data' }}).then(response => {this.loading = falseif (response.success) {this.$message.success('人脸验证成功!')this.$emit('verifySuccess')} else {this.$message.error(`人脸验证失败:${response.message}`)this.$emit('verifyError', response.message) // 触发 verifyError 事件this.initCamera() // 重新初始化摄像头}this.visible = false // 验证完成后关闭弹窗}).catch(error => {this.loading = falsethis.$message.error('人脸验证失败,请稍后重试')this.$emit('verifyError', error.message) // 触发 verifyError 事件this.initCamera() // 重新初始化摄像头})},dataURLtoBlob (dataurl) {const arr = dataurl.split(',')const mime = arr[0].match(/:(.*?);/)[1]const bstr = atob(arr[1])let n = bstr.lengthconst u8arr = new Uint8Array(n)while (n--) {u8arr[n] = bstr.charCodeAt(n)}return new Blob([u8arr], { type: mime })}}
}
</script><style scoped>
/* 自定义样式 */
</style>
在需要的页面引入人脸搜索弹窗,目前的流程就是进入做题页面后弹窗识别考生,三次识别失败后强制退出(根据需要,可以考虑间隔多少时间再次人脸认证,注意后端权限校验):
<template><div><!-- ... --><!-- 弹窗组件 --><FaceVerifyDialog ref="faceVerifyDialog" :studentId="currentUserId" @verifySuccess="handleVerifySuccess"@verifyError="handleVerifyError"/><!-- ... --></div>
</template><script>import FaceVerifyDialog from '@/components/face/FaceVerifyDialog.vue'export default {components: { FaceVerifyDialog },data () {return {currentUserId: '', // 用于存储当前用户的 studentId// ...isFaceVerified: false // 是否完成人脸识别验证}},// ...mounted () {this.initFaceVerify() // 初始化人脸识别},// ...methods: {// ...initFaceVerify () {// 开题前验证this.$alert('开考前需要进行人脸识别验证', '人脸验证提示', {closeOnClickModal: false, // 禁用点击背景关闭closeOnPressEscape: false, // 禁用按下 ESC 关闭showClose: false, // 隐藏关闭按钮callback: () => {// 弹窗关闭后的回调this.$refs.faceVerifyDialog.open()}})},handleVerifySuccess () {this.isFaceVerified = true // 标记验证成功this.closeFaceVerifyDialog()},handleVerifyError (error) {// 验证失败,允许用户重试,超过 3 次失败强制退出this.verifiedCount++if (this.verifiedCount >= 3) {this.$message.warning('人脸识别失败次数超过限制,请联系管理员', '人脸验证失败')this.closeFaceVerifyDialog()this.logout() // 退出登录} else {this.$message.error(`人脸识别失败:${error},可以点击重新验证`)}},closeFaceVerifyDialog () {this.$refs.faceVerifyDialog.close()},logout () {// 登出}},// ...
</script>
测试
录入成功后可以再腾讯云 -> 人脸识别控制台 -> 人脸库 看到录入的人脸:识别测试过程就不展示了 (`へ´*)ノ