mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-08 00:56:49 +08:00
feat: 添加裁剪人脸图片功能,更新人脸管理服务以支持裁剪路径
This commit is contained in:
@@ -15,6 +15,8 @@ public class Face : BaseModel
|
||||
[Range(0.0, 1.0)]
|
||||
public double FaceConfidence { get; set; }
|
||||
|
||||
public string? CroppedImagePath { get; set; }
|
||||
|
||||
public int PictureId { get; set; }
|
||||
|
||||
[ForeignKey("PictureId")]
|
||||
|
||||
@@ -4,6 +4,9 @@ using Foxel.Services.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
|
||||
namespace Foxel.Services.Background.Processors
|
||||
{
|
||||
@@ -162,8 +165,15 @@ namespace Foxel.Services.Background.Processors
|
||||
// 保存人脸数据到数据库
|
||||
if (faceRecognitionResult?.Result != null && faceRecognitionResult.Result.Any())
|
||||
{
|
||||
// 确保人脸保存目录存在
|
||||
var faceImagesDir = Path.Combine(Directory.GetCurrentDirectory(), "Uploads", "faces");
|
||||
Directory.CreateDirectory(faceImagesDir);
|
||||
|
||||
foreach (var faceResult in faceRecognitionResult.Result)
|
||||
{
|
||||
// 裁剪人脸图片
|
||||
var croppedImagePath = await CropAndSaveFaceImageAsync(tempImagePath, faceResult.FacialArea, faceImagesDir);
|
||||
|
||||
var face = new Face
|
||||
{
|
||||
PictureId = pictureId,
|
||||
@@ -172,7 +182,8 @@ namespace Foxel.Services.Background.Processors
|
||||
Y = faceResult.FacialArea.Y,
|
||||
W = faceResult.FacialArea.W,
|
||||
H = faceResult.FacialArea.H,
|
||||
FaceConfidence = faceResult.FaceConfidence
|
||||
FaceConfidence = faceResult.FaceConfidence,
|
||||
CroppedImagePath = croppedImagePath
|
||||
};
|
||||
|
||||
dbContext.Faces.Add(face);
|
||||
@@ -282,5 +293,41 @@ namespace Foxel.Services.Background.Processors
|
||||
_logger.LogWarning("尝试在 FaceRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> CropAndSaveFaceImageAsync(string originalImagePath, FacialAreaResponse facialArea, string saveDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var originalImage = await Image.LoadAsync(originalImagePath);
|
||||
|
||||
// 确保裁剪区域在图片范围内
|
||||
var cropX = Math.Max(0, facialArea.X);
|
||||
var cropY = Math.Max(0, facialArea.Y);
|
||||
var cropWidth = Math.Min(facialArea.W, originalImage.Width - cropX);
|
||||
var cropHeight = Math.Min(facialArea.H, originalImage.Height - cropY);
|
||||
|
||||
if (cropWidth <= 0 || cropHeight <= 0)
|
||||
{
|
||||
throw new Exception("无效的人脸区域坐标");
|
||||
}
|
||||
|
||||
var cropRect = new Rectangle(cropX, cropY, cropWidth, cropHeight);
|
||||
|
||||
// 生成唯一文件名
|
||||
var fileName = $"face_{Guid.NewGuid()}.jpg";
|
||||
var filePath = Path.Combine(saveDirectory, fileName);
|
||||
|
||||
// 使用 ImageSharp 裁剪并保存
|
||||
using var croppedImage = originalImage.Clone(ctx => ctx.Crop(cropRect));
|
||||
await croppedImage.SaveAsJpegAsync(filePath, new JpegEncoder { Quality = 90 });
|
||||
|
||||
return Path.Combine("Uploads", "faces", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "裁剪人脸图片失败");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ using Foxel.Models.Response.Picture;
|
||||
using Foxel.Services.Mapping;
|
||||
using Foxel.Api.Management;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Foxel.Services.Configuration;
|
||||
|
||||
namespace Foxel.Services.Management;
|
||||
|
||||
public class FaceManagementService(
|
||||
IDbContextFactory<MyDbContext> contextFactory,
|
||||
IMappingService mappingService,
|
||||
IConfigService configService,
|
||||
ILogger<FaceManagementService> logger) : IFaceManagementService
|
||||
{
|
||||
public async Task<PaginatedResult<FaceClusterResponse>> GetFaceClustersAsync(int page = 1, int pageSize = 20)
|
||||
@@ -22,7 +24,7 @@ public class FaceManagementService(
|
||||
{
|
||||
Cluster = c,
|
||||
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id),
|
||||
ThumbnailPath = dbContext.Faces
|
||||
ThumbnailPath = configService["AppSettings:ServerUrl"] + dbContext.Faces
|
||||
.Where(f => f.ClusterId == c.Id)
|
||||
.Include(f => f.Picture)
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
@@ -195,11 +197,10 @@ public class FaceManagementService(
|
||||
{
|
||||
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)
|
||||
ThumbnailPath = configService["AppSettings:ServerUrl"]+ dbContext.Faces
|
||||
.Where(f => f.ClusterId == c.Id && f.Picture.UserId == userId && !string.IsNullOrEmpty(f.CroppedImagePath))
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(f => f.Picture.ThumbnailPath)
|
||||
.Select(f => f.CroppedImagePath)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Where(x => x.FaceCount > 0)
|
||||
|
||||
@@ -195,25 +195,27 @@ const FaceExplore: React.FC = () => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f8f9fa',
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
padding: 20
|
||||
}}>
|
||||
{cluster.thumbnailPath ? (
|
||||
<Image
|
||||
<Avatar
|
||||
size={120}
|
||||
src={cluster.thumbnailPath}
|
||||
alt={cluster.name}
|
||||
width="100%"
|
||||
height={180}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
display: 'block'
|
||||
border: '3px solid #fff',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
preview={false}
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
size={80}
|
||||
size={120}
|
||||
icon={<UserOutlined />}
|
||||
style={{ backgroundColor: '#e6f7ff' }}
|
||||
style={{
|
||||
backgroundColor: '#e6f7ff',
|
||||
border: '3px solid #fff',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -448,38 +450,57 @@ const FaceExplore: React.FC = () => {
|
||||
{clusterPictures.length > 0 ? (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))',
|
||||
gap: 12,
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 16,
|
||||
maxHeight: 500,
|
||||
overflowY: 'auto',
|
||||
padding: '8px'
|
||||
}}>
|
||||
{clusterPictures.map(picture => (
|
||||
<div key={picture.id} style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
width={100}
|
||||
height={100}
|
||||
<Avatar
|
||||
size={100}
|
||||
src={picture.thumbnailPath || picture.path}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #f0f0f0'
|
||||
}}
|
||||
preview={{
|
||||
src: picture.path
|
||||
border: '2px solid #f0f0f0',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
marginBottom: 8
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100px'
|
||||
maxWidth: '120px'
|
||||
}}>
|
||||
{picture.name || `图片${picture.id}`}
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Image
|
||||
width={0}
|
||||
height={0}
|
||||
src={picture.path}
|
||||
style={{ display: 'none' }}
|
||||
preview={{
|
||||
src: picture.path,
|
||||
mask: (
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#fff',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
查看大图
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user