Spring Boot + Vue 接入腾讯云人脸识别API(SDK版本3.1.830)

发布于:2025-02-26 ⋅ 阅读:(20) ⋅ 点赞:(0)

一、需求分析

这次是基于一个Spring Boot +Vue的在线考试系统进行二次开发,添加人脸识别功能以防止学生替考。其他有对应场景的也可按需接入API,方法大同小异。

主要有以下两个步骤:

  • 人脸录入:将某个角色(如学生)的人脸绑定其唯一属性(如学号)录入人脸库
  • 人脸搜索(人脸识别):传递当前用户唯一属性(如学号)+ 摄像头图像给后台,在人脸库中进行匹配

二、腾讯云官网开通人脸服务

  1. 注册并进入官网:https://cloud.tencent.com/

  2. 主页搜索人脸识别,并进入产品控制台开通服务

  3. 创建人员库(注意人员库ID,后续会使用)

  4. 阅读查看官网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>

测试

录入成功后可以再腾讯云 -> 人脸识别控制台 -> 人脸库 看到录入的人脸:

识别测试过程就不展示了 (`へ´*)ノ