mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-12 02:20:28 +08:00
feat: 添加人脸探索功能,支持用户查看和管理人脸聚类
This commit is contained in:
@@ -24,7 +24,11 @@ public class FaceController(
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await faceManagementService.GetUserFaceClustersAsync(GetCurrentUserId(), page, pageSize);
|
||||
var userId = GetCurrentUserId();
|
||||
if (!userId.HasValue)
|
||||
return Error<PaginatedResult<FaceClusterResponse>>("用户未认证", 401);
|
||||
|
||||
var result = await faceManagementService.GetUserFaceClustersAsync(userId.Value, page, pageSize);
|
||||
return Success(result, "获取人脸聚类列表成功");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -43,8 +47,12 @@ public class FaceController(
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (!userId.HasValue)
|
||||
return Error<PaginatedResult<PictureResponse>>("用户未认证", 401);
|
||||
|
||||
var result = await faceManagementService.GetUserPicturesByClusterAsync(
|
||||
GetCurrentUserId(), clusterId, page, pageSize);
|
||||
userId.Value, clusterId, page, pageSize);
|
||||
return Success(result, "获取聚类图片成功");
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
@@ -68,8 +76,12 @@ public class FaceController(
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (!userId.HasValue)
|
||||
return Error<FaceClusterResponse>("用户未认证", 401);
|
||||
|
||||
var result = await faceManagementService.UpdateUserClusterAsync(
|
||||
GetCurrentUserId(), clusterId, request.PersonName, request.Description);
|
||||
userId.Value, clusterId, request.PersonName, request.Description);
|
||||
return Success(result, "更新聚类信息成功");
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
@@ -92,7 +104,11 @@ public class FaceController(
|
||||
{
|
||||
try
|
||||
{
|
||||
await faceClusteringService.ClusterUserFacesAsync(GetCurrentUserId());
|
||||
var userId = GetCurrentUserId();
|
||||
if (!userId.HasValue)
|
||||
return Error<bool>("用户未认证", 401);
|
||||
|
||||
await faceClusteringService.ClusterUserFacesAsync(userId.Value);
|
||||
return Success(true, "人脸聚类任务已开始");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -111,8 +127,12 @@ public class FaceController(
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (!userId.HasValue)
|
||||
return Error<bool>("用户未认证", 401);
|
||||
|
||||
var result = await faceManagementService.MergeUserClustersAsync(
|
||||
GetCurrentUserId(), request.SourceClusterId, targetClusterId);
|
||||
userId.Value, request.SourceClusterId, targetClusterId);
|
||||
return Success(result, "合并聚类成功");
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
@@ -134,7 +154,11 @@ public class FaceController(
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await faceManagementService.RemoveUserFaceFromClusterAsync(GetCurrentUserId(), faceId);
|
||||
var userId = GetCurrentUserId();
|
||||
if (!userId.HasValue)
|
||||
return Error<bool>("用户未认证", 401);
|
||||
|
||||
var result = await faceManagementService.RemoveUserFaceFromClusterAsync(userId.Value, faceId);
|
||||
return Success(result, "移除人脸成功");
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
@@ -149,12 +173,6 @@ public class FaceController(
|
||||
}
|
||||
}
|
||||
|
||||
private int GetCurrentUserId()
|
||||
{
|
||||
// 从JWT或Claims中获取当前用户ID
|
||||
var userIdClaim = User.FindFirst("id") ?? User.FindFirst("sub");
|
||||
return int.Parse(userIdClaim?.Value ?? "0");
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateClusterRequest
|
||||
|
||||
@@ -7,13 +7,14 @@ public class FaceClusteringService(
|
||||
IDbContextFactory<MyDbContext> contextFactory,
|
||||
ILogger<FaceClusteringService> logger) : IFaceClusteringService
|
||||
{
|
||||
private const double SIMILARITY_THRESHOLD = 0.5;
|
||||
private const double BASE_SIMILARITY_THRESHOLD = 0.3;
|
||||
private const double HIGH_CONFIDENCE_THRESHOLD = 0.5;
|
||||
private const int MAX_COMPARISON_FACES = 10;
|
||||
|
||||
public async Task<List<FaceCluster>> ClusterFacesAsync()
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 获取所有有嵌入向量但未分类的人脸
|
||||
var unclusteredFaces = await dbContext.Faces
|
||||
.Where(f => f.Embedding != null && f.ClusterId == null)
|
||||
.Include(f => f.Picture)
|
||||
@@ -77,7 +78,7 @@ public class FaceClusteringService(
|
||||
if (representativeFace.Embedding != null)
|
||||
{
|
||||
var similarity = CalculateSimilarity(face.Embedding, representativeFace.Embedding);
|
||||
if (similarity >= SIMILARITY_THRESHOLD)
|
||||
if (similarity >= BASE_SIMILARITY_THRESHOLD)
|
||||
{
|
||||
face.ClusterId = cluster.Id;
|
||||
await dbContext.SaveChangesAsync();
|
||||
@@ -107,7 +108,23 @@ public class FaceClusteringService(
|
||||
{
|
||||
if (embedding1.Length != embedding2.Length) return 0;
|
||||
|
||||
// 计算余弦相似度
|
||||
// 1. 余弦相似度
|
||||
double cosineSim = CalculateCosineSimilarity(embedding1, embedding2);
|
||||
|
||||
// 2. 欧几里得距离转换为相似度
|
||||
double euclideanSim = CalculateEuclideanSimilarity(embedding1, embedding2);
|
||||
|
||||
// 3. 曼哈顿距离转换为相似度
|
||||
double manhattanSim = CalculateManhattanSimilarity(embedding1, embedding2);
|
||||
|
||||
// 加权组合多个相似度指标
|
||||
double weightedSimilarity = cosineSim * 0.6 + euclideanSim * 0.3 + manhattanSim * 0.1;
|
||||
|
||||
return weightedSimilarity;
|
||||
}
|
||||
|
||||
private double CalculateCosineSimilarity(float[] embedding1, float[] embedding2)
|
||||
{
|
||||
double dot = 0, norm1 = 0, norm2 = 0;
|
||||
|
||||
for (int i = 0; i < embedding1.Length; i++)
|
||||
@@ -118,42 +135,112 @@ public class FaceClusteringService(
|
||||
}
|
||||
|
||||
if (norm1 == 0 || norm2 == 0) return 0;
|
||||
|
||||
return dot / (Math.Sqrt(norm1) * Math.Sqrt(norm2));
|
||||
}
|
||||
|
||||
private double CalculateEuclideanSimilarity(float[] embedding1, float[] embedding2)
|
||||
{
|
||||
double sumSquareDiff = 0;
|
||||
for (int i = 0; i < embedding1.Length; i++)
|
||||
{
|
||||
double diff = embedding1[i] - embedding2[i];
|
||||
sumSquareDiff += diff * diff;
|
||||
}
|
||||
|
||||
double distance = Math.Sqrt(sumSquareDiff);
|
||||
// 转换为相似度:距离越小,相似度越高
|
||||
return 1.0 / (1.0 + distance);
|
||||
}
|
||||
|
||||
private double CalculateManhattanSimilarity(float[] embedding1, float[] embedding2)
|
||||
{
|
||||
double sumAbsDiff = 0;
|
||||
for (int i = 0; i < embedding1.Length; i++)
|
||||
{
|
||||
sumAbsDiff += Math.Abs(embedding1[i] - embedding2[i]);
|
||||
}
|
||||
|
||||
// 转换为相似度
|
||||
return 1.0 / (1.0 + sumAbsDiff / embedding1.Length);
|
||||
}
|
||||
|
||||
private async Task<FaceCluster?> FindBestClusterAsync(Face face, List<FaceCluster> newClusters, MyDbContext dbContext)
|
||||
{
|
||||
if (face.Embedding == null) return null;
|
||||
|
||||
double bestSimilarity = 0;
|
||||
FaceCluster? bestCluster = null;
|
||||
var clusterSimilarities = new List<(FaceCluster cluster, double avgSimilarity, double maxSimilarity, int comparisonCount)>();
|
||||
|
||||
// 检查现有数据库中的聚类
|
||||
var existingClusters = await dbContext.FaceClusters
|
||||
.Include(c => c.Faces.Take(5)) // 取前5个人脸作为比较
|
||||
.Include(c => c.Faces.Take(MAX_COMPARISON_FACES))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cluster in existingClusters.Concat(newClusters))
|
||||
{
|
||||
if (cluster.Faces?.Any() == true)
|
||||
{
|
||||
foreach (var clusterFace in cluster.Faces)
|
||||
var similarities = new List<double>();
|
||||
|
||||
foreach (var clusterFace in cluster.Faces.Take(MAX_COMPARISON_FACES))
|
||||
{
|
||||
if (clusterFace.Embedding != null)
|
||||
{
|
||||
var similarity = CalculateSimilarity(face.Embedding, clusterFace.Embedding);
|
||||
if (similarity > bestSimilarity && similarity >= SIMILARITY_THRESHOLD)
|
||||
{
|
||||
bestSimilarity = similarity;
|
||||
bestCluster = cluster;
|
||||
}
|
||||
similarities.Add(similarity);
|
||||
}
|
||||
}
|
||||
|
||||
if (similarities.Any())
|
||||
{
|
||||
double avgSimilarity = similarities.Average();
|
||||
double maxSimilarity = similarities.Max();
|
||||
|
||||
clusterSimilarities.Add((cluster, avgSimilarity, maxSimilarity, similarities.Count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestCluster;
|
||||
// 智能选择最佳聚类
|
||||
return SelectBestCluster(clusterSimilarities);
|
||||
}
|
||||
|
||||
private FaceCluster? SelectBestCluster(List<(FaceCluster cluster, double avgSimilarity, double maxSimilarity, int comparisonCount)> clusterSimilarities)
|
||||
{
|
||||
if (!clusterSimilarities.Any()) return null;
|
||||
|
||||
// 按照综合评分排序
|
||||
var rankedClusters = clusterSimilarities
|
||||
.Where(cs => cs.avgSimilarity >= BASE_SIMILARITY_THRESHOLD || cs.maxSimilarity >= HIGH_CONFIDENCE_THRESHOLD)
|
||||
.Select(cs => new
|
||||
{
|
||||
cs.cluster,
|
||||
cs.avgSimilarity,
|
||||
cs.maxSimilarity,
|
||||
cs.comparisonCount,
|
||||
// 综合评分:平均相似度权重60%,最高相似度权重30%,样本数量权重10%
|
||||
Score = cs.avgSimilarity * 0.6 + cs.maxSimilarity * 0.3 +
|
||||
Math.Min(cs.comparisonCount / (double)MAX_COMPARISON_FACES, 1.0) * 0.1
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
|
||||
if (!rankedClusters.Any()) return null;
|
||||
|
||||
var bestMatch = rankedClusters.First();
|
||||
|
||||
// 额外验证:如果最高相似度很高,直接接受
|
||||
if (bestMatch.maxSimilarity >= HIGH_CONFIDENCE_THRESHOLD)
|
||||
{
|
||||
return bestMatch.cluster;
|
||||
}
|
||||
|
||||
// 如果平均相似度足够高且有足够样本,接受
|
||||
if (bestMatch.avgSimilarity >= BASE_SIMILARITY_THRESHOLD && bestMatch.comparisonCount >= 2)
|
||||
{
|
||||
return bestMatch.cluster;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<FaceCluster>> ClusterUserFacesAsync(int userId)
|
||||
@@ -203,34 +290,160 @@ public class FaceClusteringService(
|
||||
{
|
||||
if (face.Embedding == null) return null;
|
||||
|
||||
double bestSimilarity = 0;
|
||||
FaceCluster? bestCluster = null;
|
||||
var clusterSimilarities = new List<(FaceCluster cluster, double avgSimilarity, double maxSimilarity, int comparisonCount)>();
|
||||
|
||||
// 检查该用户现有的聚类
|
||||
var existingClusters = await dbContext.FaceClusters
|
||||
.Where(c => dbContext.Faces.Any(f => f.ClusterId == c.Id && f.Picture.UserId == userId))
|
||||
.Include(c => c.Faces.Where(f => f.Picture.UserId == userId).Take(5))
|
||||
.Include(c => c.Faces.Where(f => f.Picture.UserId == userId).Take(MAX_COMPARISON_FACES))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cluster in existingClusters.Concat(newClusters))
|
||||
{
|
||||
if (cluster.Faces?.Any() == true)
|
||||
{
|
||||
foreach (var clusterFace in cluster.Faces)
|
||||
var similarities = new List<double>();
|
||||
|
||||
foreach (var clusterFace in cluster.Faces.Take(MAX_COMPARISON_FACES))
|
||||
{
|
||||
if (clusterFace.Embedding != null)
|
||||
{
|
||||
var similarity = CalculateSimilarity(face.Embedding, clusterFace.Embedding);
|
||||
if (similarity > bestSimilarity && similarity >= SIMILARITY_THRESHOLD)
|
||||
{
|
||||
bestSimilarity = similarity;
|
||||
bestCluster = cluster;
|
||||
}
|
||||
similarities.Add(similarity);
|
||||
}
|
||||
}
|
||||
|
||||
if (similarities.Any())
|
||||
{
|
||||
double avgSimilarity = similarities.Average();
|
||||
double maxSimilarity = similarities.Max();
|
||||
|
||||
clusterSimilarities.Add((cluster, avgSimilarity, maxSimilarity, similarities.Count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SelectBestCluster(clusterSimilarities);
|
||||
}
|
||||
|
||||
// 新增:聚类质量评估方法
|
||||
public async Task<ClusterQualityMetrics> EvaluateClusterQualityAsync(int clusterId)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var cluster = await dbContext.FaceClusters
|
||||
.Include(c => c.Faces.Where(f => f.Embedding != null))
|
||||
.FirstOrDefaultAsync(c => c.Id == clusterId);
|
||||
|
||||
if (cluster?.Faces == null || !cluster.Faces.Any())
|
||||
{
|
||||
return new ClusterQualityMetrics { IsValid = false };
|
||||
}
|
||||
|
||||
var embeddings = cluster.Faces.Select(f => f.Embedding).Where(e => e != null).ToArray();
|
||||
if (embeddings.Length < 2)
|
||||
{
|
||||
return new ClusterQualityMetrics { IsValid = true, InternalSimilarity = 1.0, FaceCount = embeddings.Length };
|
||||
}
|
||||
|
||||
// 计算内部相似度
|
||||
var similarities = new List<double>();
|
||||
for (int i = 0; i < embeddings.Length; i++)
|
||||
{
|
||||
for (int j = i + 1; j < embeddings.Length; j++)
|
||||
{
|
||||
similarities.Add(CalculateSimilarity(embeddings[i]!, embeddings[j]!));
|
||||
}
|
||||
}
|
||||
|
||||
return new ClusterQualityMetrics
|
||||
{
|
||||
IsValid = true,
|
||||
InternalSimilarity = similarities.Average(),
|
||||
MinSimilarity = similarities.Min(),
|
||||
MaxSimilarity = similarities.Max(),
|
||||
FaceCount = embeddings.Length,
|
||||
SimilarityStandardDeviation = CalculateStandardDeviation(similarities)
|
||||
};
|
||||
}
|
||||
|
||||
private double CalculateStandardDeviation(List<double> values)
|
||||
{
|
||||
if (!values.Any()) return 0;
|
||||
|
||||
double mean = values.Average();
|
||||
double sumSquaredDifferences = values.Sum(v => Math.Pow(v - mean, 2));
|
||||
return Math.Sqrt(sumSquaredDifferences / values.Count);
|
||||
}
|
||||
|
||||
// 新增:动态阈值调整
|
||||
public async Task<double> CalculateOptimalThresholdAsync(int userId)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var userClusters = await dbContext.FaceClusters
|
||||
.Where(c => dbContext.Faces.Any(f => f.ClusterId == c.Id && f.Picture.UserId == userId))
|
||||
.Include(c => c.Faces.Where(f => f.Picture.UserId == userId && f.Embedding != null))
|
||||
.ToListAsync();
|
||||
|
||||
var intraClusterSimilarities = new List<double>();
|
||||
var interClusterSimilarities = new List<double>();
|
||||
|
||||
// 计算聚类内相似度
|
||||
foreach (var cluster in userClusters.Where(c => c.Faces.Count > 1))
|
||||
{
|
||||
var faces = cluster.Faces.Where(f => f.Embedding != null).ToArray();
|
||||
for (int i = 0; i < faces.Length; i++)
|
||||
{
|
||||
for (int j = i + 1; j < faces.Length; j++)
|
||||
{
|
||||
intraClusterSimilarities.Add(CalculateSimilarity(faces[i].Embedding!, faces[j].Embedding!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算聚类间相似度
|
||||
for (int i = 0; i < userClusters.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < userClusters.Count; j++)
|
||||
{
|
||||
var cluster1Faces = userClusters[i].Faces.Where(f => f.Embedding != null).Take(5).ToArray();
|
||||
var cluster2Faces = userClusters[j].Faces.Where(f => f.Embedding != null).Take(5).ToArray();
|
||||
|
||||
foreach (var face1 in cluster1Faces)
|
||||
{
|
||||
foreach (var face2 in cluster2Faces)
|
||||
{
|
||||
interClusterSimilarities.Add(CalculateSimilarity(face1.Embedding!, face2.Embedding!));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestCluster;
|
||||
if (!intraClusterSimilarities.Any() || !interClusterSimilarities.Any())
|
||||
{
|
||||
return BASE_SIMILARITY_THRESHOLD;
|
||||
}
|
||||
|
||||
// 找到最优分割点
|
||||
double minIntra = intraClusterSimilarities.Min();
|
||||
double maxInter = interClusterSimilarities.Max();
|
||||
|
||||
// 理想阈值应该在聚类间最大相似度和聚类内最小相似度之间
|
||||
double optimalThreshold = (minIntra + maxInter) / 2.0;
|
||||
|
||||
// 确保在合理范围内
|
||||
return Math.Max(0.4, Math.Min(0.9, optimalThreshold));
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:聚类质量评估结果类
|
||||
public class ClusterQualityMetrics
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public double InternalSimilarity { get; set; }
|
||||
public double MinSimilarity { get; set; }
|
||||
public double MaxSimilarity { get; set; }
|
||||
public int FaceCount { get; set; }
|
||||
public double SimilarityStandardDeviation { get; set; }
|
||||
}
|
||||
66
Web/src/api/faceExploreApi.ts
Normal file
66
Web/src/api/faceExploreApi.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { fetchApi, type BaseResult } from './fetchClient';
|
||||
import type { FaceClusterResponse, UpdateClusterRequest } from './faceManagementApi';
|
||||
|
||||
// 获取当前用户的人脸聚类列表
|
||||
export async function getMyFaceClusters(
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('page', page.toString());
|
||||
queryParams.append('pageSize', pageSize.toString());
|
||||
const url = `/face/clusters?${queryParams.toString()}`;
|
||||
const result = await fetchApi(url);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 根据聚类获取当前用户的图片
|
||||
export async function getMyPicturesByCluster(
|
||||
clusterId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('page', page.toString());
|
||||
queryParams.append('pageSize', pageSize.toString());
|
||||
const url = `/face/clusters/${clusterId}/pictures?${queryParams.toString()}`;
|
||||
const result = await fetchApi(url);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 更新当前用户的人脸聚类信息
|
||||
export async function updateMyCluster(
|
||||
clusterId: number,
|
||||
data: UpdateClusterRequest
|
||||
): Promise<BaseResult<FaceClusterResponse>> {
|
||||
return fetchApi<FaceClusterResponse>(`/face/clusters/${clusterId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// 开始当前用户的人脸聚类
|
||||
export async function startMyFaceClustering(): Promise<BaseResult<boolean>> {
|
||||
return fetchApi<boolean>('/face/clusters/analyze', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// 合并当前用户的聚类
|
||||
export async function mergeMyUserClusters(
|
||||
targetClusterId: number,
|
||||
sourceClusterId: number
|
||||
): Promise<BaseResult<boolean>> {
|
||||
const data = { sourceClusterId };
|
||||
return fetchApi<boolean>(`/face/clusters/${targetClusterId}/merge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// 从聚类中移除人脸
|
||||
export async function removeFaceFromCluster(faceId: number): Promise<BaseResult<boolean>> {
|
||||
return fetchApi<boolean>(`/face/faces/${faceId}/cluster`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -11,4 +11,14 @@ export * from './pictureManagementApi';
|
||||
export * from './tagApi';
|
||||
export * from './userManagementApi';
|
||||
export * from './vectorDbApi';
|
||||
export * from './storageManagementApi';
|
||||
export * from './storageManagementApi';
|
||||
|
||||
// 重新导出用户端人脸探索 API,避免与管理端冲突
|
||||
export {
|
||||
getMyFaceClusters,
|
||||
getMyPicturesByCluster,
|
||||
updateMyCluster,
|
||||
startMyFaceClustering,
|
||||
mergeMyUserClusters,
|
||||
removeFaceFromCluster as removeMyFaceFromCluster
|
||||
} from './faceExploreApi';
|
||||
488
Web/src/pages/explore/Index.tsx
Normal file
488
Web/src/pages/explore/Index.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Card, Button, Space, Modal, message, Typography,
|
||||
Row, Col, Image, Form, Input, Avatar,
|
||||
Tag, Tooltip, Spin, Empty, Statistic,
|
||||
Select, Grid
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, ReloadOutlined, PlayCircleOutlined,
|
||||
EditOutlined, TeamOutlined, MergeCellsOutlined,
|
||||
ExclamationCircleOutlined, EyeOutlined,
|
||||
HeartOutlined, SearchOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getMyFaceClusters, updateMyCluster, startMyFaceClustering, mergeMyUserClusters,
|
||||
getMyPicturesByCluster,
|
||||
type FaceClusterResponse, type UpdateClusterRequest
|
||||
} from '../../api';
|
||||
import type { PictureResponse } from '../../api/pictureApi';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { confirm } = Modal;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const FaceExplore: React.FC = () => {
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [clusters, setClusters] = useState<FaceClusterResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [clusteringLoading, setClusteringLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(12);
|
||||
|
||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||
const [isMergeModalVisible, setIsMergeModalVisible] = useState(false);
|
||||
const [isPictureModalVisible, setIsPictureModalVisible] = useState(false);
|
||||
const [editingCluster, setEditingCluster] = useState<FaceClusterResponse | null>(null);
|
||||
const [targetCluster, setTargetCluster] = useState<FaceClusterResponse | null>(null);
|
||||
const [clusterPictures, setClusterPictures] = useState<PictureResponse[]>([]);
|
||||
const [picturesLoading, setPicturesLoading] = useState(false);
|
||||
|
||||
const [editForm] = Form.useForm<UpdateClusterRequest>();
|
||||
const [mergeForm] = Form.useForm();
|
||||
|
||||
const fetchClusters = useCallback(async (page = currentPage) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getMyFaceClusters(page, pageSize);
|
||||
if (response.success) {
|
||||
const actualData = response.data?.data || response.data;
|
||||
setClusters(Array.isArray(actualData) ? actualData : []);
|
||||
setTotal(response.data?.totalCount || response.totalCount || 0);
|
||||
} else {
|
||||
message.error(response.message || '获取人脸聚类失败');
|
||||
setClusters([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取人脸聚类失败,请检查网络连接');
|
||||
setClusters([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
const fetchClusterPictures = useCallback(async (clusterId: number) => {
|
||||
setPicturesLoading(true);
|
||||
try {
|
||||
const response = await getMyPicturesByCluster(clusterId, 1, 50);
|
||||
if (response.success) {
|
||||
const actualData = response.data?.data || response.data;
|
||||
setClusterPictures(Array.isArray(actualData) ? actualData : []);
|
||||
} else {
|
||||
message.error(response.message || '获取聚类图片失败');
|
||||
setClusterPictures([]);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取聚类图片失败');
|
||||
setClusterPictures([]);
|
||||
} finally {
|
||||
setPicturesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClusters();
|
||||
}, [fetchClusters]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const nextPage = currentPage + 1;
|
||||
setCurrentPage(nextPage);
|
||||
fetchClusters(nextPage);
|
||||
};
|
||||
|
||||
const showEditModal = (cluster: FaceClusterResponse) => {
|
||||
setEditingCluster(cluster);
|
||||
editForm.setFieldsValue({
|
||||
personName: cluster.personName,
|
||||
description: cluster.description,
|
||||
});
|
||||
setIsEditModalVisible(true);
|
||||
};
|
||||
|
||||
const showMergeModal = (cluster: FaceClusterResponse) => {
|
||||
setTargetCluster(cluster);
|
||||
mergeForm.resetFields();
|
||||
setIsMergeModalVisible(true);
|
||||
};
|
||||
|
||||
const showPicturesModal = (cluster: FaceClusterResponse) => {
|
||||
setEditingCluster(cluster);
|
||||
setIsPictureModalVisible(true);
|
||||
fetchClusterPictures(cluster.id);
|
||||
};
|
||||
|
||||
const handleEditOk = async () => {
|
||||
if (!editingCluster) return;
|
||||
|
||||
try {
|
||||
const values = await editForm.validateFields();
|
||||
setLoading(true);
|
||||
const response = await updateMyCluster(editingCluster.id, values);
|
||||
|
||||
if (response.success) {
|
||||
message.success('更新聚类信息成功');
|
||||
setIsEditModalVisible(false);
|
||||
fetchClusters();
|
||||
} else {
|
||||
message.error(response.message || '更新失败');
|
||||
}
|
||||
} catch (errorInfo) {
|
||||
console.log('Validate Failed:', errorInfo);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergeOk = async () => {
|
||||
if (!targetCluster) return;
|
||||
|
||||
try {
|
||||
const values = await mergeForm.validateFields();
|
||||
setLoading(true);
|
||||
const response = await mergeMyUserClusters(targetCluster.id, values.sourceClusterId);
|
||||
|
||||
if (response.success) {
|
||||
message.success('合并聚类成功');
|
||||
setIsMergeModalVisible(false);
|
||||
fetchClusters();
|
||||
} else {
|
||||
message.error(response.message || '合并失败');
|
||||
}
|
||||
} catch (errorInfo) {
|
||||
console.log('Validate Failed:', errorInfo);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartClustering = () => {
|
||||
confirm({
|
||||
title: '开始人脸聚类分析',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '这将分析您上传图片中的未分类人脸,帮助您找到相同的人物。可能需要一些时间,确定要开始吗?',
|
||||
async onOk() {
|
||||
setClusteringLoading(true);
|
||||
try {
|
||||
const response = await startMyFaceClustering();
|
||||
if (response.success) {
|
||||
message.success('人脸聚类任务已开始,请稍后刷新查看结果');
|
||||
} else {
|
||||
message.error(response.message || '启动聚类失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('启动聚类失败');
|
||||
} finally {
|
||||
setClusteringLoading(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderClusterCard = (cluster: FaceClusterResponse) => (
|
||||
<Card
|
||||
key={cluster.id}
|
||||
hoverable
|
||||
className="cluster-card"
|
||||
cover={
|
||||
<div style={{
|
||||
height: 200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
{cluster.thumbnailPath ? (
|
||||
<Image
|
||||
src={cluster.thumbnailPath}
|
||||
alt={cluster.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
preview={false}
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
size={80}
|
||||
icon={<UserOutlined />}
|
||||
style={{ backgroundColor: '#e6f7ff' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
<Tooltip title="查看图片" key="view">
|
||||
<EyeOutlined onClick={() => showPicturesModal(cluster)} />
|
||||
</Tooltip>,
|
||||
<Tooltip title="编辑信息" key="edit">
|
||||
<EditOutlined onClick={() => showEditModal(cluster)} />
|
||||
</Tooltip>,
|
||||
<Tooltip title="合并聚类" key="merge">
|
||||
<MergeCellsOutlined onClick={() => showMergeModal(cluster)} />
|
||||
</Tooltip>
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div>
|
||||
<Text strong>{cluster.name}</Text>
|
||||
<Tag color="blue" style={{ marginLeft: 8 }}>
|
||||
{cluster.faceCount} 张人脸
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
{cluster.personName && (
|
||||
<Paragraph style={{ margin: 0, color: '#52c41a' }}>
|
||||
<HeartOutlined /> {cluster.personName}
|
||||
</Paragraph>
|
||||
)}
|
||||
{cluster.description && (
|
||||
<Paragraph
|
||||
style={{ margin: '8px 0 0 0' }}
|
||||
ellipsis={{ rows: 2, tooltip: cluster.description }}
|
||||
>
|
||||
{cluster.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
最后更新: {new Date(cluster.lastUpdatedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="face-explore" style={{ padding: isMobile ? 16 : 24 }}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Row align="middle" justify="space-between" gutter={[16, 16]}>
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<SearchOutlined style={{ fontSize: 28, color: '#1890ff' }} />
|
||||
<div>
|
||||
<Title level={2} style={{ margin: 0 }}>人脸探索</Title>
|
||||
<Text type="secondary">
|
||||
发现和管理您照片中的人物,为每个聚类命名和整理
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={handleStartClustering}
|
||||
loading={clusteringLoading}
|
||||
>
|
||||
开始聚类分析
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchClusters()}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} sm={8} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="人脸聚类"
|
||||
value={total}
|
||||
prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已命名聚类"
|
||||
value={clusters.filter(c => c.personName).length}
|
||||
prefix={<HeartOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总人脸数"
|
||||
value={clusters.reduce((sum, c) => sum + c.faceCount, 0)}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 聚类网格 */}
|
||||
<Spin spinning={loading}>
|
||||
{clusters.length > 0 ? (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
{clusters.map(cluster => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} xl={4} key={cluster.id}>
|
||||
{renderClusterCard(cluster)}
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 加载更多按钮 */}
|
||||
{clusters.length < total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Button size="large" onClick={handleLoadMore} loading={loading}>
|
||||
加载更多 ({clusters.length} / {total})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Empty
|
||||
description={
|
||||
<div>
|
||||
<Paragraph>还没有发现人脸聚类</Paragraph>
|
||||
<Paragraph type="secondary">
|
||||
点击"开始聚类分析"来分析您的照片并发现其中的人物
|
||||
</Paragraph>
|
||||
</div>
|
||||
}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={handleStartClustering}
|
||||
loading={clusteringLoading}
|
||||
>
|
||||
开始聚类分析
|
||||
</Button>
|
||||
</Empty>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
{/* 编辑聚类模态框 */}
|
||||
<Modal
|
||||
title="编辑聚类信息"
|
||||
open={isEditModalVisible}
|
||||
onOk={handleEditOk}
|
||||
onCancel={() => setIsEditModalVisible(false)}
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="personName" label="人物姓名">
|
||||
<Input placeholder="请输入人物姓名,如:张三、妈妈、小明等" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} placeholder="请输入描述信息,如:大学同学、家人等" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 合并聚类模态框 */}
|
||||
<Modal
|
||||
title={`合并聚类到: ${targetCluster?.personName || targetCluster?.name || ''}`}
|
||||
open={isMergeModalVisible}
|
||||
onOk={handleMergeOk}
|
||||
onCancel={() => setIsMergeModalVisible(false)}
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={mergeForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="sourceClusterId"
|
||||
label="选择要合并的聚类"
|
||||
rules={[{ required: true, message: '请选择要合并的聚类' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择要合并的聚类"
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
filterOption={(input: string, option: any) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={clusters
|
||||
.filter(c => c.id !== targetCluster?.id)
|
||||
.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.personName || c.name} (${c.faceCount} 个人脸)`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text type="secondary">
|
||||
合并后,选择的聚类将被删除,其所有人脸将移动到目标聚类中。
|
||||
</Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 查看聚类图片模态框 */}
|
||||
<Modal
|
||||
title={`${editingCluster?.personName || editingCluster?.name || ''} 的照片`}
|
||||
open={isPictureModalVisible}
|
||||
onCancel={() => setIsPictureModalVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
destroyOnClose
|
||||
>
|
||||
<Spin spinning={picturesLoading}>
|
||||
{clusterPictures.length > 0 ? (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 16,
|
||||
maxHeight: 500,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{clusterPictures.map(picture => (
|
||||
<div key={picture.id} style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
width={100}
|
||||
height={100}
|
||||
src={picture.thumbnailPath || picture.path}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f0f0f0'
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{picture.name || `图片${picture.id}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无图片" />
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaceExplore;
|
||||
@@ -28,6 +28,7 @@ import AdminLogManagement from '../pages/admin/log/Index';
|
||||
import StorageManagementPage from '../pages/admin/storage/StorageManagement';
|
||||
import AlbumManagement from '../pages/admin/album/Index';
|
||||
import FaceManagement from '../pages/admin/face/Index';
|
||||
import FaceExplore from '../pages/explore/Index';
|
||||
|
||||
export interface RouteConfig {
|
||||
path: string;
|
||||
@@ -59,6 +60,17 @@ const routes: RouteConfig[] = [
|
||||
title: '所有图片'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'explore',
|
||||
key: 'explore',
|
||||
icon: <CompassOutlined />,
|
||||
label: '探索',
|
||||
element: <FaceExplore />,
|
||||
area: 'main',
|
||||
breadcrumb: {
|
||||
title: '探索',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'albums',
|
||||
key: 'albums',
|
||||
@@ -189,7 +201,7 @@ const routes: RouteConfig[] = [
|
||||
title: '相册管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
path: 'faces-admin',
|
||||
key: 'admin-face',
|
||||
icon: <FolderOutlined />,
|
||||
|
||||
Reference in New Issue
Block a user