From 00dbaff8b2211fa81ac35ed76bb395929e1ab8d3 Mon Sep 17 00:00:00 2001 From: shiyu Date: Mon, 16 Jun 2025 12:47:48 +0800 Subject: [PATCH] feat(face-recognition): add face detection support and UI overlay --- Models/Response/Picture/FaceResponse.cs | 11 ++ Models/Response/Picture/PictureResponse.cs | 2 +- .../FaceRecognitionTaskProcessor.cs | 29 +-- Services/Mapping/MappingService.cs | 15 +- Services/Media/PictureService.cs | 1 + Web/src/api/pictureApi.ts | 1 + Web/src/components/image/ImageViewer.css | 60 +++++++ Web/src/components/image/ImageViewer.tsx | 166 +++++++++++++----- 8 files changed, 227 insertions(+), 58 deletions(-) create mode 100644 Models/Response/Picture/FaceResponse.cs diff --git a/Models/Response/Picture/FaceResponse.cs b/Models/Response/Picture/FaceResponse.cs new file mode 100644 index 0000000..0e497aa --- /dev/null +++ b/Models/Response/Picture/FaceResponse.cs @@ -0,0 +1,11 @@ +namespace Foxel.Models.Response.Picture; + +public record FaceResponse +{ + public int X { get; set; } + public int Y { get; set; } + public int W { get; set; } + public int H { get; set; } + public double FaceConfidence { get; set; } + public string? PersonName { get; set; } +} \ No newline at end of file diff --git a/Models/Response/Picture/PictureResponse.cs b/Models/Response/Picture/PictureResponse.cs index 6a7d13d..2f35e51 100644 --- a/Models/Response/Picture/PictureResponse.cs +++ b/Models/Response/Picture/PictureResponse.cs @@ -27,5 +27,5 @@ public record PictureResponse public string? AlbumName { get; set; } public PermissionType Permission { get; set; } = PermissionType.Public; public string? StorageModeName { get; set; } - + public List? Faces { get; set; } } diff --git a/Services/Background/Processors/FaceRecognitionTaskProcessor.cs b/Services/Background/Processors/FaceRecognitionTaskProcessor.cs index 6002f4e..feffc6c 100644 --- a/Services/Background/Processors/FaceRecognitionTaskProcessor.cs +++ b/Services/Background/Processors/FaceRecognitionTaskProcessor.cs @@ -1,4 +1,5 @@ using Foxel.Models.DataBase; +using Foxel.Services.Configuration; using Foxel.Services.Storage; using Microsoft.EntityFrameworkCore; using System.Text.Json; @@ -16,10 +17,10 @@ namespace Foxel.Services.Background.Processors { [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(); } @@ -28,10 +29,10 @@ namespace Foxel.Services.Background.Processors { [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; } } @@ -40,13 +41,13 @@ namespace Foxel.Services.Background.Processors { [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; } } @@ -55,14 +56,15 @@ namespace Foxel.Services.Background.Processors { private readonly IDbContextFactory _contextFactory; private readonly IServiceProvider _serviceProvider; + private readonly IConfigService _configService; 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, + IConfigService configService, ILogger logger, HttpClient httpClient) { @@ -70,6 +72,7 @@ namespace Foxel.Services.Background.Processors _serviceProvider = serviceProvider; _logger = logger; _httpClient = httpClient; + _configService = configService; } public async Task ProcessAsync(BackgroundTask backgroundTask) @@ -188,7 +191,7 @@ namespace Foxel.Services.Background.Processors } catch (Exception ex) { - _logger.LogError(ex, "人脸识别任务失败: TaskId={TaskId}, PictureId={PictureId}", + _logger.LogError(ex, "人脸识别任务失败: TaskId={TaskId}, PictureId={PictureId}", currentBackgroundTaskState.Id, pictureId); await UpdateTaskStatusInDb(currentBackgroundTaskState.Id, TaskExecutionStatus.Failed, currentBackgroundTaskState.Progress, ex.Message, @@ -212,10 +215,12 @@ namespace Foxel.Services.Background.Processors private async Task CallFaceRecognitionApiAsync(string imagePath) { + string FaceApiUrl = _configService["FaceRecognition:ApiEndpoint"]; + string ApiKey = _configService["FaceRecognition:ApiKey"]; 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)); @@ -224,7 +229,7 @@ namespace Foxel.Services.Background.Processors request.Content = form; var response = await _httpClient.SendAsync(request); - + if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(); diff --git a/Services/Mapping/MappingService.cs b/Services/Mapping/MappingService.cs index 0a1208f..69d63c3 100644 --- a/Services/Mapping/MappingService.cs +++ b/Services/Mapping/MappingService.cs @@ -17,12 +17,12 @@ namespace Foxel.Services.Mapping { coverPath = storageService.ExecuteAsync(album.CoverPicture.StorageModeId, provider => Task.FromResult(provider.GetUrl(album.CoverPicture.Id, album.CoverPicture.Path))) - .Result; + .Result; if (!string.IsNullOrEmpty(album.CoverPicture.ThumbnailPath)) { coverThumbnailPath = storageService.ExecuteAsync(album.CoverPicture.StorageModeId, provider => Task.FromResult(provider.GetUrl(album.CoverPicture.Id, - album.CoverPicture.ThumbnailPath))).Result; + album.CoverPicture.ThumbnailPath))).Result; } } @@ -63,7 +63,16 @@ namespace Foxel.Services.Mapping AlbumName = picture.Album?.Name, Permission = picture.Permission, FavoriteCount = picture.Favorites?.Count ?? 0, - StorageModeName = picture.StorageMode?.Name + StorageModeName = picture.StorageMode?.Name, + Faces = picture.Faces?.Select(face => new FaceResponse + { + X = face.X, + Y = face.Y, + W = face.W, + H = face.H, + FaceConfidence = face.FaceConfidence, + PersonName = face.PersonName + }).ToList(), }; } } diff --git a/Services/Media/PictureService.cs b/Services/Media/PictureService.cs index 4df1406..05f6e4a 100644 --- a/Services/Media/PictureService.cs +++ b/Services/Media/PictureService.cs @@ -157,6 +157,7 @@ public class PictureService( // 构建基础查询 IQueryable query = dbContext.Pictures .Include(p => p.Tags) + .Include(p => p.Faces) .Include(p => p.User); // 应用文本搜索条件 diff --git a/Web/src/api/pictureApi.ts b/Web/src/api/pictureApi.ts index a3ed0e6..349c40f 100644 --- a/Web/src/api/pictureApi.ts +++ b/Web/src/api/pictureApi.ts @@ -24,6 +24,7 @@ export interface FilteredPicturesRequest { // 图片响应数据 export interface PictureResponse { + faces: any; id: number; name: string; path: string; diff --git a/Web/src/components/image/ImageViewer.css b/Web/src/components/image/ImageViewer.css index 4883805..6e0c404 100644 --- a/Web/src/components/image/ImageViewer.css +++ b/Web/src/components/image/ImageViewer.css @@ -375,3 +375,63 @@ .image-loading-spinner .ant-spin-dot-item { background-color: white; } + +.face-detection-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1004; +} + +.face-mask { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + z-index: 1; +} + +.face-highlight { + position: absolute; + border: 2px solid #1890ff; + border-radius: 4px; + background-color: transparent; + box-shadow: 0 0 10px rgba(24, 144, 255, 0.5); + z-index: 2; + animation: faceHighlight 1.5s ease-in-out infinite alternate; +} + +@keyframes faceHighlight { + 0% { + border-color: #1890ff; + box-shadow: 0 0 10px rgba(24, 144, 255, 0.5); + } + 100% { + border-color: #52c41a; + box-shadow: 0 0 15px rgba(82, 196, 26, 0.7); + } +} + +.face-label { + position: absolute; + bottom: -30px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(24, 144, 255, 0.9); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + backdrop-filter: blur(4px); +} + +.face-confidence { + opacity: 0.8; + font-size: 10px; +} diff --git a/Web/src/components/image/ImageViewer.tsx b/Web/src/components/image/ImageViewer.tsx index 23256db..410c74c 100644 --- a/Web/src/components/image/ImageViewer.tsx +++ b/Web/src/components/image/ImageViewer.tsx @@ -4,7 +4,7 @@ import { ZoomInOutlined, ZoomOutOutlined, ExpandOutlined, InfoCircleOutlined, CloseOutlined, LeftOutlined, RightOutlined, RotateLeftOutlined, RotateRightOutlined, HeartOutlined, HeartFilled, DownloadOutlined, - ShareAltOutlined, FolderAddOutlined + ShareAltOutlined, FolderAddOutlined, UserOutlined } from '@ant-design/icons'; import type { PictureResponse, AlbumResponse } from '../../api'; import { getAlbums, addPicturesToAlbum, favoritePicture, unfavoritePicture } from '../../api'; @@ -42,6 +42,15 @@ interface ZoomPanState { lastPositionY: number; } +interface Face { + x: number; + y: number; + w: number; + h: number; + faceConfidence: number; + personName?: string | null; +} + const ImageViewer: React.FC = ({ visible, onClose, @@ -65,7 +74,8 @@ const ImageViewer: React.FC = ({ const [currentLoading, setCurrentLoading] = useState(false); const [fadeTransition, setFadeTransition] = useState(false); const [, setActiveImage] = useState(null); - + const [faceDetectionMode, setFaceDetectionMode] = useState(false); + const [zoomPanState, setZoomPanState] = useState({ scale: 1, positionX: 0, @@ -76,18 +86,18 @@ const ImageViewer: React.FC = ({ lastPositionX: 0, lastPositionY: 0, }); - + const imageContainerRef = useRef(null); const imageRef = useRef(null); const imageCache = useRef({}); const sessionKey = useRef(Date.now().toString()); const currentLoadingUrl = useRef(null); - const preloadedImagesRef = useRef<{[key: string]: HTMLImageElement}>({}); + const preloadedImagesRef = useRef<{ [key: string]: HTMLImageElement }>({}); const favoriteOperationsInProgress = useRef>(new Map()); - + const currentImage = localImages[currentIndex]; const preloadRange = 2; - + const MIN_SCALE = 0.1; const MAX_SCALE = 8; const ZOOM_FACTOR = 0.2; @@ -96,6 +106,7 @@ const ImageViewer: React.FC = ({ setRotation(0); setIsInfoDrawerOpen(false); setImageLoaded(false); + setFaceDetectionMode(false); setZoomPanState({ scale: 1, positionX: 0, @@ -124,15 +135,15 @@ const ImageViewer: React.FC = ({ loaded: true, img }; - + preloadedImagesRef.current[imageUrl] = img; - + if (imageUrl === currentLoadingUrl.current) { setImageLoaded(true); setCurrentLoading(false); setActiveImage(imageUrl); } - + resolve(img); }; img.onerror = () => { @@ -141,7 +152,7 @@ const ImageViewer: React.FC = ({ } reject(new Error(`Failed to load image: ${imageUrl}`)); }; - + img.src = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}_s=${sessionKey.current}`; }); }, [currentImage]); @@ -150,15 +161,15 @@ const ImageViewer: React.FC = ({ setImageLoaded(false); setCurrentLoading(true); setFadeTransition(true); - + if (currentImage && imageCache.current[currentImage.path]?.loaded) { setActiveImage(currentImage.path); setImageLoaded(true); setCurrentLoading(false); - + setTimeout(() => setFadeTransition(false), 100); } - + setZoomPanState(prev => ({ ...prev, scale: 1, @@ -177,7 +188,7 @@ const ImageViewer: React.FC = ({ } wasVisible.current = visible; }, [visible, resetViewerState]); - + useEffect(() => { if (visible && initialIndex >= 0 && initialIndex < images.length) { setCurrentIndex(initialIndex); @@ -189,14 +200,14 @@ const ImageViewer: React.FC = ({ currentLoadingUrl.current = currentImage.path; setCurrentLoading(true); - + loadImage(currentImage.path) .then(() => { if (currentLoadingUrl.current === currentImage.path) { setImageLoaded(true); setCurrentLoading(false); setActiveImage(currentImage.path); - + setTimeout(() => setFadeTransition(false), 100); } }) @@ -205,18 +216,18 @@ const ImageViewer: React.FC = ({ message.error('图片加载失败,请重试'); setCurrentLoading(false); }); - + if (localImages.length > 1) { setTimeout(() => { for (let i = 1; i <= preloadRange; i++) { const nextIndex = currentIndex + i; if (nextIndex < localImages.length) { - loadImage(localImages[nextIndex].path).catch(() => {}); + loadImage(localImages[nextIndex].path).catch(() => { }); } - + const prevIndex = currentIndex - i; if (prevIndex >= 0) { - loadImage(localImages[prevIndex].path).catch(() => {}); + loadImage(localImages[prevIndex].path).catch(() => { }); } } }, 300); @@ -259,28 +270,28 @@ const ImageViewer: React.FC = ({ const handleFavoriteClick = useCallback(async () => { if (!currentImage) return; - + if (favoriteOperationsInProgress.current.get(currentImage.id)) { return; } - + try { favoriteOperationsInProgress.current.set(currentImage.id, true); - + if (onFavorite) { onFavorite(currentImage); return; } - + const isFavorited = currentImage.isFavorited; - - const result = isFavorited + + const result = isFavorited ? await unfavoritePicture(currentImage.id) : await favoritePicture(currentImage.id); - + if (result.success) { message.success(isFavorited ? '已取消收藏' : '已添加到收藏'); - + const updatedImage = { ...currentImage, isFavorited: !isFavorited, @@ -288,7 +299,7 @@ const ImageViewer: React.FC = ({ ? Math.max(0, (currentImage.favoriteCount || 0) - 1) : (currentImage.favoriteCount || 0) + 1 }; - + setLocalImages(prevImages => prevImages.map(img => img.id === currentImage.id ? updatedImage : img @@ -350,6 +361,10 @@ const ImageViewer: React.FC = ({ onShare ? onShare(currentImage) : setShareDialogVisible(true); }, [currentImage, onShare]); + const handleFaceDetectionToggle = useCallback(() => { + setFaceDetectionMode(prev => !prev); + }, []); + const zoomIn = useCallback((factor = ZOOM_FACTOR) => { setZoomPanState(prev => ({ ...prev, @@ -384,17 +399,17 @@ const ImageViewer: React.FC = ({ setZoomPanState(prev => { const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * scaleFactor)); - + const rect = imageContainerRef.current?.getBoundingClientRect(); if (!rect) return { ...prev, scale: newScale }; - + const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const containerCenterX = rect.width / 2; const containerCenterY = rect.height / 2; const dx = (mouseX - containerCenterX - prev.positionX) * (scaleFactor - 1); const dy = (mouseY - containerCenterY - prev.positionY) * (scaleFactor - 1); - + return { ...prev, scale: newScale, @@ -434,7 +449,7 @@ const ImageViewer: React.FC = ({ if (zoomPanState.isDragging) { const dx = e.clientX - zoomPanState.dragStartX; const dy = e.clientY - zoomPanState.dragStartY; - + setZoomPanState(prev => ({ ...prev, positionX: prev.lastPositionX + dx, @@ -448,7 +463,7 @@ const ImageViewer: React.FC = ({ const touch = e.touches[0]; const dx = touch.clientX - zoomPanState.dragStartX; const dy = touch.clientY - zoomPanState.dragStartY; - + setZoomPanState(prev => ({ ...prev, positionX: prev.lastPositionX + dx, @@ -469,13 +484,72 @@ const ImageViewer: React.FC = ({ resetTransform(); }, [resetTransform]); + const renderFaceOverlay = useCallback(() => { + if (!faceDetectionMode || !currentImage || !currentImage.faces || !imageRef.current) { + return null; + } + + const img = imageRef.current; + const imgRect = img.getBoundingClientRect(); + const containerRect = imageContainerRef.current?.getBoundingClientRect(); + + if (!containerRect) return null; + + const naturalWidth = img.naturalWidth; + const naturalHeight = img.naturalHeight; + + if (!naturalWidth || !naturalHeight) return null; + + // 计算图片在容器中的实际显示尺寸和位置 + const displayWidth = imgRect.width; + const displayHeight = imgRect.height; + const scaleX = displayWidth / naturalWidth; + const scaleY = displayHeight / naturalHeight; + + // 图片相对于容器的偏移 + const offsetX = imgRect.left - containerRect.left; + const offsetY = imgRect.top - containerRect.top; + + return ( +
+
+ {currentImage.faces.map((face: Face, index: number) => { + const faceX = offsetX + (face.x * scaleX); + const faceY = offsetY + (face.y * scaleY); + const faceWidth = face.w * scaleX; + const faceHeight = face.h * scaleY; + + return ( +
+
+ {face.personName || '未知人物'} +
+ 置信度: {(face.faceConfidence * 100).toFixed(1)}% +
+
+
+ ); + })} +
+ ); + }, [faceDetectionMode, currentImage, zoomPanState.scale, zoomPanState.positionX, zoomPanState.positionY, rotation]); + useEffect(() => { if (visible) { window.addEventListener('mouseup', handleMouseUp); window.addEventListener('mouseleave', handleMouseUp); window.addEventListener('touchend', handleTouchEnd); window.addEventListener('touchcancel', handleTouchEnd); - + return () => { window.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('mouseleave', handleMouseUp); @@ -501,8 +575,8 @@ const ImageViewer: React.FC = ({
-
= ({ style={{ transform: `translate(${zoomPanState.positionX}px, ${zoomPanState.positionY}px) rotate(${rotation}deg) scale(${zoomPanState.scale})`, opacity: imageLoaded ? 1 : 0.3, - transition: zoomPanState.isDragging ? 'none' : - fadeTransition ? 'opacity 0.15s ease, transform 0.1s ease-out' : - 'transform 0.1s ease-out', + transition: zoomPanState.isDragging ? 'none' : + fadeTransition ? 'opacity 0.15s ease, transform 0.1s ease-out' : + 'transform 0.1s ease-out', cursor: zoomPanState.scale > 1 ? 'grab' : 'auto', transformOrigin: 'center center', willChange: 'opacity, transform' @@ -533,12 +607,13 @@ const ImageViewer: React.FC = ({ )}
+ {renderFaceOverlay()} + {(!imageLoaded || currentLoading) && (
图片加载中...} />
)} -
@@ -608,7 +690,7 @@ const ImageViewer: React.FC = ({ {currentImage.favoriteCount} )} - +