feat: 添加裁剪人脸图片功能,更新人脸管理服务以支持裁剪路径

This commit is contained in:
shiyu
2025-06-17 16:18:04 +08:00
parent 8a7429f1df
commit 9612a69f9d
4 changed files with 142 additions and 71 deletions
+2
View File
@@ -15,6 +15,8 @@ public class Face : BaseModel
[Range(0.0, 1.0)] [Range(0.0, 1.0)]
public double FaceConfidence { get; set; } public double FaceConfidence { get; set; }
public string? CroppedImagePath { get; set; }
public int PictureId { get; set; } public int PictureId { get; set; }
[ForeignKey("PictureId")] [ForeignKey("PictureId")]
@@ -4,6 +4,9 @@ using Foxel.Services.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Formats.Jpeg;
namespace Foxel.Services.Background.Processors namespace Foxel.Services.Background.Processors
{ {
@@ -162,8 +165,15 @@ namespace Foxel.Services.Background.Processors
// 保存人脸数据到数据库 // 保存人脸数据到数据库
if (faceRecognitionResult?.Result != null && faceRecognitionResult.Result.Any()) if (faceRecognitionResult?.Result != null && faceRecognitionResult.Result.Any())
{ {
// 确保人脸保存目录存在
var faceImagesDir = Path.Combine(Directory.GetCurrentDirectory(), "Uploads", "faces");
Directory.CreateDirectory(faceImagesDir);
foreach (var faceResult in faceRecognitionResult.Result) foreach (var faceResult in faceRecognitionResult.Result)
{ {
// 裁剪人脸图片
var croppedImagePath = await CropAndSaveFaceImageAsync(tempImagePath, faceResult.FacialArea, faceImagesDir);
var face = new Face var face = new Face
{ {
PictureId = pictureId, PictureId = pictureId,
@@ -172,7 +182,8 @@ namespace Foxel.Services.Background.Processors
Y = faceResult.FacialArea.Y, Y = faceResult.FacialArea.Y,
W = faceResult.FacialArea.W, W = faceResult.FacialArea.W,
H = faceResult.FacialArea.H, H = faceResult.FacialArea.H,
FaceConfidence = faceResult.FaceConfidence FaceConfidence = faceResult.FaceConfidence,
CroppedImagePath = croppedImagePath
}; };
dbContext.Faces.Add(face); dbContext.Faces.Add(face);
@@ -282,5 +293,41 @@ namespace Foxel.Services.Background.Processors
_logger.LogWarning("尝试在 FaceRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId); _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;
}
}
} }
} }
+6 -5
View File
@@ -4,12 +4,14 @@ using Foxel.Models.Response.Picture;
using Foxel.Services.Mapping; using Foxel.Services.Mapping;
using Foxel.Api.Management; using Foxel.Api.Management;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Foxel.Services.Configuration;
namespace Foxel.Services.Management; namespace Foxel.Services.Management;
public class FaceManagementService( public class FaceManagementService(
IDbContextFactory<MyDbContext> contextFactory, IDbContextFactory<MyDbContext> contextFactory,
IMappingService mappingService, IMappingService mappingService,
IConfigService configService,
ILogger<FaceManagementService> logger) : IFaceManagementService ILogger<FaceManagementService> logger) : IFaceManagementService
{ {
public async Task<PaginatedResult<FaceClusterResponse>> GetFaceClustersAsync(int page = 1, int pageSize = 20) public async Task<PaginatedResult<FaceClusterResponse>> GetFaceClustersAsync(int page = 1, int pageSize = 20)
@@ -22,7 +24,7 @@ public class FaceManagementService(
{ {
Cluster = c, Cluster = c,
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id), FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id),
ThumbnailPath = dbContext.Faces ThumbnailPath = configService["AppSettings:ServerUrl"] + dbContext.Faces
.Where(f => f.ClusterId == c.Id) .Where(f => f.ClusterId == c.Id)
.Include(f => f.Picture) .Include(f => f.Picture)
.OrderByDescending(f => f.CreatedAt) .OrderByDescending(f => f.CreatedAt)
@@ -195,11 +197,10 @@ public class FaceManagementService(
{ {
Cluster = c, Cluster = c,
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id && f.Picture.UserId == userId), FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id && f.Picture.UserId == userId),
ThumbnailPath = dbContext.Faces ThumbnailPath = configService["AppSettings:ServerUrl"]+ dbContext.Faces
.Where(f => f.ClusterId == c.Id && f.Picture.UserId == userId) .Where(f => f.ClusterId == c.Id && f.Picture.UserId == userId && !string.IsNullOrEmpty(f.CroppedImagePath))
.Include(f => f.Picture)
.OrderByDescending(f => f.CreatedAt) .OrderByDescending(f => f.CreatedAt)
.Select(f => f.Picture.ThumbnailPath) .Select(f => f.CroppedImagePath)
.FirstOrDefault() .FirstOrDefault()
}) })
.Where(x => x.FaceCount > 0) .Where(x => x.FaceCount > 0)
+86 -65
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
Card, Button, Space, Modal, message, Typography, Card, Button, Space, Modal, message, Typography,
Row, Col, Image, Form, Input, Avatar, Row, Col, Image, Form, Input, Avatar,
Tag, Tooltip, Spin, Empty, Statistic, Tag, Tooltip, Spin, Empty, Statistic,
Select, Grid Select, Grid
} from 'antd'; } from 'antd';
@@ -40,7 +40,7 @@ const FaceExplore: React.FC = () => {
const [targetCluster, setTargetCluster] = useState<FaceClusterResponse | null>(null); const [targetCluster, setTargetCluster] = useState<FaceClusterResponse | null>(null);
const [clusterPictures, setClusterPictures] = useState<PictureResponse[]>([]); const [clusterPictures, setClusterPictures] = useState<PictureResponse[]>([]);
const [picturesLoading, setPicturesLoading] = useState(false); const [picturesLoading, setPicturesLoading] = useState(false);
const [editForm] = Form.useForm<UpdateClusterRequest>(); const [editForm] = Form.useForm<UpdateClusterRequest>();
const [mergeForm] = Form.useForm(); const [mergeForm] = Form.useForm();
@@ -118,12 +118,12 @@ const FaceExplore: React.FC = () => {
const handleEditOk = async () => { const handleEditOk = async () => {
if (!editingCluster) return; if (!editingCluster) return;
try { try {
const values = await editForm.validateFields(); const values = await editForm.validateFields();
setLoading(true); setLoading(true);
const response = await updateMyCluster(editingCluster.id, values); const response = await updateMyCluster(editingCluster.id, values);
if (response.success) { if (response.success) {
message.success('更新聚类信息成功'); message.success('更新聚类信息成功');
setIsEditModalVisible(false); setIsEditModalVisible(false);
@@ -140,12 +140,12 @@ const FaceExplore: React.FC = () => {
const handleMergeOk = async () => { const handleMergeOk = async () => {
if (!targetCluster) return; if (!targetCluster) return;
try { try {
const values = await mergeForm.validateFields(); const values = await mergeForm.validateFields();
setLoading(true); setLoading(true);
const response = await mergeMyUserClusters(targetCluster.id, values.sourceClusterId); const response = await mergeMyUserClusters(targetCluster.id, values.sourceClusterId);
if (response.success) { if (response.success) {
message.success('合并聚类成功'); message.success('合并聚类成功');
setIsMergeModalVisible(false); setIsMergeModalVisible(false);
@@ -189,31 +189,33 @@ const FaceExplore: React.FC = () => {
hoverable hoverable
className="cluster-card" className="cluster-card"
cover={ cover={
<div style={{ <div style={{
height: 180, height: 180,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: '#f8f9fa', backgroundColor: '#f8f9fa',
overflow: 'hidden' overflow: 'hidden',
padding: 20
}}> }}>
{cluster.thumbnailPath ? ( {cluster.thumbnailPath ? (
<Image <Avatar
size={120}
src={cluster.thumbnailPath} src={cluster.thumbnailPath}
alt={cluster.name} style={{
width="100%" border: '3px solid #fff',
height={180} boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
style={{
objectFit: 'cover',
display: 'block'
}} }}
preview={false}
/> />
) : ( ) : (
<Avatar <Avatar
size={80} size={120}
icon={<UserOutlined />} icon={<UserOutlined />}
style={{ backgroundColor: '#e6f7ff' }} style={{
backgroundColor: '#e6f7ff',
border: '3px solid #fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
/> />
)} )}
</div> </div>
@@ -247,8 +249,8 @@ const FaceExplore: React.FC = () => {
</Paragraph> </Paragraph>
)} )}
{cluster.description && ( {cluster.description && (
<Paragraph <Paragraph
style={{ margin: '8px 0 0 0' }} style={{ margin: '8px 0 0 0' }}
ellipsis={{ rows: 2, tooltip: cluster.description }} ellipsis={{ rows: 2, tooltip: cluster.description }}
> >
{cluster.description} {cluster.description}
@@ -281,16 +283,16 @@ const FaceExplore: React.FC = () => {
</Col> </Col>
<Col> <Col>
<Space> <Space>
<Button <Button
type="primary" type="primary"
icon={<PlayCircleOutlined />} icon={<PlayCircleOutlined />}
onClick={handleStartClustering} onClick={handleStartClustering}
loading={clusteringLoading} loading={clusteringLoading}
> >
</Button> </Button>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => fetchClusters()} onClick={() => fetchClusters()}
loading={loading} loading={loading}
> >
@@ -305,9 +307,9 @@ const FaceExplore: React.FC = () => {
<Row gutter={16} style={{ marginBottom: 24 }}> <Row gutter={16} style={{ marginBottom: 24 }}>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={8} md={6}>
<Card> <Card>
<Statistic <Statistic
title="人脸聚类" title="人脸聚类"
value={total} value={total}
prefix={<TeamOutlined />} prefix={<TeamOutlined />}
valueStyle={{ color: '#1890ff' }} valueStyle={{ color: '#1890ff' }}
/> />
@@ -315,9 +317,9 @@ const FaceExplore: React.FC = () => {
</Col> </Col>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={8} md={6}>
<Card> <Card>
<Statistic <Statistic
title="已命名聚类" title="已命名聚类"
value={clusters.filter(c => c.personName).length} value={clusters.filter(c => c.personName).length}
prefix={<HeartOutlined />} prefix={<HeartOutlined />}
valueStyle={{ color: '#52c41a' }} valueStyle={{ color: '#52c41a' }}
/> />
@@ -325,9 +327,9 @@ const FaceExplore: React.FC = () => {
</Col> </Col>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={8} md={6}>
<Card> <Card>
<Statistic <Statistic
title="总人脸数" title="总人脸数"
value={clusters.reduce((sum, c) => sum + c.faceCount, 0)} value={clusters.reduce((sum, c) => sum + c.faceCount, 0)}
prefix={<UserOutlined />} prefix={<UserOutlined />}
valueStyle={{ color: '#722ed1' }} valueStyle={{ color: '#722ed1' }}
/> />
@@ -346,7 +348,7 @@ const FaceExplore: React.FC = () => {
</Col> </Col>
))} ))}
</Row> </Row>
{/* 加载更多按钮 */} {/* 加载更多按钮 */}
{clusters.length < total && ( {clusters.length < total && (
<div style={{ textAlign: 'center', marginTop: 24 }}> <div style={{ textAlign: 'center', marginTop: 24 }}>
@@ -357,7 +359,7 @@ const FaceExplore: React.FC = () => {
)} )}
</> </>
) : ( ) : (
<Empty <Empty
description={ description={
<div> <div>
<Paragraph></Paragraph> <Paragraph></Paragraph>
@@ -368,9 +370,9 @@ const FaceExplore: React.FC = () => {
} }
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
> >
<Button <Button
type="primary" type="primary"
icon={<PlayCircleOutlined />} icon={<PlayCircleOutlined />}
onClick={handleStartClustering} onClick={handleStartClustering}
loading={clusteringLoading} loading={clusteringLoading}
> >
@@ -409,8 +411,8 @@ const FaceExplore: React.FC = () => {
destroyOnClose destroyOnClose
> >
<Form form={mergeForm} layout="vertical"> <Form form={mergeForm} layout="vertical">
<Form.Item <Form.Item
name="sourceClusterId" name="sourceClusterId"
label="选择要合并的聚类" label="选择要合并的聚类"
rules={[{ required: true, message: '请选择要合并的聚类' }]} rules={[{ required: true, message: '请选择要合并的聚类' }]}
> >
@@ -446,40 +448,59 @@ const FaceExplore: React.FC = () => {
> >
<Spin spinning={picturesLoading}> <Spin spinning={picturesLoading}>
{clusterPictures.length > 0 ? ( {clusterPictures.length > 0 ? (
<div style={{ <div style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 12, gap: 16,
maxHeight: 500, maxHeight: 500,
overflowY: 'auto', overflowY: 'auto',
padding: '8px' padding: '8px'
}}> }}>
{clusterPictures.map(picture => ( {clusterPictures.map(picture => (
<div key={picture.id} style={{ textAlign: 'center' }}> <div key={picture.id} style={{ textAlign: 'center' }}>
<Image <Avatar
width={100} size={100}
height={100}
src={picture.thumbnailPath || picture.path} src={picture.thumbnailPath || picture.path}
style={{ style={{
objectFit: 'cover', border: '2px solid #f0f0f0',
borderRadius: 6, cursor: 'pointer',
border: '1px solid #f0f0f0' transition: 'all 0.3s ease',
}} marginBottom: 8
preview={{
src: picture.path
}} }}
/> />
<div style={{ <div style={{
fontSize: '11px', fontSize: '11px',
color: '#666', color: '#666',
marginTop: 4,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
maxWidth: '100px' maxWidth: '120px'
}}> }}>
{picture.name || `图片${picture.id}`} {picture.name || `图片${picture.id}`}
</div> </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>
))} ))}
</div> </div>