mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-25 07:46:56 +08:00
feat: Implement face clustering management service and API
This commit is contained in:
236
Services/AI/FaceClusteringService.cs
Normal file
236
Services/AI/FaceClusteringService.cs
Normal file
@@ -0,0 +1,236 @@
|
||||
using Foxel.Models.DataBase;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Foxel.Services.AI;
|
||||
|
||||
public class FaceClusteringService(
|
||||
IDbContextFactory<MyDbContext> contextFactory,
|
||||
ILogger<FaceClusteringService> logger) : IFaceClusteringService
|
||||
{
|
||||
private const double SIMILARITY_THRESHOLD = 0.5;
|
||||
|
||||
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)
|
||||
.ToListAsync();
|
||||
|
||||
var clusters = new List<FaceCluster>();
|
||||
|
||||
foreach (var face in unclusteredFaces)
|
||||
{
|
||||
var assignedCluster = await FindBestClusterAsync(face, clusters, dbContext);
|
||||
|
||||
if (assignedCluster != null)
|
||||
{
|
||||
// 分配到现有聚类
|
||||
face.ClusterId = assignedCluster.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 创建新聚类
|
||||
var newCluster = new FaceCluster
|
||||
{
|
||||
Name = $"未知人物 {clusters.Count + 1}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.FaceClusters.Add(newCluster);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
face.ClusterId = newCluster.Id;
|
||||
clusters.Add(newCluster);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
logger.LogInformation("人脸聚类完成,共处理 {FaceCount} 个人脸,生成 {ClusterCount} 个聚类",
|
||||
unclusteredFaces.Count, clusters.Count);
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
public async Task<FaceCluster?> AssignFaceToClusterAsync(int faceId)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var face = await dbContext.Faces
|
||||
.Include(f => f.Picture)
|
||||
.FirstOrDefaultAsync(f => f.Id == faceId);
|
||||
|
||||
if (face?.Embedding == null) return null;
|
||||
|
||||
// 获取所有现有聚类的代表人脸
|
||||
var existingClusters = await dbContext.FaceClusters
|
||||
.Include(c => c.Faces.Take(1))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cluster in existingClusters)
|
||||
{
|
||||
if (cluster.Faces?.Any() == true)
|
||||
{
|
||||
var representativeFace = cluster.Faces.First();
|
||||
if (representativeFace.Embedding != null)
|
||||
{
|
||||
var similarity = CalculateSimilarity(face.Embedding, representativeFace.Embedding);
|
||||
if (similarity >= SIMILARITY_THRESHOLD)
|
||||
{
|
||||
face.ClusterId = cluster.Id;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新聚类
|
||||
var newCluster = new FaceCluster
|
||||
{
|
||||
Name = $"未知人物 {DateTime.Now:yyyyMMddHHmmss}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.FaceClusters.Add(newCluster);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
face.ClusterId = newCluster.Id;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return newCluster;
|
||||
}
|
||||
|
||||
public double CalculateSimilarity(float[] embedding1, float[] embedding2)
|
||||
{
|
||||
if (embedding1.Length != embedding2.Length) return 0;
|
||||
|
||||
// 计算余弦相似度
|
||||
double dot = 0, norm1 = 0, norm2 = 0;
|
||||
|
||||
for (int i = 0; i < embedding1.Length; i++)
|
||||
{
|
||||
dot += embedding1[i] * embedding2[i];
|
||||
norm1 += embedding1[i] * embedding1[i];
|
||||
norm2 += embedding2[i] * embedding2[i];
|
||||
}
|
||||
|
||||
if (norm1 == 0 || norm2 == 0) return 0;
|
||||
|
||||
return dot / (Math.Sqrt(norm1) * Math.Sqrt(norm2));
|
||||
}
|
||||
|
||||
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 existingClusters = await dbContext.FaceClusters
|
||||
.Include(c => c.Faces.Take(5)) // 取前5个人脸作为比较
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cluster in existingClusters.Concat(newClusters))
|
||||
{
|
||||
if (cluster.Faces?.Any() == true)
|
||||
{
|
||||
foreach (var clusterFace in cluster.Faces)
|
||||
{
|
||||
if (clusterFace.Embedding != null)
|
||||
{
|
||||
var similarity = CalculateSimilarity(face.Embedding, clusterFace.Embedding);
|
||||
if (similarity > bestSimilarity && similarity >= SIMILARITY_THRESHOLD)
|
||||
{
|
||||
bestSimilarity = similarity;
|
||||
bestCluster = cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestCluster;
|
||||
}
|
||||
|
||||
public async Task<List<FaceCluster>> ClusterUserFacesAsync(int userId)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 获取指定用户所有有嵌入向量但未分类的人脸
|
||||
var unclusteredFaces = await dbContext.Faces
|
||||
.Where(f => f.Embedding != null && f.ClusterId == null && f.Picture.UserId == userId)
|
||||
.Include(f => f.Picture)
|
||||
.ToListAsync();
|
||||
|
||||
var clusters = new List<FaceCluster>();
|
||||
|
||||
foreach (var face in unclusteredFaces)
|
||||
{
|
||||
var assignedCluster = await FindBestUserClusterAsync(face, userId, clusters, dbContext);
|
||||
|
||||
if (assignedCluster != null)
|
||||
{
|
||||
face.ClusterId = assignedCluster.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
var newCluster = new FaceCluster
|
||||
{
|
||||
Name = $"未知人物 {DateTime.Now:yyyyMMddHHmmss}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.FaceClusters.Add(newCluster);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
face.ClusterId = newCluster.Id;
|
||||
clusters.Add(newCluster);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
logger.LogInformation("用户 {UserId} 人脸聚类完成,共处理 {FaceCount} 个人脸,生成 {ClusterCount} 个聚类",
|
||||
userId, unclusteredFaces.Count, clusters.Count);
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
private async Task<FaceCluster?> FindBestUserClusterAsync(Face face, int userId, List<FaceCluster> newClusters, MyDbContext dbContext)
|
||||
{
|
||||
if (face.Embedding == null) return null;
|
||||
|
||||
double bestSimilarity = 0;
|
||||
FaceCluster? bestCluster = null;
|
||||
|
||||
// 检查该用户现有的聚类
|
||||
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))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cluster in existingClusters.Concat(newClusters))
|
||||
{
|
||||
if (cluster.Faces?.Any() == true)
|
||||
{
|
||||
foreach (var clusterFace in cluster.Faces)
|
||||
{
|
||||
if (clusterFace.Embedding != null)
|
||||
{
|
||||
var similarity = CalculateSimilarity(face.Embedding, clusterFace.Embedding);
|
||||
if (similarity > bestSimilarity && similarity >= SIMILARITY_THRESHOLD)
|
||||
{
|
||||
bestSimilarity = similarity;
|
||||
bestCluster = cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestCluster;
|
||||
}
|
||||
}
|
||||
24
Services/AI/IFaceClusteringService.cs
Normal file
24
Services/AI/IFaceClusteringService.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Foxel.Models.DataBase;
|
||||
|
||||
public interface IFaceClusteringService
|
||||
{
|
||||
/// <summary>
|
||||
/// 对所有未分类的人脸进行聚类
|
||||
/// </summary>
|
||||
Task<List<FaceCluster>> ClusterFacesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 对指定用户的未分类人脸进行聚类
|
||||
/// </summary>
|
||||
Task<List<FaceCluster>> ClusterUserFacesAsync(int userId);
|
||||
|
||||
/// <summary>
|
||||
/// 为新检测到的人脸分配到现有聚类或创建新聚类
|
||||
/// </summary>
|
||||
Task<FaceCluster?> AssignFaceToClusterAsync(int faceId);
|
||||
|
||||
/// <summary>
|
||||
/// 计算两个人脸嵌入向量的相似度
|
||||
/// </summary>
|
||||
double CalculateSimilarity(float[] embedding1, float[] embedding2);
|
||||
}
|
||||
448
Services/Management/FaceManagementService.cs
Normal file
448
Services/Management/FaceManagementService.cs
Normal file
@@ -0,0 +1,448 @@
|
||||
using Foxel.Models;
|
||||
using Foxel.Models.Response.Face;
|
||||
using Foxel.Models.Response.Picture;
|
||||
using Foxel.Services.Mapping;
|
||||
using Foxel.Api.Management;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Foxel.Services.Management;
|
||||
|
||||
public class FaceManagementService(
|
||||
IDbContextFactory<MyDbContext> contextFactory,
|
||||
IMappingService mappingService,
|
||||
ILogger<FaceManagementService> logger) : IFaceManagementService
|
||||
{
|
||||
public async Task<PaginatedResult<FaceClusterResponse>> GetFaceClustersAsync(int page = 1, int pageSize = 20)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 20;
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
var clusterQuery = dbContext.FaceClusters
|
||||
.Select(c => new
|
||||
{
|
||||
Cluster = c,
|
||||
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id),
|
||||
ThumbnailPath = dbContext.Faces
|
||||
.Where(f => f.ClusterId == c.Id)
|
||||
.Include(f => f.Picture)
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(f => f.Picture.ThumbnailPath)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.OrderByDescending(x => x.FaceCount)
|
||||
.ThenByDescending(x => x.Cluster.LastUpdatedAt);
|
||||
|
||||
var totalCount = await clusterQuery.CountAsync();
|
||||
var clusterData = await clusterQuery
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var clusterResponses = clusterData.Select(data => new FaceClusterResponse
|
||||
{
|
||||
Id = data.Cluster.Id,
|
||||
Name = data.Cluster.Name,
|
||||
PersonName = data.Cluster.PersonName,
|
||||
Description = data.Cluster.Description,
|
||||
FaceCount = data.FaceCount,
|
||||
LastUpdatedAt = data.Cluster.LastUpdatedAt,
|
||||
ThumbnailPath = data.ThumbnailPath,
|
||||
CreatedAt = data.Cluster.CreatedAt
|
||||
}).ToList();
|
||||
|
||||
return new PaginatedResult<FaceClusterResponse>
|
||||
{
|
||||
Data = clusterResponses,
|
||||
TotalCount = totalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PaginatedResult<PictureResponse>> GetPicturesByClusterAsync(int clusterId, int page = 1, int pageSize = 20)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 20;
|
||||
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 获取该聚类下所有图片ID
|
||||
var pictureIds = await dbContext.Faces
|
||||
.Where(f => f.ClusterId == clusterId)
|
||||
.Select(f => f.PictureId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
var query = dbContext.Pictures
|
||||
.Where(p => pictureIds.Contains(p.Id))
|
||||
.Include(p => p.User)
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.Faces)
|
||||
.OrderByDescending(p => p.CreatedAt);
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var pictures = await query
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var pictureResponses = pictures
|
||||
.Select(p => mappingService.MapPictureToResponse(p))
|
||||
.ToList();
|
||||
|
||||
return new PaginatedResult<PictureResponse>
|
||||
{
|
||||
Data = pictureResponses,
|
||||
TotalCount = totalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<FaceClusterResponse> UpdateClusterAsync(int clusterId, string? personName, string? description = null)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var cluster = await dbContext.FaceClusters
|
||||
.Include(c => c.Faces)
|
||||
.FirstOrDefaultAsync(c => c.Id == clusterId);
|
||||
|
||||
if (cluster == null)
|
||||
throw new KeyNotFoundException($"找不到ID为 {clusterId} 的人脸聚类");
|
||||
|
||||
cluster.PersonName = personName;
|
||||
if (description != null)
|
||||
cluster.Description = description;
|
||||
cluster.LastUpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// 如果设置了人物姓名,更新聚类名称
|
||||
if (!string.IsNullOrWhiteSpace(personName))
|
||||
{
|
||||
cluster.Name = personName;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return new FaceClusterResponse
|
||||
{
|
||||
Id = cluster.Id,
|
||||
Name = cluster.Name,
|
||||
PersonName = cluster.PersonName,
|
||||
Description = cluster.Description,
|
||||
FaceCount = cluster.Faces?.Count ?? 0,
|
||||
LastUpdatedAt = cluster.LastUpdatedAt,
|
||||
CreatedAt = cluster.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> MergeClustersAsync(int sourceClusterId, int targetClusterId)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var sourceFaces = await dbContext.Faces
|
||||
.Where(f => f.ClusterId == sourceClusterId)
|
||||
.ToListAsync();
|
||||
|
||||
var targetCluster = await dbContext.FaceClusters
|
||||
.FirstOrDefaultAsync(c => c.Id == targetClusterId);
|
||||
|
||||
if (targetCluster == null)
|
||||
throw new KeyNotFoundException($"找不到目标聚类 {targetClusterId}");
|
||||
|
||||
// 将源聚类的所有人脸移动到目标聚类
|
||||
foreach (var face in sourceFaces)
|
||||
{
|
||||
face.ClusterId = targetClusterId;
|
||||
}
|
||||
|
||||
// 删除源聚类
|
||||
var sourceCluster = await dbContext.FaceClusters.FindAsync(sourceClusterId);
|
||||
if (sourceCluster != null)
|
||||
{
|
||||
dbContext.FaceClusters.Remove(sourceCluster);
|
||||
}
|
||||
targetCluster.LastUpdatedAt = DateTime.UtcNow;
|
||||
await dbContext.SaveChangesAsync();
|
||||
logger.LogInformation("成功合并聚类 {SourceId} 到 {TargetId},移动了 {FaceCount} 个人脸",
|
||||
sourceClusterId, targetClusterId, sourceFaces.Count);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveFaceFromClusterAsync(int faceId)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var face = await dbContext.Faces.FindAsync(faceId);
|
||||
if (face == null)
|
||||
return false;
|
||||
|
||||
face.ClusterId = null;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<PaginatedResult<FaceClusterResponse>> GetUserFaceClustersAsync(int userId, int page = 1, int pageSize = 20)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 20;
|
||||
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var clusterQuery = dbContext.FaceClusters
|
||||
.Where(c => dbContext.Faces.Any(f => f.ClusterId == c.Id && f.Picture.UserId == userId))
|
||||
.Select(c => new
|
||||
{
|
||||
Cluster = c,
|
||||
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id && f.Picture.UserId == userId),
|
||||
ThumbnailPath = dbContext.Faces
|
||||
.Where(f => f.ClusterId == c.Id && f.Picture.UserId == userId)
|
||||
.Include(f => f.Picture)
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(f => f.Picture.ThumbnailPath)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Where(x => x.FaceCount > 0)
|
||||
.OrderByDescending(x => x.FaceCount)
|
||||
.ThenByDescending(x => x.Cluster.LastUpdatedAt);
|
||||
|
||||
var totalCount = await clusterQuery.CountAsync();
|
||||
var clusterData = await clusterQuery
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var clusterResponses = clusterData.Select(data => new FaceClusterResponse
|
||||
{
|
||||
Id = data.Cluster.Id,
|
||||
Name = data.Cluster.Name,
|
||||
PersonName = data.Cluster.PersonName,
|
||||
Description = data.Cluster.Description,
|
||||
FaceCount = data.FaceCount,
|
||||
LastUpdatedAt = data.Cluster.LastUpdatedAt,
|
||||
ThumbnailPath = data.ThumbnailPath,
|
||||
CreatedAt = data.Cluster.CreatedAt
|
||||
}).ToList();
|
||||
|
||||
return new PaginatedResult<FaceClusterResponse>
|
||||
{
|
||||
Data = clusterResponses,
|
||||
TotalCount = totalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PaginatedResult<PictureResponse>> GetUserPicturesByClusterAsync(int userId, int clusterId, int page = 1, int pageSize = 20)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 20;
|
||||
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 验证聚类是否包含该用户的人脸
|
||||
var hasUserFaces = await dbContext.Faces
|
||||
.AnyAsync(f => f.ClusterId == clusterId && f.Picture.UserId == userId);
|
||||
|
||||
if (!hasUserFaces)
|
||||
throw new KeyNotFoundException($"找不到用户 {userId} 的聚类 {clusterId}");
|
||||
|
||||
// 获取该聚类下该用户的所有图片ID
|
||||
var pictureIds = await dbContext.Faces
|
||||
.Where(f => f.ClusterId == clusterId && f.Picture.UserId == userId)
|
||||
.Select(f => f.PictureId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
var query = dbContext.Pictures
|
||||
.Where(p => pictureIds.Contains(p.Id) && p.UserId == userId)
|
||||
.Include(p => p.User)
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.Faces)
|
||||
.OrderByDescending(p => p.CreatedAt);
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var pictures = await query
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var pictureResponses = pictures
|
||||
.Select(p => mappingService.MapPictureToResponse(p))
|
||||
.ToList();
|
||||
|
||||
return new PaginatedResult<PictureResponse>
|
||||
{
|
||||
Data = pictureResponses,
|
||||
TotalCount = totalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<FaceClusterResponse> UpdateUserClusterAsync(int userId, int clusterId, string? personName, string? description = null)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 验证聚类是否包含该用户的人脸
|
||||
var hasUserFaces = await dbContext.Faces
|
||||
.AnyAsync(f => f.ClusterId == clusterId && f.Picture.UserId == userId);
|
||||
|
||||
if (!hasUserFaces)
|
||||
throw new KeyNotFoundException($"找不到用户 {userId} 的聚类 {clusterId}");
|
||||
|
||||
var cluster = await dbContext.FaceClusters
|
||||
.Include(c => c.Faces.Where(f => f.Picture.UserId == userId))
|
||||
.FirstOrDefaultAsync(c => c.Id == clusterId);
|
||||
|
||||
if (cluster == null)
|
||||
throw new KeyNotFoundException($"找不到ID为 {clusterId} 的人脸聚类");
|
||||
|
||||
cluster.PersonName = personName;
|
||||
if (description != null)
|
||||
cluster.Description = description;
|
||||
cluster.LastUpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(personName))
|
||||
{
|
||||
cluster.Name = personName;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return new FaceClusterResponse
|
||||
{
|
||||
Id = cluster.Id,
|
||||
Name = cluster.Name,
|
||||
PersonName = cluster.PersonName,
|
||||
Description = cluster.Description,
|
||||
FaceCount = cluster.Faces?.Count ?? 0,
|
||||
LastUpdatedAt = cluster.LastUpdatedAt,
|
||||
CreatedAt = cluster.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> MergeUserClustersAsync(int userId, int sourceClusterId, int targetClusterId)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 验证两个聚类都包含该用户的人脸
|
||||
var sourceHasUserFaces = await dbContext.Faces
|
||||
.AnyAsync(f => f.ClusterId == sourceClusterId && f.Picture.UserId == userId);
|
||||
var targetHasUserFaces = await dbContext.Faces
|
||||
.AnyAsync(f => f.ClusterId == targetClusterId && f.Picture.UserId == userId);
|
||||
|
||||
if (!sourceHasUserFaces || !targetHasUserFaces)
|
||||
throw new KeyNotFoundException("找不到指定的聚类或无权访问");
|
||||
|
||||
// 只移动该用户的人脸
|
||||
var sourceFaces = await dbContext.Faces
|
||||
.Where(f => f.ClusterId == sourceClusterId && f.Picture.UserId == userId)
|
||||
.ToListAsync();
|
||||
|
||||
var targetCluster = await dbContext.FaceClusters
|
||||
.FirstOrDefaultAsync(c => c.Id == targetClusterId);
|
||||
|
||||
if (targetCluster == null)
|
||||
throw new KeyNotFoundException($"找不到目标聚类 {targetClusterId}");
|
||||
|
||||
foreach (var face in sourceFaces)
|
||||
{
|
||||
face.ClusterId = targetClusterId;
|
||||
}
|
||||
|
||||
// 检查源聚类是否还有其他用户的人脸,如果没有则删除
|
||||
var remainingFaces = await dbContext.Faces
|
||||
.CountAsync(f => f.ClusterId == sourceClusterId);
|
||||
|
||||
if (remainingFaces == 0)
|
||||
{
|
||||
var sourceCluster = await dbContext.FaceClusters.FindAsync(sourceClusterId);
|
||||
if (sourceCluster != null)
|
||||
{
|
||||
dbContext.FaceClusters.Remove(sourceCluster);
|
||||
}
|
||||
}
|
||||
|
||||
targetCluster.LastUpdatedAt = DateTime.UtcNow;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("用户 {UserId} 成功合并聚类 {SourceId} 到 {TargetId},移动了 {FaceCount} 个人脸",
|
||||
userId, sourceClusterId, targetClusterId, sourceFaces.Count);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveUserFaceFromClusterAsync(int userId, int faceId)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var face = await dbContext.Faces
|
||||
.Include(f => f.Picture)
|
||||
.FirstOrDefaultAsync(f => f.Id == faceId && f.Picture.UserId == userId);
|
||||
|
||||
if (face == null)
|
||||
throw new KeyNotFoundException("找不到指定的人脸或无权访问");
|
||||
|
||||
face.ClusterId = null;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteClusterAsync(int clusterId)
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var cluster = await dbContext.FaceClusters
|
||||
.Include(c => c.Faces)
|
||||
.FirstOrDefaultAsync(c => c.Id == clusterId);
|
||||
|
||||
if (cluster == null)
|
||||
throw new KeyNotFoundException($"找不到ID为 {clusterId} 的人脸聚类");
|
||||
|
||||
// 将所有人脸的聚类ID设为null
|
||||
if (cluster.Faces != null)
|
||||
{
|
||||
foreach (var face in cluster.Faces)
|
||||
{
|
||||
face.ClusterId = null;
|
||||
}
|
||||
}
|
||||
|
||||
dbContext.FaceClusters.Remove(cluster);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("删除聚类 {ClusterId},影响了 {FaceCount} 个人脸",
|
||||
clusterId, cluster.Faces?.Count ?? 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<FaceClusterStatistics> GetClusterStatisticsAsync()
|
||||
{
|
||||
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||
|
||||
var totalClusters = await dbContext.FaceClusters.CountAsync();
|
||||
var totalFaces = await dbContext.Faces.CountAsync();
|
||||
var unclusteredFaces = await dbContext.Faces.CountAsync(f => f.ClusterId == null);
|
||||
var namedClusters = await dbContext.FaceClusters.CountAsync(c => !string.IsNullOrEmpty(c.PersonName));
|
||||
|
||||
var clustersByUserQuery = await dbContext.Faces
|
||||
.Where(f => f.ClusterId != null)
|
||||
.GroupBy(f => f.Picture.UserId)
|
||||
.Select(g => new { UserId = g.Key, ClusterCount = g.Select(f => f.ClusterId).Distinct().Count() })
|
||||
.ToListAsync();
|
||||
|
||||
var clustersByUser = clustersByUserQuery
|
||||
.Where(x => x.UserId.HasValue)
|
||||
.ToDictionary(x => x.UserId.Value, x => x.ClusterCount);
|
||||
|
||||
return new FaceClusterStatistics
|
||||
{
|
||||
TotalClusters = totalClusters,
|
||||
TotalFaces = totalFaces,
|
||||
UnclusteredFaces = unclusteredFaces,
|
||||
NamedClusters = namedClusters,
|
||||
ClustersByUser = clustersByUser
|
||||
};
|
||||
}
|
||||
}
|
||||
69
Services/Management/IFaceManagementService.cs
Normal file
69
Services/Management/IFaceManagementService.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Foxel.Models;
|
||||
using Foxel.Models.Response.Face;
|
||||
using Foxel.Models.Response.Picture;
|
||||
using Foxel.Api.Management;
|
||||
|
||||
namespace Foxel.Services.Management;
|
||||
|
||||
public interface IFaceManagementService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取所有人脸聚类(管理员)
|
||||
/// </summary>
|
||||
Task<PaginatedResult<FaceClusterResponse>> GetFaceClustersAsync(int page = 1, int pageSize = 20);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定用户的人脸聚类
|
||||
/// </summary>
|
||||
Task<PaginatedResult<FaceClusterResponse>> GetUserFaceClustersAsync(int userId, int page = 1, int pageSize = 20);
|
||||
|
||||
/// <summary>
|
||||
/// 根据聚类ID获取相关图片(管理员)
|
||||
/// </summary>
|
||||
Task<PaginatedResult<PictureResponse>> GetPicturesByClusterAsync(int clusterId, int page = 1, int pageSize = 20);
|
||||
|
||||
/// <summary>
|
||||
/// 根据聚类ID获取指定用户的相关图片
|
||||
/// </summary>
|
||||
Task<PaginatedResult<PictureResponse>> GetUserPicturesByClusterAsync(int userId, int clusterId, int page = 1, int pageSize = 20);
|
||||
|
||||
/// <summary>
|
||||
/// 更新聚类信息(管理员)
|
||||
/// </summary>
|
||||
Task<FaceClusterResponse> UpdateClusterAsync(int clusterId, string? personName, string? description = null);
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户聚类信息
|
||||
/// </summary>
|
||||
Task<FaceClusterResponse> UpdateUserClusterAsync(int userId, int clusterId, string? personName, string? description = null);
|
||||
|
||||
/// <summary>
|
||||
/// 合并两个聚类(管理员)
|
||||
/// </summary>
|
||||
Task<bool> MergeClustersAsync(int sourceClusterId, int targetClusterId);
|
||||
|
||||
/// <summary>
|
||||
/// 合并用户的两个聚类
|
||||
/// </summary>
|
||||
Task<bool> MergeUserClustersAsync(int userId, int sourceClusterId, int targetClusterId);
|
||||
|
||||
/// <summary>
|
||||
/// 从聚类中移除人脸(管理员)
|
||||
/// </summary>
|
||||
Task<bool> RemoveFaceFromClusterAsync(int faceId);
|
||||
|
||||
/// <summary>
|
||||
/// 从用户聚类中移除人脸
|
||||
/// </summary>
|
||||
Task<bool> RemoveUserFaceFromClusterAsync(int userId, int faceId);
|
||||
|
||||
/// <summary>
|
||||
/// 删除聚类(管理员)
|
||||
/// </summary>
|
||||
Task<bool> DeleteClusterAsync(int clusterId);
|
||||
|
||||
/// <summary>
|
||||
/// 获取聚类统计信息(管理员)
|
||||
/// </summary>
|
||||
Task<FaceClusterStatistics> GetClusterStatisticsAsync();
|
||||
}
|
||||
@@ -71,7 +71,7 @@ namespace Foxel.Services.Mapping
|
||||
W = face.W,
|
||||
H = face.H,
|
||||
FaceConfidence = face.FaceConfidence,
|
||||
PersonName = face.PersonName
|
||||
PersonName = face.Cluster?.Name
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,16 +157,17 @@ public class PictureService(
|
||||
// 构建基础查询
|
||||
IQueryable<Picture> query = dbContext.Pictures
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.Faces)
|
||||
.Include(p => p.User);
|
||||
.Include(p => p.User)
|
||||
.Include(p => p.Faces!)
|
||||
.ThenInclude(f => f.Cluster);
|
||||
|
||||
// 应用文本搜索条件
|
||||
if (!string.IsNullOrWhiteSpace(searchQuery))
|
||||
{
|
||||
var searchTerm = searchQuery.ToLower();
|
||||
query = query.Where(p =>
|
||||
(p.Name.ToLower().Contains(searchTerm)) ||
|
||||
(p.Description.ToLower().Contains(searchTerm)));
|
||||
p.Name.ToLower().Contains(searchTerm) ||
|
||||
p.Description.ToLower().Contains(searchTerm));
|
||||
}
|
||||
|
||||
// 应用共通的查询条件
|
||||
@@ -617,7 +618,7 @@ public class PictureService(
|
||||
UserIdForPicture = picture.UserId
|
||||
};
|
||||
await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload);
|
||||
|
||||
|
||||
// 添加人脸识别任务
|
||||
var faceRecognitionPayload = new Background.Processors.FaceRecognitionPayload
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user