Merge branch 'refs/heads/result_edit_2'

# Conflicts:
#	src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java
#	src/main/java/com/ycwl/basic/service/task/impl/TaskFaceServiceImpl.java
This commit is contained in:
2025-10-28 15:36:32 +08:00
11 changed files with 696 additions and 155 deletions

View File

@@ -3,6 +3,8 @@ package com.ycwl.basic.controller.pc;
import com.github.pagehelper.PageInfo; import com.github.pagehelper.PageInfo;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.face.req.FaceReqQuery; import com.ycwl.basic.model.pc.face.req.FaceReqQuery;
import com.ycwl.basic.model.pc.face.req.FaceRecognitionUpdateReq;
import com.ycwl.basic.model.pc.face.resp.FaceRecognitionDetailVO;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO; import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.service.pc.FaceService; import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
@@ -53,5 +55,17 @@ public class FaceController {
return faceService.deleteByIds(ids); return faceService.deleteByIds(ids);
} }
@PutMapping("/{faceId}/recognition")
public ApiResponse<FaceRecognitionDetailVO> updateRecognition(@PathVariable Long faceId,
@RequestBody FaceRecognitionUpdateReq req) {
req.setFaceId(faceId);
return ApiResponse.success(faceService.updateRecognition(req));
}
@GetMapping("/{faceId}/recognition/detail")
public ApiResponse<FaceRecognitionDetailVO> recognitionDetail(@PathVariable Long faceId) {
return ApiResponse.success(faceService.getRecognitionDetail(faceId));
}
} }

View File

@@ -0,0 +1,22 @@
package com.ycwl.basic.model.pc.face.enums;
import lombok.Getter;
/**
* 标识系统在过滤识别样本时的原因,便于前端渲染提示。
*/
@Getter
public enum FaceRecognitionFilterReason {
SCORE_BELOW_THRESHOLD("score_below_threshold", "置信度低于阈值"),
OUT_OF_TIME_RANGE("out_of_time_range", "超出景区时间范围限制"),
DEVICE_PHOTO_LIMIT("device_photo_limit", "超过设备照片数量限制"),
MANUAL_REJECTED("manual_rejected", "已被手动排除");
private final String code;
private final String description;
FaceRecognitionFilterReason(String code, String description) {
this.code = code;
this.description = description;
}
}

View File

@@ -0,0 +1,36 @@
package com.ycwl.basic.model.pc.face.req;
import lombok.Data;
import java.util.List;
/**
* 人工调整人脸识别结果的请求体。
*/
@Data
public class FaceRecognitionUpdateReq {
/**
* 指定需要操作的人脸ID。
*/
private Long faceId;
/**
* 用户人工选中希望保留的样本ID列表。
*/
private List<Long> manualAcceptedSampleIds;
/**
* 用户主动排除的样本ID列表。
*/
private List<Long> manualRejectedSampleIds;
/**
* 是否强制重新走一次识别流程。
*/
private Boolean forceRematch;
/**
* 前端传回的备注信息。
*/
private String remark;
}

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.model.pc.face.resp;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
* 人脸识别详情,包含系统采纳及被过滤的样本。
*/
@Data
public class FaceRecognitionDetailVO {
private Long faceId;
private Long memberId;
private Long scenicId;
private String faceUrl;
private Float score;
private Float firstMatchRate;
private Boolean lowThreshold;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date lastMatchedAt;
/**
* 系统采纳的样本信息。
*/
private List<FaceRecognitionSampleVO> acceptedSamples;
/**
* 被系统过滤的样本信息。
*/
private List<FaceRecognitionSampleVO> filteredSamples;
}

View File

@@ -0,0 +1,42 @@
package com.ycwl.basic.model.pc.face.resp;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ycwl.basic.model.pc.face.enums.FaceRecognitionFilterReason;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
* 单个人脸样本识别结果的信息描述。
*/
@Data
public class FaceRecognitionSampleVO {
private Long sampleId;
private Float score;
private Boolean accepted;
private Long sourceId;
private Integer sourceType;
private String sourceUrl;
private String faceUrl;
private Long deviceId;
private String deviceName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date shotAt;
private Integer isFree;
private Integer isBuy;
/**
* 过滤原因列表,用于提示用户样本被过滤的原因。
*/
private List<FaceRecognitionFilterReason> filterReasons;
/**
* 过滤原因的描述集合,方便前端直接展示。
*/
private List<String> filterReasonTexts;
}

View File

@@ -0,0 +1,34 @@
package com.ycwl.basic.model.task.resp;
import com.ycwl.basic.model.pc.face.enums.FaceRecognitionFilterReason;
import lombok.Data;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 样本筛选的轨迹信息,记录最终样本集合及各过滤原因。
*/
@Data
public class SampleFilterTrace {
private List<Long> acceptedSampleIds;
private Map<Long, EnumSet<FaceRecognitionFilterReason>> filteredReasonMap = new HashMap<>();
public void addReason(Long sampleId, FaceRecognitionFilterReason reason) {
if (sampleId == null || reason == null) {
return;
}
filteredReasonMap.computeIfAbsent(sampleId, key -> EnumSet.noneOf(FaceRecognitionFilterReason.class))
.add(reason);
}
public Map<Long, EnumSet<FaceRecognitionFilterReason>> getFilteredReasonMap() {
if (filteredReasonMap == null) {
return Collections.emptyMap();
}
return filteredReasonMap;
}
}

View File

@@ -11,4 +11,5 @@ public class SearchFaceRespVo {
private String searchResultJson; private String searchResultJson;
private Float firstMatchRate; private Float firstMatchRate;
private boolean lowThreshold; private boolean lowThreshold;
private SampleFilterTrace filterTrace;
} }

View File

@@ -5,8 +5,10 @@ import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.face.FaceStatusResp; import com.ycwl.basic.model.mobile.face.FaceStatusResp;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO; import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.face.req.FaceRecognitionUpdateReq;
import com.ycwl.basic.model.pc.face.req.FaceReqQuery; import com.ycwl.basic.model.pc.face.req.FaceReqQuery;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO; import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.model.pc.face.resp.FaceRecognitionDetailVO;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.faceSample.resp.FaceSampleRespVO; import com.ycwl.basic.model.pc.faceSample.resp.FaceSampleRespVO;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo; import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
@@ -51,4 +53,8 @@ public interface FaceService {
List<FaceSampleEntity> getLowMatchedFaceSamples(Long faceId); List<FaceSampleEntity> getLowMatchedFaceSamples(Long faceId);
void matchCustomFaceId(Long faceId, List<Long> faceSampleIds); void matchCustomFaceId(Long faceId, List<Long> faceSampleIds);
FaceRecognitionDetailVO updateRecognition(FaceRecognitionUpdateReq req);
FaceRecognitionDetailVO getRecognitionDetail(Long faceId);
} }

