一、需求分析
这次是基于一个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: xxx
secret-key: xxx
region: ap-guangzhou
group-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;
@Bean
public Credential credential() {
return new Credential(secretId, secretKey);
}
@Bean
public 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 {
@Autowired
private 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 {
@Autowired
private IaiClient iaiClient;
@Autowired
private 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-dialog
title="人脸录入"
: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-button
type="primary"
@click="capture"
class="capture-btn">
拍照
</el-button>
</div>
<!-- 图片上传 -->
<el-upload
v-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-button
type="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 = studentId
this.visible = true
this.$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.video
const canvas = this.$refs.canvas
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.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 = stream
await 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 = 1024
const scale = maxWidth / img.width
canvas.width = maxWidth
canvas.height = img.height * scale
ctx.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 = true
this.initCamera()
},
close () {
this.visible = false
this.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.canvas
const video = this.$refs.video
canvas.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 = true
post(`/api/face/verify`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(response => {
this.loading = false
if (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 = false
this.$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.length
const 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>
测试
录入成功后可以再腾讯云 -> 人脸识别控制台 -> 人脸库 看到录入的人脸:识别测试过程就不展示了 (`へ´*)ノ