From 204bc96c5665e41da16a1f6d9e50f5f77d85aee5 Mon Sep 17 00:00:00 2001 From: ShiYu Date: Sat, 14 Jun 2025 00:36:45 +0800 Subject: [PATCH] feat(background): add face recognition functionality- Implement face recognition task type and processing --- Extensions/ServiceCollectionExtensions.cs | 1 + Models/DataBase/BackgroundTask.cs | 1 + Models/DataBase/Face.cs | 24 ++ Models/DataBase/Picture.cs | 2 + MyDbContext.cs | 1 + Services/Background/BackgroundTaskQueue.cs | 54 +++- Services/Background/IBackgroundTaskQueue.cs | 7 + .../FaceRecognitionTaskProcessor.cs | 281 ++++++++++++++++++ Services/Media/PictureService.cs | 8 + Web/src/pages/admin/system/ConfigTabs.tsx | 23 ++ Web/src/pages/admin/system/Index.tsx | 6 + Web/src/pages/backgroundTasks/Index.tsx | 6 +- 12 files changed, 408 insertions(+), 6 deletions(-) create mode 100644 Models/DataBase/Face.cs create mode 100644 Services/Background/Processors/FaceRecognitionTaskProcessor.cs diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs index 1c0e5ec..f0bc28a 100644 --- a/Extensions/ServiceCollectionExtensions.cs +++ b/Extensions/ServiceCollectionExtensions.cs @@ -38,6 +38,7 @@ public static class ServiceCollectionExtensions services.AddHostedService(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Models/DataBase/BackgroundTask.cs b/Models/DataBase/BackgroundTask.cs index b6fc6d1..4b548ca 100644 --- a/Models/DataBase/BackgroundTask.cs +++ b/Models/DataBase/BackgroundTask.cs @@ -8,6 +8,7 @@ namespace Foxel.Models.DataBase { PictureProcessing = 0, VisualRecognition = 1, + FaceRecognition = 2, } public enum TaskExecutionStatus diff --git a/Models/DataBase/Face.cs b/Models/DataBase/Face.cs new file mode 100644 index 0000000..ab2b8bf --- /dev/null +++ b/Models/DataBase/Face.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Foxel.Models.DataBase; + +public class Face : BaseModel +{ + public float[]? Embedding { get; set; } + + public int X { get; set; } + public int Y { get; set; } + public int W { get; set; } + public int H { get; set; } + + [Range(0.0, 1.0)] + public double FaceConfidence { get; set; } + + public int PictureId { get; set; } + + [StringLength(255)] + public string? PersonName { get; set; } + [ForeignKey("PictureId")] + public Picture Picture { get; set; } = null!; +} diff --git a/Models/DataBase/Picture.cs b/Models/DataBase/Picture.cs index 9a36760..2272217 100644 --- a/Models/DataBase/Picture.cs +++ b/Models/DataBase/Picture.cs @@ -48,6 +48,8 @@ public class Picture : BaseModel public Album? Album { get; set; } public ICollection? Favorites { get; set; } + + public ICollection? Faces { get; set; } public bool ContentWarning { get; set; } = false; public PermissionType Permission { get; set; } = PermissionType.Public; diff --git a/MyDbContext.cs b/MyDbContext.cs index 8362999..83cb16d 100644 --- a/MyDbContext.cs +++ b/MyDbContext.cs @@ -15,6 +15,7 @@ public class MyDbContext(DbContextOptions options) : DbContext(opti public DbSet Logs { get; set; } = null!; public DbSet BackgroundTasks { get; set; } = null!; public DbSet StorageModes { get; set; } = null!; + public DbSet Faces { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/Services/Background/BackgroundTaskQueue.cs b/Services/Background/BackgroundTaskQueue.cs index 735d072..f9ae692 100644 --- a/Services/Background/BackgroundTaskQueue.cs +++ b/Services/Background/BackgroundTaskQueue.cs @@ -108,6 +108,37 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable return backgroundTask.Id; } + public async Task QueueFaceRecognitionTaskAsync(FaceRecognitionPayload payload) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var picture = await dbContext.Pictures.FindAsync(payload.PictureId); + if (picture == null) + { + _logger.LogError("无法为不存在的图片 PictureId: {PictureId} 创建人脸识别任务", payload.PictureId); + throw new KeyNotFoundException($"尝试为 PictureId: {payload.PictureId} 创建人脸识别任务时找不到图片"); + } + + var backgroundTask = new BackgroundTask + { + Type = TaskType.FaceRecognition, + Payload = JsonSerializer.Serialize(payload), + UserId = payload.UserIdForPicture, + RelatedEntityId = payload.PictureId, + Status = TaskExecutionStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + + dbContext.BackgroundTasks.Add(backgroundTask); + await dbContext.SaveChangesAsync(); + + await _queue.Writer.WriteAsync(backgroundTask.Id); + _logger.LogInformation("人脸识别任务已加入队列: TaskId={TaskId}, PictureId={PictureId}", backgroundTask.Id, payload.PictureId); + + StartProcessor(); + + return backgroundTask.Id; + } + public async Task> GetUserTasksStatusAsync(int userId) { await using var dbContext = await _contextFactory.CreateDbContextAsync(); @@ -132,7 +163,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable taskName = "图片处理 (图片信息丢失)"; } } - else if (task.Type == TaskType.VisualRecognition && task.RelatedEntityId.HasValue) // Added for VisualRecognition + else if (task.Type == TaskType.VisualRecognition && task.RelatedEntityId.HasValue) { var picture = await dbContext.Pictures.FindAsync(task.RelatedEntityId.Value); if (picture != null) @@ -144,6 +175,18 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable taskName = "视觉识别 (图片信息丢失)"; } } + else if (task.Type == TaskType.FaceRecognition && task.RelatedEntityId.HasValue) + { + var picture = await dbContext.Pictures.FindAsync(task.RelatedEntityId.Value); + if (picture != null) + { + taskName = $"人脸识别: {picture.Name}"; + } + else + { + taskName = "人脸识别 (图片信息丢失)"; + } + } else { taskName = $"任务: {task.Id} ({task.Type})"; // Generic name @@ -201,7 +244,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable { await using var dbContext = await _contextFactory.CreateDbContextAsync(); var unfinishedTasks = await dbContext.BackgroundTasks - .Where(bt => (bt.Type == TaskType.PictureProcessing || bt.Type == TaskType.VisualRecognition) && // Added VisualRecognition + .Where(bt => (bt.Type == TaskType.PictureProcessing || bt.Type == TaskType.VisualRecognition || bt.Type == TaskType.FaceRecognition) && (bt.Status == TaskExecutionStatus.Pending || bt.Status == TaskExecutionStatus.Processing)) .ToListAsync(); @@ -286,13 +329,16 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable case TaskType.PictureProcessing: processor = scope.ServiceProvider.GetRequiredService(); break; - case TaskType.VisualRecognition: // Added case for VisualRecognition + case TaskType.VisualRecognition: processor = scope.ServiceProvider.GetRequiredService(); break; + case TaskType.FaceRecognition: + processor = scope.ServiceProvider.GetRequiredService(); + break; default: _logger.LogError("未找到任务类型 {TaskType} 的处理器: TaskId={TaskId}", taskToCheck.Type, taskToCheck.Id); await MarkTaskAsFailedByQueue(taskToCheck.Id, $"未找到任务类型 {taskToCheck.Type} 的处理器。"); - continue; // Continue to next task in queue + continue; } await processor.ProcessAsync(taskToCheck); // Processor handles its own final status update } diff --git a/Services/Background/IBackgroundTaskQueue.cs b/Services/Background/IBackgroundTaskQueue.cs index 95283e9..294cb9a 100644 --- a/Services/Background/IBackgroundTaskQueue.cs +++ b/Services/Background/IBackgroundTaskQueue.cs @@ -23,6 +23,13 @@ public interface IBackgroundTaskQueue /// 任务ID Task QueueVisualRecognitionTaskAsync(VisualRecognitionPayload payload); + /// + /// 将人脸识别任务添加到队列 + /// + /// 人脸识别任务的Payload + /// 任务ID + Task QueueFaceRecognitionTaskAsync(FaceRecognitionPayload payload); + /// /// 获取用户的所有任务状态 (目前主要指图片处理任务) /// diff --git a/Services/Background/Processors/FaceRecognitionTaskProcessor.cs b/Services/Background/Processors/FaceRecognitionTaskProcessor.cs new file mode 100644 index 0000000..6002f4e --- /dev/null +++ b/Services/Background/Processors/FaceRecognitionTaskProcessor.cs @@ -0,0 +1,281 @@ +using Foxel.Models.DataBase; +using Foxel.Services.Storage; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Foxel.Services.Background.Processors +{ + public class FaceRecognitionPayload + { + public int PictureId { get; set; } + public int? UserIdForPicture { get; set; } + } + + public class FaceRecognitionResponse + { + [JsonPropertyName("detector_backend")] + public string DetectorBackend { get; set; } = string.Empty; + + [JsonPropertyName("recognition_model")] + public string RecognitionModel { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public List Result { get; set; } = new(); + } + + public class FaceResult + { + [JsonPropertyName("embedding")] + public float[] Embedding { get; set; } = Array.Empty(); + + [JsonPropertyName("facial_area")] + public FacialAreaResponse FacialArea { get; set; } = new(); + + [JsonPropertyName("face_confidence")] + public double FaceConfidence { get; set; } + } + + public class FacialAreaResponse + { + [JsonPropertyName("x")] + public int X { get; set; } + + [JsonPropertyName("y")] + public int Y { get; set; } + + [JsonPropertyName("w")] + public int W { get; set; } + + [JsonPropertyName("h")] + public int H { get; set; } + } + + public class FaceRecognitionTaskProcessor : ITaskProcessor + { + private readonly IDbContextFactory _contextFactory; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private const string FaceApiUrl = "http://103.143.81.28:8066/represent"; + private const string ApiKey = ""; + + public FaceRecognitionTaskProcessor( + IDbContextFactory contextFactory, + IServiceProvider serviceProvider, + ILogger logger, + HttpClient httpClient) + { + _contextFactory = contextFactory; + _serviceProvider = serviceProvider; + _logger = logger; + _httpClient = httpClient; + } + + public async Task ProcessAsync(BackgroundTask backgroundTask) + { + if (backgroundTask.Payload == null) + { + await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "任务 Payload 为空。"); + _logger.LogError("人脸识别任务 Payload 为空: TaskId={TaskId}", backgroundTask.Id); + return; + } + + FaceRecognitionPayload? payload; + try + { + payload = JsonSerializer.Deserialize(backgroundTask.Payload); + } + catch (JsonException ex) + { + _logger.LogError(ex, "无法解析人脸识别任务的 Payload: TaskId={TaskId}", backgroundTask.Id); + await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 解析失败。"); + return; + } + + if (payload == null || payload.PictureId == 0) + { + _logger.LogError("人脸识别任务的 Payload 无效或缺少 PictureId: TaskId={TaskId}", backgroundTask.Id); + await UpdateTaskStatusInDb(backgroundTask.Id, TaskExecutionStatus.Failed, 0, "Payload 无效或缺少 PictureId。"); + return; + } + + var pictureId = payload.PictureId; + string tempImagePath = string.Empty; + bool isTempFile = false; + + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var currentBackgroundTaskState = await dbContext.BackgroundTasks.FindAsync(backgroundTask.Id); + if (currentBackgroundTaskState == null) + { + _logger.LogError("在 FaceRecognitionTaskProcessor 中找不到后台任务: TaskId={TaskId}", backgroundTask.Id); + return; + } + + var picture = await dbContext.Pictures + .Include(p => p.StorageMode) + .FirstOrDefaultAsync(p => p.Id == pictureId); + + try + { + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 10, + currentBackgroundTaskState: currentBackgroundTaskState); + + if (picture == null) + { + throw new Exception($"找不到ID为{pictureId}的图片。"); + } + + if (picture.StorageMode == null || picture.StorageModeId < 0) + { + throw new Exception($"图片ID {pictureId} 缺少有效的 StorageMode 配置。"); + } + + using var scope = _serviceProvider.CreateScope(); + var storageService = scope.ServiceProvider.GetRequiredService(); + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 20, + currentBackgroundTaskState: currentBackgroundTaskState); + + // 下载图片文件用于人脸识别 + tempImagePath = await storageService.ExecuteAsync(picture.StorageModeId, + provider => provider.DownloadFileAsync(picture.Path)); + isTempFile = true; + + if (string.IsNullOrEmpty(tempImagePath) || !File.Exists(tempImagePath)) + { + throw new Exception($"找不到用于人脸识别的图片文件: {tempImagePath}"); + } + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 40, + currentBackgroundTaskState: currentBackgroundTaskState); + + // 调用人脸识别 API + var faceRecognitionResult = await CallFaceRecognitionApiAsync(tempImagePath); + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Processing, 70, + currentBackgroundTaskState: currentBackgroundTaskState); + + // 保存人脸数据到数据库 + if (faceRecognitionResult?.Result != null && faceRecognitionResult.Result.Any()) + { + foreach (var faceResult in faceRecognitionResult.Result) + { + var face = new Face + { + PictureId = pictureId, + Embedding = faceResult.Embedding, + X = faceResult.FacialArea.X, + Y = faceResult.FacialArea.Y, + W = faceResult.FacialArea.W, + H = faceResult.FacialArea.H, + FaceConfidence = faceResult.FaceConfidence + }; + + dbContext.Faces.Add(face); + } + + await dbContext.SaveChangesAsync(); + _logger.LogInformation("为图片 {PictureId} 检测到 {FaceCount} 个人脸", pictureId, faceRecognitionResult.Result.Count); + } + else + { + _logger.LogInformation("图片 {PictureId} 未检测到人脸", pictureId); + } + + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Completed, 100, + completedAt: DateTime.UtcNow, currentBackgroundTaskState: currentBackgroundTaskState); + } + catch (Exception ex) + { + _logger.LogError(ex, "人脸识别任务失败: TaskId={TaskId}, PictureId={PictureId}", + currentBackgroundTaskState.Id, pictureId); + await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, + currentBackgroundTaskState.Progress, ex.Message, + currentBackgroundTaskState: currentBackgroundTaskState); + } + finally + { + if (isTempFile && File.Exists(tempImagePath)) + { + try + { + File.Delete(tempImagePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "删除临时人脸识别图片文件失败: {FilePath}", tempImagePath); + } + } + } + } + + private async Task CallFaceRecognitionApiAsync(string imagePath) + { + using var form = new MultipartFormDataContent(); + using var fileStream = new FileStream(imagePath, FileMode.Open, FileAccess.Read); + using var fileContent = new StreamContent(fileStream); + + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg"); + form.Add(fileContent, "file", Path.GetFileName(imagePath)); + + using var request = new HttpRequestMessage(HttpMethod.Post, FaceApiUrl); + request.Headers.Add("api-key", ApiKey); + request.Content = form; + + var response = await _httpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new Exception($"人脸识别 API 调用失败: {response.StatusCode}, {errorContent}"); + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(jsonContent); + } + + private async Task UpdateTaskStatusInDb(Guid taskId, TaskExecutionStatus status, int progress, + string? error = null, DateTime? startedAt = null, DateTime? completedAt = null, + BackgroundTask? currentBackgroundTaskState = null) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var taskToUpdate = currentBackgroundTaskState ?? await dbContext.BackgroundTasks.FindAsync(taskId); + + if (taskToUpdate != null) + { + if (currentBackgroundTaskState != null && + dbContext.Entry(currentBackgroundTaskState).State == EntityState.Detached) + { + dbContext.BackgroundTasks.Attach(currentBackgroundTaskState); + } + + taskToUpdate.Status = status; + taskToUpdate.Progress = progress; + taskToUpdate.ErrorMessage = string.IsNullOrEmpty(error) ? taskToUpdate.ErrorMessage : error; + if (startedAt.HasValue) taskToUpdate.StartedAt = startedAt; + if (completedAt.HasValue) taskToUpdate.CompletedAt = completedAt; + + if ((status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) && + !taskToUpdate.StartedAt.HasValue) + { + taskToUpdate.StartedAt = taskToUpdate.CreatedAt; + } + + if (status == TaskExecutionStatus.Completed || status == TaskExecutionStatus.Failed) + { + taskToUpdate.CompletedAt ??= DateTime.UtcNow; + } + + await dbContext.SaveChangesAsync(); + _logger.LogInformation( + "任务状态更新 (FaceRecognitionProcessor): TaskId={TaskId}, Status={Status}, Progress={Progress}%", + taskId, status, progress); + } + else + { + _logger.LogWarning("尝试在 FaceRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId); + } + } + } +} diff --git a/Services/Media/PictureService.cs b/Services/Media/PictureService.cs index 04f5c40..4df1406 100644 --- a/Services/Media/PictureService.cs +++ b/Services/Media/PictureService.cs @@ -616,6 +616,14 @@ public class PictureService( UserIdForPicture = picture.UserId }; await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload); + + // 添加人脸识别任务 + var faceRecognitionPayload = new Background.Processors.FaceRecognitionPayload + { + PictureId = picture.Id, + UserIdForPicture = picture.UserId + }; + await backgroundTaskQueue.QueueFaceRecognitionTaskAsync(faceRecognitionPayload); } var pictureResponse = mappingService.MapPictureToResponse(picture); diff --git a/Web/src/pages/admin/system/ConfigTabs.tsx b/Web/src/pages/admin/system/ConfigTabs.tsx index b88e114..5e2b2cf 100644 --- a/Web/src/pages/admin/system/ConfigTabs.tsx +++ b/Web/src/pages/admin/system/ConfigTabs.tsx @@ -188,6 +188,29 @@ const ConfigTabs: React.FC = ({ + + } + description="配置人脸识别API服务的基本参数" + isMobile={isMobile} + > +
+ {renderConfigFormItems(formsMap.FaceRecognition, "FaceRecognition", ['ApiEndpoint', 'ApiKey'])} + + + + + +
+
) }, diff --git a/Web/src/pages/admin/system/Index.tsx b/Web/src/pages/admin/system/Index.tsx index 0afa81c..8236827 100644 --- a/Web/src/pages/admin/system/Index.tsx +++ b/Web/src/pages/admin/system/Index.tsx @@ -28,6 +28,10 @@ const allDescriptions: Record> = { TagGenerationPrompt: '用于从图片内容生成标签的提示词。请确保提示词包含返回JSON格式的指示,并且要求返回tags数组字段。', TagMatchingPrompt: '用于将描述内容与已有标签进行匹配的提示词。请确保提示词包含{\'{tagsText}\'}和{\'{description}\'}占位符,系统将会用实际的标签列表和描述内容替换这些占位符。' }, + FaceRecognition: { + ApiEndpoint: '人脸识别服务的API端点地址', + ApiKey: '人脸识别服务的API密钥' + }, Jwt: { SecretKey: 'JWT 加密密钥', Issuer: 'JWT 签发者', @@ -75,9 +79,11 @@ const System: React.FC = () => { const [authForm] = Form.useForm(); const [appSettingsForm] = Form.useForm(); const [uploadForm] = Form.useForm(); + const [faceRecognitionForm] = Form.useForm(); const formsMap: Record = { AI: aiForm, + FaceRecognition: faceRecognitionForm, Jwt: jwtForm, Authentication: authForm, AppSettings: appSettingsForm, diff --git a/Web/src/pages/backgroundTasks/Index.tsx b/Web/src/pages/backgroundTasks/Index.tsx index b2dc269..9ac717c 100644 --- a/Web/src/pages/backgroundTasks/Index.tsx +++ b/Web/src/pages/backgroundTasks/Index.tsx @@ -13,7 +13,8 @@ const { Title, Text } = Typography; // 定义任务类型映射 const taskTypeDisplayMapping: { [key: number]: string } = { 0: '图片处理', - 1: '视觉识别', // 新增视觉识别任务类型 + 1: '视觉识别', + 2: '人脸识别', // 新增人脸识别任务类型 }; const BackgroundTasks: React.FC = () => { @@ -127,7 +128,7 @@ const BackgroundTasks: React.FC = () => { dataIndex: 'taskName', // Changed dataIndex key: 'taskName', render: (text: string, record: TaskDetailsViewModel) => ( // Updated type and logic - (record.taskType === 0 || record.taskType === 1) && record.relatedEntityId // 检查是否为图片处理或视觉识别任务 + (record.taskType === 0 || record.taskType === 1 || record.taskType === 2) && record.relatedEntityId // 检查是否为图片处理、视觉识别或人脸识别任务 ? {text} : text ), @@ -155,6 +156,7 @@ const BackgroundTasks: React.FC = () => { filters: [ // 可以为任务类型添加筛选器 { text: '图片处理', value: 0 }, { text: '视觉识别', value: 1 }, + { text: '人脸识别', value: 2 }, // 新增人脸识别筛选器 ], onFilter: (value, record: TaskDetailsViewModel) => record.taskType === (value as number), },