View File

@@ -25,8 +25,13 @@ import com.ycwl.basic.model.mobile.goods.VideoTaskStatusVO;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO; import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO; import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq; import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.face.enums.FaceRecognitionFilterReason;
import com.ycwl.basic.model.pc.face.req.FaceRecognitionUpdateReq;
import com.ycwl.basic.model.pc.face.req.FaceReqQuery; import com.ycwl.basic.model.pc.face.req.FaceReqQuery;
import com.ycwl.basic.model.pc.face.resp.FaceRecognitionDetailVO;
import com.ycwl.basic.model.pc.face.resp.FaceRecognitionSampleVO;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO; import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.mp.MpConfigEntity; import com.ycwl.basic.model.pc.mp.MpConfigEntity;
@@ -42,6 +47,7 @@ import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity; import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
import com.ycwl.basic.model.pc.video.entity.VideoEntity; import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.model.repository.TaskUpdateResult; import com.ycwl.basic.model.repository.TaskUpdateResult;
import com.ycwl.basic.model.task.resp.SampleFilterTrace;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo; import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.repository.DeviceRepository; import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository; import com.ycwl.basic.repository.FaceRepository;
@@ -74,12 +80,19 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.Date; import java.util.Date;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -1098,21 +1111,23 @@ public class FaceServiceImpl implements FaceService {
@Override @Override
public void matchCustomFaceId(Long faceId, List<Long> faceSampleIds) { public void matchCustomFaceId(Long faceId, List<Long> faceSampleIds) {
// 参数验证 handleCustomFaceMatching(faceId, faceSampleIds);
}
private SearchFaceRespVo handleCustomFaceMatching(Long faceId, List<Long> faceSampleIds) {
if (faceId == null) { if (faceId == null) {
throw new IllegalArgumentException("faceId 不能为空"); throw new IllegalArgumentException("faceId 不能为空");
} }
if (faceSampleIds == null || faceSampleIds.isEmpty()) { if (faceSampleIds == null || faceSampleIds.isEmpty()) {
throw new IllegalArgumentException("faceSampleIds 不能为空"); throw new IllegalArgumentException("faceSampleIds 不能为空");
} }
log.debug("开始自定义人脸匹配:faceId={}, faceSampleIds={}", faceId, faceSampleIds); log.debug("开始自定义人脸匹配:faceId={}, faceSampleIds={}", faceId, faceSampleIds);
// 记录自定义匹配调用次数,便于监控调用频率 // 记录自定义匹配调用次数,便于监控调用频率
recordCustomMatchCount(faceId); recordCustomMatchCount(faceId);
try { try {
// 1. 获取基础数据
FaceEntity face = faceRepository.getFace(faceId); FaceEntity face = faceRepository.getFace(faceId);
if (face == null) { if (face == null) {
log.warn("人脸不存在,faceId: {}", faceId); log.warn("人脸不存在,faceId: {}", faceId);
@@ -1127,7 +1142,7 @@ public class FaceServiceImpl implements FaceService {
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId()); ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId()); IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId());
if (faceBodyAdapter == null) { if (faceBodyAdapter == null) {
log.error("无法获取人脸识别适配器,scenicId: {}", face.getScenicId()); log.error("无法获取人脸识别适配器,scenicId: {}", face.getScenicId());
throw new BaseException("人脸识别服务不可用,请稍后再试"); throw new BaseException("人脸识别服务不可用,请稍后再试");
@@ -1138,7 +1153,7 @@ public class FaceServiceImpl implements FaceService {
log.debug("face_select_post_mode配置值: {}", faceSelectPostMode); log.debug("face_select_post_mode配置值: {}", faceSelectPostMode);
SearchFaceRespVo mergedResult; SearchFaceRespVo mergedResult;
// 2. 根据face_select_post_mode决定搜索策略 // 2. 根据face_select_post_mode决定搜索策略
if (Integer.valueOf(2).equals(faceSelectPostMode)) { if (Integer.valueOf(2).equals(faceSelectPostMode)) {
// 模式2:不搜索,直接使用用户选择的faceSampleIds // 模式2:不搜索,直接使用用户选择的faceSampleIds
@@ -1150,15 +1165,15 @@ public class FaceServiceImpl implements FaceService {
List<SearchFaceRespVo> searchResults = new ArrayList<>(); List<SearchFaceRespVo> searchResults = new ArrayList<>();
for (FaceSampleEntity faceSample : faceSamples) { for (FaceSampleEntity faceSample : faceSamples) {
try { try {
SearchFaceRespVo result = faceService.searchFace(faceBodyAdapter, SearchFaceRespVo result = faceService.searchFace(faceBodyAdapter,
String.valueOf(face.getScenicId()), String.valueOf(face.getScenicId()),
faceSample.getFaceUrl(), faceSample.getFaceUrl(),
"自定义人脸匹配"); "自定义人脸匹配");
if (result != null) { if (result != null) {
searchResults.add(result); searchResults.add(result);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("人脸样本搜索失败,faceSampleId={}, faceUrl={}", log.warn("人脸样本搜索失败,faceSampleId={}, faceUrl={}",
faceSample.getId(), faceSample.getFaceUrl(), e); faceSample.getId(), faceSample.getFaceUrl(), e);
// 继续处理其他样本,不中断整个流程 // 继续处理其他样本,不中断整个流程
} }
@@ -1172,56 +1187,60 @@ public class FaceServiceImpl implements FaceService {
// 2.2 根据模式整合多个搜索结果 // 2.2 根据模式整合多个搜索结果
mergedResult = mergeSearchResults(searchResults, faceSelectPostMode); mergedResult = mergeSearchResults(searchResults, faceSelectPostMode);
} }
// 3. 应用后置筛选逻辑 // 3. 应用后置筛选逻辑
if (mergedResult.getSampleListIds() != null && !mergedResult.getSampleListIds().isEmpty()) { if (mergedResult.getSampleListIds() != null && !mergedResult.getSampleListIds().isEmpty()) {
List<FaceSampleEntity> allFaceSampleList = faceSampleMapper.listByIds(mergedResult.getSampleListIds()); List<FaceSampleEntity> allFaceSampleList = faceSampleMapper.listByIds(mergedResult.getSampleListIds());
List<Long> filteredSampleIds = faceService.applySampleFilters(mergedResult.getSampleListIds(), allFaceSampleList, scenicConfig); SampleFilterTrace filterTrace = faceService.applySampleFiltersWithTrace(
mergedResult.getSampleListIds(), allFaceSampleList, scenicConfig);
List<Long> filteredSampleIds = filterTrace.getAcceptedSampleIds() == null
? Collections.emptyList()
: filterTrace.getAcceptedSampleIds();
mergedResult.setSampleListIds(filteredSampleIds); mergedResult.setSampleListIds(filteredSampleIds);
log.debug("应用后置筛选:原始样本数={}, 筛选后样本数={}", allFaceSampleList.size(), filteredSampleIds.size()); mergedResult.setFilterTrace(filterTrace);
log.debug("应用后置筛选:原始样本数={}, 筛选后样本数={}",
allFaceSampleList.size(), filteredSampleIds.size());
} }
// 5. 更新人脸实体结果
updateFaceEntityResult(face, mergedResult, faceId); updateFaceEntityResult(face, mergedResult, faceId);
// 6. 执行后续业务逻辑
List<Long> sampleListIds = mergedResult.getSampleListIds(); List<Long> sampleListIds = mergedResult.getSampleListIds();
if (sampleListIds != null && !sampleListIds.isEmpty()) { if (sampleListIds != null && !sampleListIds.isEmpty()) {
try { try {
List<MemberSourceEntity> memberSourceEntityList = processMemberSources(sampleListIds, face); List<MemberSourceEntity> memberSourceEntityList = processMemberSources(sampleListIds, face);
if (!memberSourceEntityList.isEmpty()) { if (!memberSourceEntityList.isEmpty()) {
List<Long> freeSourceIds = processFreeSourceLogic(memberSourceEntityList, scenicConfig, false); List<Long> freeSourceIds = processFreeSourceLogic(memberSourceEntityList, scenicConfig, false);
processBuyStatus(memberSourceEntityList, freeSourceIds, face.getMemberId(), processBuyStatus(memberSourceEntityList, freeSourceIds, face.getMemberId(),
face.getScenicId(), faceId); face.getScenicId(), faceId);
handleVideoRecreation(scenicConfig, memberSourceEntityList, faceId, handleVideoRecreation(scenicConfig, memberSourceEntityList, faceId,
face.getMemberId(), sampleListIds, false); face.getMemberId(), sampleListIds, false);
// 过滤已存在的关联关系和无效的source引用,防止数据不一致
List<MemberSourceEntity> existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList); List<MemberSourceEntity> existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList);
List<MemberSourceEntity> validFiltered = sourceMapper.filterValidSourceRelations(existingFiltered); List<MemberSourceEntity> validFiltered = sourceMapper.filterValidSourceRelations(existingFiltered);
if (!validFiltered.isEmpty()) { if (!validFiltered.isEmpty()) {
sourceMapper.addRelations(validFiltered); sourceMapper.addRelations(validFiltered);
log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}", log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}",
faceId, memberSourceEntityList.size(), validFiltered.size()); faceId, memberSourceEntityList.size(), validFiltered.size());
} else { } else {
log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}", faceId, memberSourceEntityList.size()); log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}", faceId, memberSourceEntityList.size());
} }
memberRelationRepository.clearSCacheByFace(faceId); memberRelationRepository.clearSCacheByFace(faceId);
taskTaskService.autoCreateTaskByFaceId(faceId); taskTaskService.autoCreateTaskByFaceId(faceId);
log.info("自定义人脸匹配完成:faceId={}, 匹配样本数={}, 关联源文件数={}, 免费数={}", log.info("自定义人脸匹配完成:faceId={}, 匹配样本数={}, 关联源文件数={}, 免费数={}",
faceId, sampleListIds.size(), memberSourceEntityList.size(), freeSourceIds.size()); faceId, sampleListIds.size(), memberSourceEntityList.size(), freeSourceIds.size());
} }
} catch (Exception e) { } catch (Exception e) {
log.error("处理源文件关联失败,faceId={}", faceId, e); log.error("处理源文件关联失败,faceId={}", faceId, e);
// 源文件关联失败不影响主流程
} }
} else { } else {
log.warn("自定义人脸匹配无结果:faceId={}, faceSampleIds={}", faceId, faceSampleIds); log.warn("自定义人脸匹配无结果:faceId={}, faceSampleIds={}", faceId, faceSampleIds);
} }
return mergedResult;
} catch (BaseException e) { } catch (BaseException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
@@ -1237,9 +1256,323 @@ public class FaceServiceImpl implements FaceService {
return mergeSearchResults(searchResults, 0); return mergeSearchResults(searchResults, 0);
} }
@Override
public FaceRecognitionDetailVO updateRecognition(FaceRecognitionUpdateReq req) {
if (req == null || req.getFaceId() == null) {
throw new IllegalArgumentException("faceId 不能为空");
}
Long faceId = req.getFaceId();
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
throw new BaseException("人脸不存在");
}
boolean forceRematch = Boolean.TRUE.equals(req.getForceRematch());
if (forceRematch) {
matchFaceId(faceId, false);
face = faceRepository.getFace(faceId);
}
List<Long> currentAccepted = parseMatchSampleIds(face.getMatchSampleIds());
List<Long> manualAccepted = Optional.ofNullable(req.getManualAcceptedSampleIds()).orElse(Collections.emptyList());
List<Long> manualRejected = Optional.ofNullable(req.getManualRejectedSampleIds()).orElse(Collections.emptyList());
Set<Long> manualRejectedSet = new HashSet<>(manualRejected);
LinkedHashSet<Long> finalSampleSet = new LinkedHashSet<>();
manualAccepted.stream()
.filter(Objects::nonNull)
.forEach(finalSampleSet::add);
currentAccepted.stream()
.filter(Objects::nonNull)
.filter(id -> !manualRejectedSet.contains(id))
.forEach(finalSampleSet::add);
boolean hasManualChange = !manualAccepted.isEmpty() || !manualRejectedSet.isEmpty();
List<Long> finalSampleList = new ArrayList<>(finalSampleSet);
boolean needsUpdate = hasManualChange && !finalSampleList.equals(currentAccepted);
if (needsUpdate) {
if (finalSampleList.isEmpty()) {
throw new BaseException("至少需要保留一个样本");
}
handleCustomFaceMatching(faceId, finalSampleList);
}
if (Strings.isNotBlank(req.getRemark())) {
log.info("人脸识别人工调整备注:faceId={}, remark={}", faceId, req.getRemark());
}
return getRecognitionDetail(faceId);
}
@Override
public FaceRecognitionDetailVO getRecognitionDetail(Long faceId) {
if (faceId == null) {
throw new IllegalArgumentException("faceId 不能为空");
}
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
throw new BaseException("人脸不存在");
}
FaceRecognitionDetailVO detail = new FaceRecognitionDetailVO();
detail.setFaceId(faceId);
detail.setMemberId(face.getMemberId());
detail.setScenicId(face.getScenicId());
detail.setFaceUrl(face.getFaceUrl());
detail.setScore(face.getScore());
detail.setFirstMatchRate(face.getFirstMatchRate() != null ? face.getFirstMatchRate().floatValue() : null);
detail.setLowThreshold(redisTemplate.hasKey(FACE_LOW_THRESHOLD_PFX + faceId));
detail.setLastMatchedAt(face.getUpdateAt() != null ? face.getUpdateAt() : face.getCreateAt());
String matchResultJson = face.getMatchResult();
if (Strings.isBlank(matchResultJson)) {
detail.setAcceptedSamples(Collections.emptyList());
detail.setFilteredSamples(Collections.emptyList());
return detail;
}
List<SearchFaceResultItem> resultItems = JacksonUtil.fromJsonToList(matchResultJson, SearchFaceResultItem.class);
if (resultItems == null) {
resultItems = Collections.emptyList();
}
List<Long> persistedAcceptedIds = parseMatchSampleIds(face.getMatchSampleIds());
LinkedHashSet<Long> sampleUniverse = new LinkedHashSet<>();
Map<Long, SearchFaceResultItem> itemBySampleId = new LinkedHashMap<>();
for (SearchFaceResultItem item : resultItems) {
Long sampleId = parseLongSilently(item.getExtData());
if (sampleId != null) {
sampleUniverse.add(sampleId);
itemBySampleId.putIfAbsent(sampleId, item);
}
}
sampleUniverse.addAll(persistedAcceptedIds);
List<Long> allSampleIds = new ArrayList<>(sampleUniverse);
if (allSampleIds.isEmpty()) {
detail.setAcceptedSamples(Collections.emptyList());
detail.setFilteredSamples(Collections.emptyList());
return detail;
}
List<FaceSampleEntity> allSamples = faceSampleMapper.listByIds(allSampleIds);
Map<Long, FaceSampleEntity> sampleEntityMap = allSamples.stream()
.collect(Collectors.toMap(FaceSampleEntity::getId, Function.identity(), (a, b) -> a, LinkedHashMap::new));
List<SourceEntity> sourceEntities = sourceMapper.listBySampleIds(allSampleIds);
Map<Long, SourceEntity> sourceBySampleId = sourceEntities.stream()
.collect(Collectors.toMap(SourceEntity::getFaceSampleId, Function.identity(), (a, b) -> a, LinkedHashMap::new));
Map<Long, SourceEntity> sourceById = sourceEntities.stream()
.collect(Collectors.toMap(SourceEntity::getId, Function.identity(), (a, b) -> a));
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
Float thresholdConfig = scenicConfig != null ? scenicConfig.getFloat("face_score_threshold") : null;
float threshold = thresholdConfig != null ? thresholdConfig / 100F : 0F;
List<Long> initialAcceptedIds = allSampleIds.stream()
.filter(id -> {
SearchFaceResultItem item = itemBySampleId.get(id);
return item != null && item.getScore() != null && item.getScore() > threshold;
})
.collect(Collectors.toList());
List<FaceSampleEntity> orderedSampleList = allSampleIds.stream()
.map(sampleEntityMap::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
SampleFilterTrace filterTrace = faceService.applySampleFiltersWithTrace(initialAcceptedIds, orderedSampleList, scenicConfig);
List<Long> systemAcceptedIds = filterTrace.getAcceptedSampleIds() == null
? Collections.emptyList()
: filterTrace.getAcceptedSampleIds();
Set<Long> initialAcceptedSet = new HashSet<>(initialAcceptedIds);
for (Long sampleId : allSampleIds) {
if (!initialAcceptedSet.contains(sampleId) && !systemAcceptedIds.contains(sampleId)) {
filterTrace.addReason(sampleId, FaceRecognitionFilterReason.SCORE_BELOW_THRESHOLD);
}
}
Set<Long> systemAcceptedSet = new HashSet<>(systemAcceptedIds);
Set<Long> persistedAcceptedSet = new HashSet<>(persistedAcceptedIds);
for (Long sampleId : systemAcceptedSet) {
if (!persistedAcceptedSet.contains(sampleId)) {
filterTrace.addReason(sampleId, FaceRecognitionFilterReason.MANUAL_REJECTED);
}
}
List<MemberSourceEntity> relations = new ArrayList<>();
List<MemberSourceEntity> videoRelations = memberRelationRepository.listSourceByFaceRelation(faceId, 1);
if (videoRelations != null) {
relations.addAll(videoRelations);
}
List<MemberSourceEntity> imageRelations = memberRelationRepository.listSourceByFaceRelation(faceId, 2);
if (imageRelations != null) {
relations.addAll(imageRelations);
}
Map<Long, MemberSourceEntity> relationBySampleId = new HashMap<>();
for (MemberSourceEntity relation : relations) {
SourceEntity source = sourceById.get(relation.getSourceId());
if (source != null && source.getFaceSampleId() != null) {
relationBySampleId.putIfAbsent(source.getFaceSampleId(), relation);
}
}
Map<Long, EnumSet<FaceRecognitionFilterReason>> reasonMap = filterTrace.getFilteredReasonMap();
Map<Long, DeviceEntity> deviceCache = new HashMap<>();
List<Long> acceptedOrdered = new ArrayList<>();
for (Long sampleId : allSampleIds) {
if (persistedAcceptedSet.contains(sampleId)) {
acceptedOrdered.add(sampleId);
}
}
for (Long sampleId : persistedAcceptedIds) {
if (!acceptedOrdered.contains(sampleId)) {
acceptedOrdered.add(sampleId);
}
}
List<FaceRecognitionSampleVO> acceptedSamples = acceptedOrdered.stream()
.map(sampleId -> buildSampleVO(
sampleId,
true,
itemBySampleId.get(sampleId),
sampleEntityMap.get(sampleId),
sourceBySampleId.get(sampleId),
relationBySampleId.get(sampleId),
deviceCache,
Collections.emptyList()))
.collect(Collectors.toList());
Set<Long> acceptedSet = new HashSet<>(acceptedOrdered);
List<FaceRecognitionSampleVO> filteredSamples = new ArrayList<>();
for (Long sampleId : allSampleIds) {
if (acceptedSet.contains(sampleId)) {
continue;
}
EnumSet<FaceRecognitionFilterReason> reasons = reasonMap.get(sampleId);
List<FaceRecognitionFilterReason> reasonList = reasons == null
? Collections.emptyList()
: new ArrayList<>(reasons);
filteredSamples.add(buildSampleVO(
sampleId,
false,
itemBySampleId.get(sampleId),
sampleEntityMap.get(sampleId),
sourceBySampleId.get(sampleId),
relationBySampleId.get(sampleId),
deviceCache,
reasonList));
}
detail.setAcceptedSamples(acceptedSamples);
detail.setFilteredSamples(filteredSamples);
return detail;
}
private List<Long> parseMatchSampleIds(String matchSampleIds) {
if (Strings.isBlank(matchSampleIds)) {
return Collections.emptyList();
}
String[] segments = matchSampleIds.split(",");
List<Long> result = new ArrayList<>(segments.length);
for (String segment : segments) {
Long id = parseLongSilently(segment);
if (id != null) {
result.add(id);
}
}
return result;
}
private Long parseLongSilently(String value) {
if (Strings.isBlank(value)) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
private FaceRecognitionSampleVO buildSampleVO(Long sampleId,
boolean accepted,
SearchFaceResultItem resultItem,
FaceSampleEntity sampleEntity,
SourceEntity sourceEntity,
MemberSourceEntity relation,
Map<Long, DeviceEntity> deviceCache,
List<FaceRecognitionFilterReason> reasons) {
FaceRecognitionSampleVO vo = new FaceRecognitionSampleVO();
vo.setSampleId(sampleId);
vo.setAccepted(accepted);
if (resultItem != null) {
vo.setScore(resultItem.getScore());
}
if (sampleEntity != null) {
vo.setFaceUrl(sampleEntity.getFaceUrl());
vo.setDeviceId(sampleEntity.getDeviceId());
vo.setShotAt(sampleEntity.getCreateAt());
DeviceEntity device = getDeviceCached(sampleEntity.getDeviceId(), deviceCache);
if (device != null) {
vo.setDeviceName(device.getName());
}
}
if (sourceEntity != null) {
vo.setSourceId(sourceEntity.getId());
vo.setSourceType(sourceEntity.getType());
vo.setSourceUrl(resolveSourceUrl(sourceEntity));
}
if (relation != null) {
vo.setIsFree(relation.getIsFree());
vo.setIsBuy(relation.getIsBuy());
}
if (reasons != null && !reasons.isEmpty()) {
vo.setFilterReasons(new ArrayList<>(reasons));
vo.setFilterReasonTexts(reasons.stream()
.map(FaceRecognitionFilterReason::getDescription)
.collect(Collectors.toList()));
} else {
vo.setFilterReasons(Collections.emptyList());
vo.setFilterReasonTexts(Collections.emptyList());
}
return vo;
}
private DeviceEntity getDeviceCached(Long deviceId, Map<Long, DeviceEntity> cache) {
if (deviceId == null) {
return null;
}
if (cache.containsKey(deviceId)) {
return cache.get(deviceId);
}
DeviceEntity device = deviceRepository.getDevice(deviceId);
cache.put(deviceId, device);
return device;
}
private String resolveSourceUrl(SourceEntity sourceEntity) {
if (sourceEntity == null) {
return null;
}
if (!Strings.isBlank(sourceEntity.getUrl())) {
return sourceEntity.getUrl();
}
if (!Strings.isBlank(sourceEntity.getVideoUrl())) {
return sourceEntity.getVideoUrl();
}
if (!Strings.isBlank(sourceEntity.getThumbUrl())) {
return sourceEntity.getThumbUrl();
}
return null;
}
/** /**
* 合并多个搜索结果 * 合并多个搜索结果
* *
* @param searchResults 搜索结果列表 * @param searchResults 搜索结果列表
* @param mergeMode 合并模式:0-并集,1-交集 * @param mergeMode 合并模式:0-并集,1-交集
* @return 合并后的结果 * @return 合并后的结果
@@ -1250,7 +1583,7 @@ public class FaceServiceImpl implements FaceService {
if (searchResults == null || searchResults.isEmpty()) { if (searchResults == null || searchResults.isEmpty()) {
return mergedResult; return mergedResult;
} }
List<String> allSearchJsons = new ArrayList<>(); List<String> allSearchJsons = new ArrayList<>();
float maxScore = 0f; float maxScore = 0f;
float maxFirstMatchRate = 0f; float maxFirstMatchRate = 0f;
@@ -1289,7 +1622,7 @@ public class FaceServiceImpl implements FaceService {
finalSampleIds = new ArrayList<>(allSampleIds); finalSampleIds = new ArrayList<>(allSampleIds);
log.debug("使用并集模式合并搜索结果,并集样本数: {}", finalSampleIds.size()); log.debug("使用并集模式合并搜索结果,并集样本数: {}", finalSampleIds.size());
} }
mergedResult.setSampleListIds(finalSampleIds); mergedResult.setSampleListIds(finalSampleIds);
mergedResult.setSearchResultJson(String.join("|", allSearchJsons)); mergedResult.setSearchResultJson(String.join("|", allSearchJsons));
mergedResult.setScore(maxScore); mergedResult.setScore(maxScore);
@@ -1309,52 +1642,52 @@ public class FaceServiceImpl implements FaceService {
if (searchResults == null || searchResults.isEmpty()) { if (searchResults == null || searchResults.isEmpty()) {
return new ArrayList<>(); return new ArrayList<>();
} }
// 过滤掉空结果 // 过滤掉空结果
List<List<Long>> validSampleLists = searchResults.stream() List<List<Long>> validSampleLists = searchResults.stream()
.filter(result -> result.getSampleListIds() != null && !result.getSampleListIds().isEmpty()) .filter(result -> result.getSampleListIds() != null && !result.getSampleListIds().isEmpty())
.map(SearchFaceRespVo::getSampleListIds) .map(SearchFaceRespVo::getSampleListIds)
.toList(); .toList();
if (validSampleLists.isEmpty()) { if (validSampleLists.isEmpty()) {
return new ArrayList<>(); return new ArrayList<>();
} }
// 如果只有一个有效结果,直接返回 // 如果只有一个有效结果,直接返回
if (validSampleLists.size() == 1) { if (validSampleLists.size() == 1) {
return new ArrayList<>(validSampleLists.getFirst()); return new ArrayList<>(validSampleLists.getFirst());
} }
// 计算交集:从第一个列表开始,保留在所有其他列表中都出现的ID // 计算交集:从第一个列表开始,保留在所有其他列表中都出现的ID
Set<Long> intersection = new LinkedHashSet<>(validSampleLists.getFirst()); Set<Long> intersection = new LinkedHashSet<>(validSampleLists.getFirst());
for (int i = 1; i < validSampleLists.size(); i++) { for (int i = 1; i < validSampleLists.size(); i++) {
intersection.retainAll(validSampleLists.get(i)); intersection.retainAll(validSampleLists.get(i));
} }
return new ArrayList<>(intersection); return new ArrayList<>(intersection);
} }
/** /**
* 创建直接结果(模式2:不搜索,直接使用用户选择的faceSampleIds) * 创建直接结果(模式2:不搜索,直接使用用户选择的faceSampleIds)
* *
* @param faceSampleIds 用户选择的人脸样本ID列表 * @param faceSampleIds 用户选择的人脸样本ID列表
* @return 搜索结果对象 * @return 搜索结果对象
*/ */
private SearchFaceRespVo createDirectResult(List<Long> faceSampleIds) { private SearchFaceRespVo createDirectResult(List<Long> faceSampleIds) {
SearchFaceRespVo result = new SearchFaceRespVo(); SearchFaceRespVo result = new SearchFaceRespVo();
// 直接使用用户选择的faceSampleIds作为结果 // 直接使用用户选择的faceSampleIds作为结果
result.setSampleListIds(new ArrayList<>(faceSampleIds)); result.setSampleListIds(new ArrayList<>(faceSampleIds));
// 设置默认值 // 设置默认值
result.setScore(1.0f); result.setScore(1.0f);
result.setFirstMatchRate(1.0f); result.setFirstMatchRate(1.0f);
result.setLowThreshold(false); result.setLowThreshold(false);
result.setSearchResultJson(""); result.setSearchResultJson("");
log.debug("创建直接结果,样本数: {}", faceSampleIds.size()); log.debug("创建直接结果,样本数: {}", faceSampleIds.size());
return result; return result;
} }

View File

@@ -3,6 +3,7 @@ package com.ycwl.basic.service.task;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter; import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager; import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.task.resp.SampleFilterTrace;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo; import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -25,7 +26,19 @@ public interface TaskFaceService {
* @param scenicConfig 景区配置管理器 * @param scenicConfig 景区配置管理器
* @return 筛选后的样本ID列表 * @return 筛选后的样本ID列表
*/ */
List<Long> applySampleFilters(List<Long> acceptedSampleIds, List<Long> applySampleFilters(List<Long> acceptedSampleIds,
List<FaceSampleEntity> allFaceSampleList, List<FaceSampleEntity> allFaceSampleList,
ScenicConfigManager scenicConfig); ScenicConfigManager scenicConfig);
/**
* 带过滤轨迹的样本筛选逻辑。
*
* @param acceptedSampleIds 已接受的样本ID列表
* @param allFaceSampleList 所有人脸样本实体列表
* @param scenicConfig 景区配置
* @return 包含最终样本及过滤原因的轨迹对象
*/
SampleFilterTrace applySampleFiltersWithTrace(List<Long> acceptedSampleIds,
List<FaceSampleEntity> allFaceSampleList,
ScenicConfigManager scenicConfig);
} }

View File

@@ -23,6 +23,7 @@ import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity; import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO; import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.model.pc.face.enums.FaceRecognitionFilterReason;
import com.ycwl.basic.model.pc.faceDetectLog.entity.FaceDetectLog; import com.ycwl.basic.model.pc.faceDetectLog.entity.FaceDetectLog;
import com.ycwl.basic.model.pc.faceDetectLog.resp.MatchLocalRecord; import com.ycwl.basic.model.pc.faceDetectLog.resp.MatchLocalRecord;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
@@ -30,6 +31,7 @@ import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity; import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity; import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo; import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.model.task.resp.SampleFilterTrace;
import com.ycwl.basic.repository.DeviceRepository; import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository; import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.ScenicRepository; import com.ycwl.basic.repository.ScenicRepository;
@@ -52,7 +54,10 @@ import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -264,7 +269,18 @@ public class TaskFaceServiceImpl implements TaskFaceService {
idIndexMap.put(allFaceSampleIds.get(i), i); idIndexMap.put(allFaceSampleIds.get(i), i);
} }
allFaceSampleList.sort(Comparator.comparing(sample -> idIndexMap.get(sample.getId()))); allFaceSampleList.sort(Comparator.comparing(sample -> idIndexMap.get(sample.getId())));
acceptFaceSampleIds = applySampleFilters(acceptFaceSampleIds, allFaceSampleList, scenicConfig); SampleFilterTrace filterTrace = applySampleFiltersWithTrace(acceptFaceSampleIds, allFaceSampleList, scenicConfig);
List<Long> finalAcceptedSampleIds = filterTrace.getAcceptedSampleIds() == null
? Collections.emptyList()
: filterTrace.getAcceptedSampleIds();
Set<Long> initialAcceptedSet = acceptFaceSampleIds == null
? Collections.emptySet()
: new HashSet<>(acceptFaceSampleIds);
for (Long sampleId : allFaceSampleIds) {
if (!finalAcceptedSampleIds.contains(sampleId) && !initialAcceptedSet.contains(sampleId)) {
filterTrace.addReason(sampleId, FaceRecognitionFilterReason.SCORE_BELOW_THRESHOLD);
}
}
List<MatchLocalRecord> collect = new ArrayList<>(); List<MatchLocalRecord> collect = new ArrayList<>();
for (SearchFaceResultItem item : records) { for (SearchFaceResultItem item : records) {
MatchLocalRecord record = new MatchLocalRecord(); MatchLocalRecord record = new MatchLocalRecord();
@@ -277,7 +293,7 @@ public class TaskFaceServiceImpl implements TaskFaceService {
if (device != null) { if (device != null) {
record.setDeviceName(device.getName()); record.setDeviceName(device.getName());
} }
record.setAccept(acceptFaceSampleIds.contains(optionalFse.get().getId())); record.setAccept(finalAcceptedSampleIds.contains(optionalFse.get().getId()));
record.setFaceUrl(optionalFse.get().getFaceUrl()); record.setFaceUrl(optionalFse.get().getFaceUrl());
record.setShotDate(optionalFse.get().getCreateAt()); record.setShotDate(optionalFse.get().getCreateAt());
} }
@@ -289,13 +305,14 @@ public class TaskFaceServiceImpl implements TaskFaceService {
collect.add(record); collect.add(record);
} }
logEntity.setMatchLocalRecord(JacksonUtil.toJSONString(collect)); logEntity.setMatchLocalRecord(JacksonUtil.toJSONString(collect));
if (acceptFaceSampleIds.isEmpty()) { if (finalAcceptedSampleIds.isEmpty()) {
respVo.setFirstMatchRate(0f); respVo.setFirstMatchRate(0f);
respVo.setSampleListIds(Collections.emptyList()); respVo.setSampleListIds(Collections.emptyList());
return respVo; return respVo;
} }
respVo.setFirstMatchRate(response.getFirstMatchRate()); respVo.setFirstMatchRate(response.getFirstMatchRate());
respVo.setSampleListIds(acceptFaceSampleIds); respVo.setSampleListIds(finalAcceptedSampleIds);
respVo.setFilterTrace(filterTrace);
return respVo; return respVo;
} catch (Exception e) { } catch (Exception e) {
logEntity.setMatchRawResult("识别错误,错误为:["+e.getLocalizedMessage()+"]"); logEntity.setMatchRawResult("识别错误,错误为:["+e.getLocalizedMessage()+"]");
@@ -389,132 +406,120 @@ public class TaskFaceServiceImpl implements TaskFaceService {
*/ */
@Override @Override
public List<Long> applySampleFilters(List<Long> acceptedSampleIds, public List<Long> applySampleFilters(List<Long> acceptedSampleIds,
List<FaceSampleEntity> allFaceSampleList, List<FaceSampleEntity> allFaceSampleList,
ScenicConfigManager scenicConfig) { ScenicConfigManager scenicConfig) {
SampleFilterTrace trace = applySampleFiltersWithTrace(acceptedSampleIds, allFaceSampleList, scenicConfig);
List<Long> result = trace.getAcceptedSampleIds();
return result == null ? Collections.emptyList() : result;
}
@Override
public SampleFilterTrace applySampleFiltersWithTrace(List<Long> acceptedSampleIds,
List<FaceSampleEntity> allFaceSampleList,
ScenicConfigManager scenicConfig) {
SampleFilterTrace trace = new SampleFilterTrace();
if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) { if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) {
return acceptedSampleIds; trace.setAcceptedSampleIds(acceptedSampleIds == null ? Collections.emptyList() : new ArrayList<>(acceptedSampleIds));
return trace;
} }
if (scenicConfig == null) { if (allFaceSampleList == null || allFaceSampleList.isEmpty()) {
// 没有配置,不管 trace.setAcceptedSampleIds(new ArrayList<>(acceptedSampleIds));
return acceptedSampleIds; return trace;
} }
// 1. 找到最高匹配的样本(按创建时间倒序排列的第一个) Map<Long, FaceSampleEntity> sampleMap = allFaceSampleList.stream()
Optional<FaceSampleEntity> firstFaceSample = allFaceSampleList.stream() .collect(Collectors.toMap(FaceSampleEntity::getId, sample -> sample, (a, b) -> a));
.filter(faceSample -> acceptedSampleIds.contains(faceSample.getId())).max(Comparator.comparing(FaceSampleEntity::getCreateAt));
if (firstFaceSample.isEmpty()) { List<Long> workingList = acceptedSampleIds.stream()
log.warn("样本筛选逻辑:未找到匹配的人脸样本,acceptedIds: {}", acceptedSampleIds); .filter(sampleMap::containsKey)
return acceptedSampleIds; .collect(Collectors.toCollection(ArrayList::new));
if (workingList.isEmpty()) {
trace.setAcceptedSampleIds(Collections.emptyList());
return trace;
} }
FaceSampleEntity topMatchSample = firstFaceSample.get(); Integer tourMinutes = scenicConfig != null ? scenicConfig.getInteger("tour_time") : null;
log.debug("样本筛选逻辑:找到最高匹配样本 ID={}, 创建时间={}", if (tourMinutes != null && tourMinutes > 0) {
topMatchSample.getId(), topMatchSample.getCreateAt()); workingList = filterSampleIdsByTimeRangeWithTrace(workingList, sampleMap, tourMinutes, trace);
log.debug("应用时间范围筛选:游览时间限制={}分钟,过滤后数量={}", tourMinutes, workingList.size());
List<Long> filteredIds = acceptedSampleIds;
// 2. 应用时间范围筛选(基于景区配置)
if (scenicConfig.getInteger("tour_time", 0) > 0) {
filteredIds = filterSampleIdsByTimeRange(filteredIds, topMatchSample, scenicConfig.getInteger("tour_time"));
log.debug("应用时间范围筛选:游览时间限制={}分钟", scenicConfig.getInteger("tour_time"));
} else { } else {
log.debug("时间范围逻辑:景区未设置游览时间限制"); log.debug("时间范围逻辑:景区未设置游览时间限制");
} }
workingList = applyDevicePhotoLimitWithTrace(workingList, sampleMap, trace);
trace.setAcceptedSampleIds(new ArrayList<>(workingList));
log.debug("样本筛选完成:原始数量={}, 最终数量={}", acceptedSampleIds.size(), workingList.size());
// 3. 应用设备照片数量限制筛选 return trace;
filteredIds = applyDevicePhotoLimit(filteredIds, allFaceSampleList);
log.debug("应用设备照片数量限制筛选完成");
// 4. TODO: 基于景区配置的其他筛选策略
// 可以根据 scenicConfig 中的配置来决定是否启用特定筛选
// 示例:未来可能的筛选策略
// if (scenicConfig.getEnableLocationFilter() != null && scenicConfig.getEnableLocationFilter()) {
// filteredIds = applyLocationFilter(filteredIds, allFaceSampleList, scenicConfig);
// }
// if (scenicConfig.getEnableQualityFilter() != null && scenicConfig.getEnableQualityFilter()) {
// filteredIds = applyQualityFilter(filteredIds, allFaceSampleList, scenicConfig);
// }
// if (scenicConfig.getMaxSampleCount() != null) {
// filteredIds = applySampleCountLimit(filteredIds, scenicConfig.getMaxSampleCount());
// }
log.debug("样本筛选完成:原始数量={}, 最终数量={}",
acceptedSampleIds.size(), filteredIds.size());
return filteredIds;
} }
/** private List<Long> filterSampleIdsByTimeRangeWithTrace(List<Long> acceptedSampleIds,
* 根据时间范围过滤人脸样本ID Map<Long, FaceSampleEntity> sampleMap,
* 基于最高匹配样本的时间,过滤出指定时间范围内的样本ID int tourMinutes,
*/ SampleFilterTrace trace) {
private List<Long> filterSampleIdsByTimeRange(List<Long> acceptedSampleIds,
FaceSampleEntity firstMatch,
int tourMinutes) {
if (acceptedSampleIds == null || acceptedSampleIds.isEmpty() ||
firstMatch == null || tourMinutes <= 0) {
return acceptedSampleIds;
}
List<FaceSampleEntity> acceptFaceSampleList = faceSampleMapper.listByIds(acceptedSampleIds);
if (acceptFaceSampleList.isEmpty()) {
return acceptedSampleIds;
}
Date startDate = DateUtil.offsetMinute(firstMatch.getCreateAt(), -tourMinutes);
Date endDate = DateUtil.offsetMinute(firstMatch.getCreateAt(), 1);
List<Long> filteredIds = acceptFaceSampleList.stream()
.filter(faceSample -> faceSample.getCreateAt().after(startDate) &&
faceSample.getCreateAt().before(endDate))
.map(FaceSampleEntity::getId)
.collect(Collectors.toList());
log.info("时间范围逻辑:最高匹配:{},时间范围:{}~{},原样本数:{},过滤后样本数:{}",
firstMatch.getId(), startDate, endDate, acceptedSampleIds.size(), filteredIds.size());
return filteredIds;
}
/**
* 根据设备配置的limit_photo值限制每个设备的照片数量
*
* @param acceptedSampleIds 已接受的样本ID列表
* @param allFaceSampleList 所有人脸样本实体列表
* @return 应用设备照片数量限制后的样本ID列表
*/
private List<Long> applyDevicePhotoLimit(List<Long> acceptedSampleIds,
List<FaceSampleEntity> allFaceSampleList) {
if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) { if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) {
return Collections.emptyList();
}
FaceSampleEntity topMatchSample = acceptedSampleIds.stream()
.map(sampleMap::get)
.filter(Objects::nonNull)
.max(Comparator.comparing(FaceSampleEntity::getCreateAt))
.orElse(null);
if (topMatchSample == null || topMatchSample.getCreateAt() == null) {
log.warn("样本筛选逻辑:未找到匹配的人脸样本,acceptedIds: {}", acceptedSampleIds);
return acceptedSampleIds; return acceptedSampleIds;
} }
// 获取过滤后的样本列表 Date startDate = DateUtil.offsetMinute(topMatchSample.getCreateAt(), -tourMinutes);
List<FaceSampleEntity> filteredSamples = allFaceSampleList.stream() Date endDate = DateUtil.offsetMinute(topMatchSample.getCreateAt(), 1);
.filter(sample -> acceptedSampleIds.contains(sample.getId()))
.collect(Collectors.toList());
// 按设备ID分组 List<Long> result = new ArrayList<>();
Map<Long, List<FaceSampleEntity>> samplesByDevice = filteredSamples.stream() for (Long sampleId : acceptedSampleIds) {
.collect(Collectors.groupingBy(FaceSampleEntity::getDeviceId)); FaceSampleEntity sample = sampleMap.get(sampleId);
if (sample == null || sample.getCreateAt() == null) {
List<Long> resultIds = new ArrayList<>(); result.add(sampleId);
continue;
// 处理每个设备的样本
for (Map.Entry<Long, List<FaceSampleEntity>> entry : samplesByDevice.entrySet()) {
Long deviceId = entry.getKey();
List<FaceSampleEntity> deviceSamples = entry.getValue();
// 获取设备配置
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
Integer limitPhoto = null;
if (deviceConfig != null) {
limitPhoto = deviceConfig.getInteger("limit_photo");
} }
Date createAt = sample.getCreateAt();
if (createAt.after(startDate) && createAt.before(endDate)) {
result.add(sampleId);
} else {
trace.addReason(sampleId, FaceRecognitionFilterReason.OUT_OF_TIME_RANGE);
}
}
// 如果没有配置或配置为0,不限制 log.info("时间范围逻辑:最高匹配:{},时间范围:{}~{},原样本数:{},过滤后样本数:{}",
topMatchSample.getId(), startDate, endDate, acceptedSampleIds.size(), result.size());
return result;
}
private List<Long> applyDevicePhotoLimitWithTrace(List<Long> acceptedSampleIds,
Map<Long, FaceSampleEntity> sampleMap,
SampleFilterTrace trace) {
if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) {
return Collections.emptyList();
}
Map<Long, Integer> usedCount = new HashMap<>();
Map<Long, Integer> limitCache = new HashMap<>();
List<Long> result = new ArrayList<>();
for (Long sampleId : acceptedSampleIds) {
FaceSampleEntity sample = sampleMap.get(sampleId);
if (sample == null) {
result.add(sampleId);
continue;
}
Long deviceId = sample.getDeviceId();
if (deviceId == null) {
result.add(sampleId);
continue;
}
Integer limitPhoto = limitCache.computeIfAbsent(deviceId, id -> {
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(id);
return deviceConfig != null ? deviceConfig.getInteger("limit_photo") : null;
});
if (limitPhoto == null || limitPhoto <= 0) { if (limitPhoto == null || limitPhoto <= 0) {
List<Long> deviceSampleIds = deviceSamples.stream() List<Long> deviceSampleIds = deviceSamples.stream()
.map(FaceSampleEntity::getId) .map(FaceSampleEntity::getId)