diff --git a/src/main/java/com/ycwl/basic/controller/pc/FaceController.java b/src/main/java/com/ycwl/basic/controller/pc/FaceController.java index 896a3105..58865387 100644 --- a/src/main/java/com/ycwl/basic/controller/pc/FaceController.java +++ b/src/main/java/com/ycwl/basic/controller/pc/FaceController.java @@ -3,6 +3,8 @@ package com.ycwl.basic.controller.pc; import com.github.pagehelper.PageInfo; 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.FaceRecognitionUpdateReq; +import com.ycwl.basic.model.pc.face.resp.FaceRecognitionDetailVO; import com.ycwl.basic.model.pc.face.resp.FaceRespVO; import com.ycwl.basic.service.pc.FaceService; import com.ycwl.basic.utils.ApiResponse; @@ -53,5 +55,17 @@ public class FaceController { return faceService.deleteByIds(ids); } + @PutMapping("/{faceId}/recognition") + public ApiResponse updateRecognition(@PathVariable Long faceId, + @RequestBody FaceRecognitionUpdateReq req) { + req.setFaceId(faceId); + return ApiResponse.success(faceService.updateRecognition(req)); + } + + @GetMapping("/{faceId}/recognition/detail") + public ApiResponse recognitionDetail(@PathVariable Long faceId) { + return ApiResponse.success(faceService.getRecognitionDetail(faceId)); + } + } diff --git a/src/main/java/com/ycwl/basic/model/pc/face/enums/FaceRecognitionFilterReason.java b/src/main/java/com/ycwl/basic/model/pc/face/enums/FaceRecognitionFilterReason.java new file mode 100644 index 00000000..d45e1665 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/face/enums/FaceRecognitionFilterReason.java @@ -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; + } +} diff --git a/src/main/java/com/ycwl/basic/model/pc/face/req/FaceRecognitionUpdateReq.java b/src/main/java/com/ycwl/basic/model/pc/face/req/FaceRecognitionUpdateReq.java new file mode 100644 index 00000000..d1bb8bde --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/face/req/FaceRecognitionUpdateReq.java @@ -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 manualAcceptedSampleIds; + + /** + * 用户主动排除的样本ID列表。 + */ + private List manualRejectedSampleIds; + + /** + * 是否强制重新走一次识别流程。 + */ + private Boolean forceRematch; + + /** + * 前端传回的备注信息。 + */ + private String remark; +} diff --git a/src/main/java/com/ycwl/basic/model/pc/face/resp/FaceRecognitionDetailVO.java b/src/main/java/com/ycwl/basic/model/pc/face/resp/FaceRecognitionDetailVO.java new file mode 100644 index 00000000..74816955 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/face/resp/FaceRecognitionDetailVO.java @@ -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 acceptedSamples; + + /** + * 被系统过滤的样本信息。 + */ + private List filteredSamples; +} diff --git a/src/main/java/com/ycwl/basic/model/pc/face/resp/FaceRecognitionSampleVO.java b/src/main/java/com/ycwl/basic/model/pc/face/resp/FaceRecognitionSampleVO.java new file mode 100644 index 00000000..5754ed0e --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/face/resp/FaceRecognitionSampleVO.java @@ -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 filterReasons; + + /** + * 过滤原因的描述集合,方便前端直接展示。 + */ + private List filterReasonTexts; +} diff --git a/src/main/java/com/ycwl/basic/model/task/resp/SampleFilterTrace.java b/src/main/java/com/ycwl/basic/model/task/resp/SampleFilterTrace.java new file mode 100644 index 00000000..d134a729 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/task/resp/SampleFilterTrace.java @@ -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 acceptedSampleIds; + private Map> 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> getFilteredReasonMap() { + if (filteredReasonMap == null) { + return Collections.emptyMap(); + } + return filteredReasonMap; + } +} diff --git a/src/main/java/com/ycwl/basic/model/task/resp/SearchFaceRespVo.java b/src/main/java/com/ycwl/basic/model/task/resp/SearchFaceRespVo.java index d590f119..1dd8f351 100644 --- a/src/main/java/com/ycwl/basic/model/task/resp/SearchFaceRespVo.java +++ b/src/main/java/com/ycwl/basic/model/task/resp/SearchFaceRespVo.java @@ -11,4 +11,5 @@ public class SearchFaceRespVo { private String searchResultJson; private Float firstMatchRate; private boolean lowThreshold; + private SampleFilterTrace filterTrace; } diff --git a/src/main/java/com/ycwl/basic/service/pc/FaceService.java b/src/main/java/com/ycwl/basic/service/pc/FaceService.java index 6d720067..14d82bd0 100644 --- a/src/main/java/com/ycwl/basic/service/pc/FaceService.java +++ b/src/main/java/com/ycwl/basic/service/pc/FaceService.java @@ -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.scenic.content.ContentPageVO; 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.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.resp.FaceSampleRespVO; import com.ycwl.basic.model.task.resp.SearchFaceRespVo; @@ -51,4 +53,8 @@ public interface FaceService { List getLowMatchedFaceSamples(Long faceId); void matchCustomFaceId(Long faceId, List faceSampleIds); + + FaceRecognitionDetailVO updateRecognition(FaceRecognitionUpdateReq req); + + FaceRecognitionDetailVO getRecognitionDetail(Long faceId); } diff --git a/src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java b/src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java index 35595f77..64d11233 100644 --- a/src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java +++ b/src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java @@ -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.scenic.content.ContentPageVO; 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.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.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.faceSample.entity.FaceSampleEntity; 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.VideoEntity; 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.repository.DeviceRepository; import com.ycwl.basic.repository.FaceRepository; @@ -74,12 +80,19 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; 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.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -1098,21 +1111,23 @@ public class FaceServiceImpl implements FaceService { @Override public void matchCustomFaceId(Long faceId, List faceSampleIds) { - // 参数验证 + handleCustomFaceMatching(faceId, faceSampleIds); + } + + private SearchFaceRespVo handleCustomFaceMatching(Long faceId, List faceSampleIds) { if (faceId == null) { throw new IllegalArgumentException("faceId 不能为空"); } if (faceSampleIds == null || faceSampleIds.isEmpty()) { throw new IllegalArgumentException("faceSampleIds 不能为空"); } - + log.debug("开始自定义人脸匹配:faceId={}, faceSampleIds={}", faceId, faceSampleIds); // 记录自定义匹配调用次数,便于监控调用频率 recordCustomMatchCount(faceId); - + try { - // 1. 获取基础数据 FaceEntity face = faceRepository.getFace(faceId); if (face == null) { log.warn("人脸不存在,faceId: {}", faceId); @@ -1127,7 +1142,7 @@ public class FaceServiceImpl implements FaceService { ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId()); IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId()); - + if (faceBodyAdapter == null) { log.error("无法获取人脸识别适配器,scenicId: {}", face.getScenicId()); throw new BaseException("人脸识别服务不可用,请稍后再试"); @@ -1138,7 +1153,7 @@ public class FaceServiceImpl implements FaceService { log.debug("face_select_post_mode配置值: {}", faceSelectPostMode); SearchFaceRespVo mergedResult; - + // 2. 根据face_select_post_mode决定搜索策略 if (Integer.valueOf(2).equals(faceSelectPostMode)) { // 模式2:不搜索,直接使用用户选择的faceSampleIds @@ -1150,15 +1165,15 @@ public class FaceServiceImpl implements FaceService { List searchResults = new ArrayList<>(); for (FaceSampleEntity faceSample : faceSamples) { try { - SearchFaceRespVo result = faceService.searchFace(faceBodyAdapter, - String.valueOf(face.getScenicId()), - faceSample.getFaceUrl(), + SearchFaceRespVo result = faceService.searchFace(faceBodyAdapter, + String.valueOf(face.getScenicId()), + faceSample.getFaceUrl(), "自定义人脸匹配"); if (result != null) { searchResults.add(result); } } catch (Exception e) { - log.warn("人脸样本搜索失败,faceSampleId={}, faceUrl={}", + log.warn("人脸样本搜索失败,faceSampleId={}, faceUrl={}", faceSample.getId(), faceSample.getFaceUrl(), e); // 继续处理其他样本,不中断整个流程 } @@ -1172,56 +1187,60 @@ public class FaceServiceImpl implements FaceService { // 2.2 根据模式整合多个搜索结果 mergedResult = mergeSearchResults(searchResults, faceSelectPostMode); } - + // 3. 应用后置筛选逻辑 if (mergedResult.getSampleListIds() != null && !mergedResult.getSampleListIds().isEmpty()) { List allFaceSampleList = faceSampleMapper.listByIds(mergedResult.getSampleListIds()); - List filteredSampleIds = faceService.applySampleFilters(mergedResult.getSampleListIds(), allFaceSampleList, scenicConfig); + SampleFilterTrace filterTrace = faceService.applySampleFiltersWithTrace( + mergedResult.getSampleListIds(), allFaceSampleList, scenicConfig); + List filteredSampleIds = filterTrace.getAcceptedSampleIds() == null + ? Collections.emptyList() + : filterTrace.getAcceptedSampleIds(); mergedResult.setSampleListIds(filteredSampleIds); - log.debug("应用后置筛选:原始样本数={}, 筛选后样本数={}", allFaceSampleList.size(), filteredSampleIds.size()); + mergedResult.setFilterTrace(filterTrace); + log.debug("应用后置筛选:原始样本数={}, 筛选后样本数={}", + allFaceSampleList.size(), filteredSampleIds.size()); } - // 5. 更新人脸实体结果 updateFaceEntityResult(face, mergedResult, faceId); - - // 6. 执行后续业务逻辑 + List sampleListIds = mergedResult.getSampleListIds(); if (sampleListIds != null && !sampleListIds.isEmpty()) { try { List memberSourceEntityList = processMemberSources(sampleListIds, face); - + if (!memberSourceEntityList.isEmpty()) { List freeSourceIds = processFreeSourceLogic(memberSourceEntityList, scenicConfig, false); - processBuyStatus(memberSourceEntityList, freeSourceIds, face.getMemberId(), - face.getScenicId(), faceId); - - handleVideoRecreation(scenicConfig, memberSourceEntityList, faceId, - face.getMemberId(), sampleListIds, false); - - // 过滤已存在的关联关系和无效的source引用,防止数据不一致 + processBuyStatus(memberSourceEntityList, freeSourceIds, face.getMemberId(), + face.getScenicId(), faceId); + + handleVideoRecreation(scenicConfig, memberSourceEntityList, faceId, + face.getMemberId(), sampleListIds, false); + List existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList); List validFiltered = sourceMapper.filterValidSourceRelations(existingFiltered); if (!validFiltered.isEmpty()) { sourceMapper.addRelations(validFiltered); - log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}", - faceId, memberSourceEntityList.size(), validFiltered.size()); + log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}", + faceId, memberSourceEntityList.size(), validFiltered.size()); } else { log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}", faceId, memberSourceEntityList.size()); } memberRelationRepository.clearSCacheByFace(faceId); taskTaskService.autoCreateTaskByFaceId(faceId); - + log.info("自定义人脸匹配完成:faceId={}, 匹配样本数={}, 关联源文件数={}, 免费数={}", - faceId, sampleListIds.size(), memberSourceEntityList.size(), freeSourceIds.size()); + faceId, sampleListIds.size(), memberSourceEntityList.size(), freeSourceIds.size()); } } catch (Exception e) { log.error("处理源文件关联失败,faceId={}", faceId, e); - // 源文件关联失败不影响主流程 } } else { log.warn("自定义人脸匹配无结果:faceId={}, faceSampleIds={}", faceId, faceSampleIds); } - + + return mergedResult; + } catch (BaseException e) { throw e; } catch (Exception e) { @@ -1237,9 +1256,323 @@ public class FaceServiceImpl implements FaceService { 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 currentAccepted = parseMatchSampleIds(face.getMatchSampleIds()); + List manualAccepted = Optional.ofNullable(req.getManualAcceptedSampleIds()).orElse(Collections.emptyList()); + List manualRejected = Optional.ofNullable(req.getManualRejectedSampleIds()).orElse(Collections.emptyList()); + Set manualRejectedSet = new HashSet<>(manualRejected); + + LinkedHashSet 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 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 resultItems = JacksonUtil.fromJsonToList(matchResultJson, SearchFaceResultItem.class); + if (resultItems == null) { + resultItems = Collections.emptyList(); + } + + List persistedAcceptedIds = parseMatchSampleIds(face.getMatchSampleIds()); + LinkedHashSet sampleUniverse = new LinkedHashSet<>(); + Map 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 allSampleIds = new ArrayList<>(sampleUniverse); + if (allSampleIds.isEmpty()) { + detail.setAcceptedSamples(Collections.emptyList()); + detail.setFilteredSamples(Collections.emptyList()); + return detail; + } + + List allSamples = faceSampleMapper.listByIds(allSampleIds); + Map sampleEntityMap = allSamples.stream() + .collect(Collectors.toMap(FaceSampleEntity::getId, Function.identity(), (a, b) -> a, LinkedHashMap::new)); + + List sourceEntities = sourceMapper.listBySampleIds(allSampleIds); + Map sourceBySampleId = sourceEntities.stream() + .collect(Collectors.toMap(SourceEntity::getFaceSampleId, Function.identity(), (a, b) -> a, LinkedHashMap::new)); + Map 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 initialAcceptedIds = allSampleIds.stream() + .filter(id -> { + SearchFaceResultItem item = itemBySampleId.get(id); + return item != null && item.getScore() != null && item.getScore() > threshold; + }) + .collect(Collectors.toList()); + + List orderedSampleList = allSampleIds.stream() + .map(sampleEntityMap::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + SampleFilterTrace filterTrace = faceService.applySampleFiltersWithTrace(initialAcceptedIds, orderedSampleList, scenicConfig); + List systemAcceptedIds = filterTrace.getAcceptedSampleIds() == null + ? Collections.emptyList() + : filterTrace.getAcceptedSampleIds(); + + Set initialAcceptedSet = new HashSet<>(initialAcceptedIds); + for (Long sampleId : allSampleIds) { + if (!initialAcceptedSet.contains(sampleId) && !systemAcceptedIds.contains(sampleId)) { + filterTrace.addReason(sampleId, FaceRecognitionFilterReason.SCORE_BELOW_THRESHOLD); + } + } + + Set systemAcceptedSet = new HashSet<>(systemAcceptedIds); + Set persistedAcceptedSet = new HashSet<>(persistedAcceptedIds); + for (Long sampleId : systemAcceptedSet) { + if (!persistedAcceptedSet.contains(sampleId)) { + filterTrace.addReason(sampleId, FaceRecognitionFilterReason.MANUAL_REJECTED); + } + } + + List relations = new ArrayList<>(); + List videoRelations = memberRelationRepository.listSourceByFaceRelation(faceId, 1); + if (videoRelations != null) { + relations.addAll(videoRelations); + } + List imageRelations = memberRelationRepository.listSourceByFaceRelation(faceId, 2); + if (imageRelations != null) { + relations.addAll(imageRelations); + } + Map 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> reasonMap = filterTrace.getFilteredReasonMap(); + Map deviceCache = new HashMap<>(); + + List 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 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 acceptedSet = new HashSet<>(acceptedOrdered); + List filteredSamples = new ArrayList<>(); + for (Long sampleId : allSampleIds) { + if (acceptedSet.contains(sampleId)) { + continue; + } + EnumSet reasons = reasonMap.get(sampleId); + List 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 parseMatchSampleIds(String matchSampleIds) { + if (Strings.isBlank(matchSampleIds)) { + return Collections.emptyList(); + } + String[] segments = matchSampleIds.split(","); + List 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 deviceCache, + List 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 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 mergeMode 合并模式:0-并集,1-交集 * @return 合并后的结果 @@ -1250,7 +1583,7 @@ public class FaceServiceImpl implements FaceService { if (searchResults == null || searchResults.isEmpty()) { return mergedResult; } - + List allSearchJsons = new ArrayList<>(); float maxScore = 0f; float maxFirstMatchRate = 0f; @@ -1289,7 +1622,7 @@ public class FaceServiceImpl implements FaceService { finalSampleIds = new ArrayList<>(allSampleIds); log.debug("使用并集模式合并搜索结果,并集样本数: {}", finalSampleIds.size()); } - + mergedResult.setSampleListIds(finalSampleIds); mergedResult.setSearchResultJson(String.join("|", allSearchJsons)); mergedResult.setScore(maxScore); @@ -1309,52 +1642,52 @@ public class FaceServiceImpl implements FaceService { if (searchResults == null || searchResults.isEmpty()) { return new ArrayList<>(); } - + // 过滤掉空结果 List> 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 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 搜索结果对象 */ private SearchFaceRespVo createDirectResult(List 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; } diff --git a/src/main/java/com/ycwl/basic/service/task/TaskFaceService.java b/src/main/java/com/ycwl/basic/service/task/TaskFaceService.java index 76c220dd..6887251a 100644 --- a/src/main/java/com/ycwl/basic/service/task/TaskFaceService.java +++ b/src/main/java/com/ycwl/basic/service/task/TaskFaceService.java @@ -3,6 +3,7 @@ package com.ycwl.basic.service.task; import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter; import com.ycwl.basic.integration.common.manager.ScenicConfigManager; 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 org.springframework.web.multipart.MultipartFile; @@ -25,7 +26,19 @@ public interface TaskFaceService { * @param scenicConfig 景区配置管理器 * @return 筛选后的样本ID列表 */ - List applySampleFilters(List acceptedSampleIds, - List allFaceSampleList, + List applySampleFilters(List acceptedSampleIds, + List allFaceSampleList, ScenicConfigManager scenicConfig); + + /** + * 带过滤轨迹的样本筛选逻辑。 + * + * @param acceptedSampleIds 已接受的样本ID列表 + * @param allFaceSampleList 所有人脸样本实体列表 + * @param scenicConfig 景区配置 + * @return 包含最终样本及过滤原因的轨迹对象 + */ + SampleFilterTrace applySampleFiltersWithTrace(List acceptedSampleIds, + List allFaceSampleList, + ScenicConfigManager scenicConfig); } diff --git a/src/main/java/com/ycwl/basic/service/task/impl/TaskFaceServiceImpl.java b/src/main/java/com/ycwl/basic/service/task/impl/TaskFaceServiceImpl.java index aebb9e10..b25a4fd5 100644 --- a/src/main/java/com/ycwl/basic/service/task/impl/TaskFaceServiceImpl.java +++ b/src/main/java/com/ycwl/basic/service/task/impl/TaskFaceServiceImpl.java @@ -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.face.entity.FaceEntity; 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.resp.MatchLocalRecord; 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.SourceEntity; 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.FaceRepository; import com.ycwl.basic.repository.ScenicRepository; @@ -52,7 +54,10 @@ import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.HashSet; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -264,7 +269,18 @@ public class TaskFaceServiceImpl implements TaskFaceService { idIndexMap.put(allFaceSampleIds.get(i), i); } allFaceSampleList.sort(Comparator.comparing(sample -> idIndexMap.get(sample.getId()))); - acceptFaceSampleIds = applySampleFilters(acceptFaceSampleIds, allFaceSampleList, scenicConfig); + SampleFilterTrace filterTrace = applySampleFiltersWithTrace(acceptFaceSampleIds, allFaceSampleList, scenicConfig); + List finalAcceptedSampleIds = filterTrace.getAcceptedSampleIds() == null + ? Collections.emptyList() + : filterTrace.getAcceptedSampleIds(); + Set 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 collect = new ArrayList<>(); for (SearchFaceResultItem item : records) { MatchLocalRecord record = new MatchLocalRecord(); @@ -277,7 +293,7 @@ public class TaskFaceServiceImpl implements TaskFaceService { if (device != null) { record.setDeviceName(device.getName()); } - record.setAccept(acceptFaceSampleIds.contains(optionalFse.get().getId())); + record.setAccept(finalAcceptedSampleIds.contains(optionalFse.get().getId())); record.setFaceUrl(optionalFse.get().getFaceUrl()); record.setShotDate(optionalFse.get().getCreateAt()); } @@ -289,13 +305,14 @@ public class TaskFaceServiceImpl implements TaskFaceService { collect.add(record); } logEntity.setMatchLocalRecord(JacksonUtil.toJSONString(collect)); - if (acceptFaceSampleIds.isEmpty()) { + if (finalAcceptedSampleIds.isEmpty()) { respVo.setFirstMatchRate(0f); respVo.setSampleListIds(Collections.emptyList()); return respVo; } respVo.setFirstMatchRate(response.getFirstMatchRate()); - respVo.setSampleListIds(acceptFaceSampleIds); + respVo.setSampleListIds(finalAcceptedSampleIds); + respVo.setFilterTrace(filterTrace); return respVo; } catch (Exception e) { logEntity.setMatchRawResult("识别错误,错误为:["+e.getLocalizedMessage()+"]"); @@ -389,132 +406,120 @@ public class TaskFaceServiceImpl implements TaskFaceService { */ @Override public List applySampleFilters(List acceptedSampleIds, - List allFaceSampleList, - ScenicConfigManager scenicConfig) { + List allFaceSampleList, + ScenicConfigManager scenicConfig) { + SampleFilterTrace trace = applySampleFiltersWithTrace(acceptedSampleIds, allFaceSampleList, scenicConfig); + List result = trace.getAcceptedSampleIds(); + return result == null ? Collections.emptyList() : result; + } + + @Override + public SampleFilterTrace applySampleFiltersWithTrace(List acceptedSampleIds, + List allFaceSampleList, + ScenicConfigManager scenicConfig) { + SampleFilterTrace trace = new SampleFilterTrace(); if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) { - return acceptedSampleIds; + trace.setAcceptedSampleIds(acceptedSampleIds == null ? Collections.emptyList() : new ArrayList<>(acceptedSampleIds)); + return trace; } - if (scenicConfig == null) { - // 没有配置,不管 - return acceptedSampleIds; + if (allFaceSampleList == null || allFaceSampleList.isEmpty()) { + trace.setAcceptedSampleIds(new ArrayList<>(acceptedSampleIds)); + return trace; } - // 1. 找到最高匹配的样本(按创建时间倒序排列的第一个) - Optional firstFaceSample = allFaceSampleList.stream() - .filter(faceSample -> acceptedSampleIds.contains(faceSample.getId())).max(Comparator.comparing(FaceSampleEntity::getCreateAt)); + Map sampleMap = allFaceSampleList.stream() + .collect(Collectors.toMap(FaceSampleEntity::getId, sample -> sample, (a, b) -> a)); - if (firstFaceSample.isEmpty()) { - log.warn("样本筛选逻辑:未找到匹配的人脸样本,acceptedIds: {}", acceptedSampleIds); - return acceptedSampleIds; + List workingList = acceptedSampleIds.stream() + .filter(sampleMap::containsKey) + .collect(Collectors.toCollection(ArrayList::new)); + + if (workingList.isEmpty()) { + trace.setAcceptedSampleIds(Collections.emptyList()); + return trace; } - FaceSampleEntity topMatchSample = firstFaceSample.get(); - log.debug("样本筛选逻辑:找到最高匹配样本 ID={}, 创建时间={}", - topMatchSample.getId(), topMatchSample.getCreateAt()); - - List filteredIds = acceptedSampleIds; - - // 2. 应用时间范围筛选(基于景区配置) - if (scenicConfig.getInteger("tour_time", 0) > 0) { - filteredIds = filterSampleIdsByTimeRange(filteredIds, topMatchSample, scenicConfig.getInteger("tour_time")); - log.debug("应用时间范围筛选:游览时间限制={}分钟", scenicConfig.getInteger("tour_time")); + Integer tourMinutes = scenicConfig != null ? scenicConfig.getInteger("tour_time") : null; + if (tourMinutes != null && tourMinutes > 0) { + workingList = filterSampleIdsByTimeRangeWithTrace(workingList, sampleMap, tourMinutes, trace); + log.debug("应用时间范围筛选:游览时间限制={}分钟,过滤后数量={}", tourMinutes, workingList.size()); } else { log.debug("时间范围逻辑:景区未设置游览时间限制"); } + workingList = applyDevicePhotoLimitWithTrace(workingList, sampleMap, trace); + trace.setAcceptedSampleIds(new ArrayList<>(workingList)); - - // 3. 应用设备照片数量限制筛选 - 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; + log.debug("样本筛选完成:原始数量={}, 最终数量={}", acceptedSampleIds.size(), workingList.size()); + return trace; } - /** - * 根据时间范围过滤人脸样本ID - * 基于最高匹配样本的时间,过滤出指定时间范围内的样本ID - */ - private List filterSampleIdsByTimeRange(List acceptedSampleIds, - FaceSampleEntity firstMatch, - int tourMinutes) { - if (acceptedSampleIds == null || acceptedSampleIds.isEmpty() || - firstMatch == null || tourMinutes <= 0) { - return acceptedSampleIds; - } - - List acceptFaceSampleList = faceSampleMapper.listByIds(acceptedSampleIds); - if (acceptFaceSampleList.isEmpty()) { - return acceptedSampleIds; - } - - Date startDate = DateUtil.offsetMinute(firstMatch.getCreateAt(), -tourMinutes); - Date endDate = DateUtil.offsetMinute(firstMatch.getCreateAt(), 1); - - List 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 applyDevicePhotoLimit(List acceptedSampleIds, - List allFaceSampleList) { + private List filterSampleIdsByTimeRangeWithTrace(List acceptedSampleIds, + Map sampleMap, + int tourMinutes, + SampleFilterTrace trace) { 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; } - // 获取过滤后的样本列表 - List filteredSamples = allFaceSampleList.stream() - .filter(sample -> acceptedSampleIds.contains(sample.getId())) - .collect(Collectors.toList()); + Date startDate = DateUtil.offsetMinute(topMatchSample.getCreateAt(), -tourMinutes); + Date endDate = DateUtil.offsetMinute(topMatchSample.getCreateAt(), 1); - // 按设备ID分组 - Map> samplesByDevice = filteredSamples.stream() - .collect(Collectors.groupingBy(FaceSampleEntity::getDeviceId)); - - List resultIds = new ArrayList<>(); - - // 处理每个设备的样本 - for (Map.Entry> entry : samplesByDevice.entrySet()) { - Long deviceId = entry.getKey(); - List deviceSamples = entry.getValue(); - - // 获取设备配置 - DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId); - Integer limitPhoto = null; - if (deviceConfig != null) { - limitPhoto = deviceConfig.getInteger("limit_photo"); + List result = new ArrayList<>(); + for (Long sampleId : acceptedSampleIds) { + FaceSampleEntity sample = sampleMap.get(sampleId); + if (sample == null || sample.getCreateAt() == null) { + result.add(sampleId); + continue; } + 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 applyDevicePhotoLimitWithTrace(List acceptedSampleIds, + Map sampleMap, + SampleFilterTrace trace) { + if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) { + return Collections.emptyList(); + } + + Map usedCount = new HashMap<>(); + Map limitCache = new HashMap<>(); + List 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) { List deviceSampleIds = deviceSamples.stream() .map(FaceSampleEntity::getId)