You've already forked FrameTour-BE
feat(face): 引入人脸识别指标记录与搜索结果合并功能
- 新增 FaceMetricsRecorder 类用于记录人脸识别、自定义匹配及低阈值检测次数 - 新增 SearchResultMerger 类用于合并多个人脸搜索结果,支持并集与交集模式- 在 FaceServiceImpl 中引入 metricsRecorder 和 resultMerger 辅助类 - 替换原有的 Redis 操作代码为 FaceMetricsRecorder 的方法调用- 将搜索结果合并逻辑从 FaceServiceImpl 提取至 SearchResultMerger- 新增策略模式相关类:RematchContext、RematchModeStrategy 接口及四种实现 - 使用策略工厂 Rematch
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package com.ycwl.basic.service.pc.helper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.ycwl.basic.constant.FaceConstant.*;
|
||||
|
||||
/**
|
||||
* 人脸识别指标记录器
|
||||
* 负责记录人脸识别相关的计数指标到Redis
|
||||
*
|
||||
* @author longbinbin
|
||||
* @date 2025-01-31
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class FaceMetricsRecorder {
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
/**
|
||||
* 记录人脸识别次数到Redis
|
||||
* 设置2天过期时间
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
*/
|
||||
public void recordRecognitionCount(Long faceId) {
|
||||
if (faceId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String redisKey = FACE_RECOGNITION_COUNT_PFX + faceId;
|
||||
|
||||
// 使用Redis原子操作INCR增加计数
|
||||
Long count = redisTemplate.opsForValue().increment(redisKey);
|
||||
|
||||
// 设置2天过期时间(48小时)
|
||||
redisTemplate.expire(redisKey, 2, TimeUnit.DAYS);
|
||||
|
||||
log.debug("人脸识别计数更新:faceId={}, count={}", faceId, count);
|
||||
|
||||
} catch (Exception e) {
|
||||
// 计数失败不应影响主要业务逻辑,只记录错误日志
|
||||
log.error("记录人脸识别次数失败:faceId={}", faceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录自定义人脸匹配次数到Redis
|
||||
* 设置2天过期时间
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
*/
|
||||
public void recordCustomMatchCount(Long faceId) {
|
||||
if (faceId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String redisKey = FACE_CUSTOM_MATCH_COUNT_PFX + faceId;
|
||||
|
||||
Long count = redisTemplate.opsForValue().increment(redisKey);
|
||||
redisTemplate.expire(redisKey, 2, TimeUnit.DAYS);
|
||||
|
||||
log.debug("自定义人脸匹配计数更新:faceId={}, count={}", faceId, count);
|
||||
} catch (Exception e) {
|
||||
log.error("记录自定义人脸匹配次数失败:faceId={}", faceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录低阈值检测的人脸ID到Redis
|
||||
* 设置2天过期时间
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
*/
|
||||
public void recordLowThreshold(Long faceId) {
|
||||
if (faceId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String redisKey = FACE_LOW_THRESHOLD_PFX + faceId;
|
||||
|
||||
// 设置标记,表示该人脸ID触发了低阈值检测
|
||||
redisTemplate.opsForValue().set(redisKey, "1", 2, TimeUnit.DAYS);
|
||||
|
||||
log.debug("记录低阈值检测人脸:faceId={}", faceId);
|
||||
|
||||
} catch (Exception e) {
|
||||
// 记录失败不应影响主要业务逻辑,只记录错误日志
|
||||
log.error("记录低阈值检测人脸失败:faceId={}", faceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取人脸识别次数
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @return 识别次数
|
||||
*/
|
||||
public long getRecognitionCount(Long faceId) {
|
||||
if (faceId == null) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
try {
|
||||
String countKey = FACE_RECOGNITION_COUNT_PFX + faceId;
|
||||
String countStr = redisTemplate.opsForValue().get(countKey);
|
||||
if (countStr != null) {
|
||||
return Long.parseLong(countStr);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取识别次数失败:faceId={}", faceId, e);
|
||||
}
|
||||
|
||||
return 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取自定义匹配次数
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @return 自定义匹配次数
|
||||
*/
|
||||
public long getCustomMatchCount(Long faceId) {
|
||||
if (faceId == null) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
try {
|
||||
String customMatchKey = FACE_CUSTOM_MATCH_COUNT_PFX + faceId;
|
||||
String customMatchCountStr = redisTemplate.opsForValue().get(customMatchKey);
|
||||
if (customMatchCountStr != null) {
|
||||
return Long.parseLong(customMatchCountStr);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取自定义匹配次数失败:faceId={}", faceId, e);
|
||||
}
|
||||
|
||||
return 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否触发过低阈值检测
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @return 是否触发过低阈值检测
|
||||
*/
|
||||
public boolean hasLowThreshold(Long faceId) {
|
||||
if (faceId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String lowThresholdKey = FACE_LOW_THRESHOLD_PFX + faceId;
|
||||
return Boolean.TRUE.equals(redisTemplate.hasKey(lowThresholdKey));
|
||||
} catch (Exception e) {
|
||||
log.warn("检查低阈值状态失败:faceId={}", faceId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.ycwl.basic.service.pc.helper;
|
||||
|
||||
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 搜索结果合并器
|
||||
* 负责合并多个人脸搜索结果
|
||||
*
|
||||
* @author longbinbin
|
||||
* @date 2025-01-31
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SearchResultMerger {
|
||||
|
||||
/**
|
||||
* 合并多个搜索结果(默认使用并集模式)
|
||||
*
|
||||
* @param searchResults 搜索结果列表
|
||||
* @return 合并后的结果
|
||||
*/
|
||||
public SearchFaceRespVo merge(List<SearchFaceRespVo> searchResults) {
|
||||
return merge(searchResults, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个搜索结果
|
||||
*
|
||||
* @param searchResults 搜索结果列表
|
||||
* @param mergeMode 合并模式:0-并集,1-交集
|
||||
* @return 合并后的结果
|
||||
*/
|
||||
public SearchFaceRespVo merge(List<SearchFaceRespVo> searchResults, Integer mergeMode) {
|
||||
SearchFaceRespVo mergedResult = new SearchFaceRespVo();
|
||||
|
||||
if (searchResults == null || searchResults.isEmpty()) {
|
||||
return mergedResult;
|
||||
}
|
||||
|
||||
List<String> allSearchJsons = new ArrayList<>();
|
||||
float maxScore = 0f;
|
||||
float maxFirstMatchRate = 0f;
|
||||
boolean hasLowThreshold = false;
|
||||
|
||||
// 收集基础信息
|
||||
for (SearchFaceRespVo result : searchResults) {
|
||||
if (result.getSearchResultJson() != null) {
|
||||
allSearchJsons.add(result.getSearchResultJson());
|
||||
}
|
||||
if (result.getScore() > maxScore) {
|
||||
maxScore = result.getScore();
|
||||
}
|
||||
if (result.getFirstMatchRate() > maxFirstMatchRate) {
|
||||
maxFirstMatchRate = result.getFirstMatchRate();
|
||||
}
|
||||
if (result.isLowThreshold()) {
|
||||
hasLowThreshold = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据合并模式处理样本ID
|
||||
List<Long> finalSampleIds;
|
||||
if (Integer.valueOf(1).equals(mergeMode)) {
|
||||
// 模式1:交集 - 只保留所有搜索结果中都出现的样本ID
|
||||
finalSampleIds = computeIntersection(searchResults);
|
||||
log.debug("使用交集模式合并搜索结果,交集样本数: {}", finalSampleIds.size());
|
||||
} else {
|
||||
// 模式0:并集(默认) - 收集所有样本ID并去重
|
||||
Set<Long> allSampleIds = new LinkedHashSet<>();
|
||||
for (SearchFaceRespVo result : searchResults) {
|
||||
if (result.getSampleListIds() != null) {
|
||||
allSampleIds.addAll(result.getSampleListIds());
|
||||
}
|
||||
}
|
||||
finalSampleIds = new ArrayList<>(allSampleIds);
|
||||
log.debug("使用并集模式合并搜索结果,并集样本数: {}", finalSampleIds.size());
|
||||
}
|
||||
|
||||
mergedResult.setSampleListIds(finalSampleIds);
|
||||
mergedResult.setSearchResultJson(String.join("|", allSearchJsons));
|
||||
mergedResult.setScore(maxScore);
|
||||
mergedResult.setFirstMatchRate(maxFirstMatchRate);
|
||||
mergedResult.setLowThreshold(hasLowThreshold);
|
||||
|
||||
log.debug("合并搜索结果完成,模式={}, 最终样本数: {}", mergeMode, finalSampleIds.size());
|
||||
|
||||
return mergedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算多个搜索结果的交集
|
||||
* 返回在所有搜索结果中都出现的样本ID
|
||||
*
|
||||
* @param searchResults 搜索结果列表
|
||||
* @return 交集样本ID列表
|
||||
*/
|
||||
public List<Long> computeIntersection(List<SearchFaceRespVo> searchResults) {
|
||||
if (searchResults == null || searchResults.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 过滤掉空结果
|
||||
List<List<Long>> validSampleLists = searchResults.stream()
|
||||
.filter(result -> result.getSampleListIds() != null && !result.getSampleListIds().isEmpty())
|
||||
.map(SearchFaceRespVo::getSampleListIds)
|
||||
.toList();
|
||||
|
||||
if (validSampleLists.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 如果只有一个有效结果,直接返回
|
||||
if (validSampleLists.size() == 1) {
|
||||
return new ArrayList<>(validSampleLists.getFirst());
|
||||
}
|
||||
|
||||
// 计算交集:从第一个列表开始,保留在所有其他列表中都出现的ID
|
||||
Set<Long> intersection = new LinkedHashSet<>(validSampleLists.getFirst());
|
||||
|
||||
for (int i = 1; i < validSampleLists.size(); i++) {
|
||||
intersection.retainAll(validSampleLists.get(i));
|
||||
}
|
||||
|
||||
return new ArrayList<>(intersection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建直接结果(模式2:不搜索,直接使用用户选择的faceSampleIds)
|
||||
*
|
||||
* @param faceSampleIds 用户选择的人脸样本ID列表
|
||||
* @return 搜索结果对象
|
||||
*/
|
||||
public SearchFaceRespVo createDirectResult(List<Long> faceSampleIds) {
|
||||
SearchFaceRespVo result = new SearchFaceRespVo();
|
||||
|
||||
// 直接使用用户选择的faceSampleIds作为结果
|
||||
result.setSampleListIds(new ArrayList<>(faceSampleIds));
|
||||
|
||||
// 设置默认值
|
||||
result.setScore(1.0f);
|
||||
result.setFirstMatchRate(1.0f);
|
||||
result.setLowThreshold(false);
|
||||
result.setSearchResultJson("");
|
||||
|
||||
log.debug("创建直接结果,样本数: {}", faceSampleIds.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user