You've already forked VptPassiveAdapter
feat(api): 添加图像处理配置和服务器端图像处理功能
- 新增 CompressionConfig、ThumbnailConfig 和 ImageProcessingConfig 结构体用于图像处理配置 - 实现 GetEffectiveConfig 方法提供图像处理配置的默认值 - 在 UploadConfig 中添加 ImageProcessing 字段传递服务器配置 - 移除客户端本地缩略图生成功能,改用服务器端处理 - 添加 UploadFaceDataWithProcessing 函数实现带图像处理的上传流程 - 实现 configToImageOptions 函数将服务器配置转换为图像处理选项 - 在 util/image.go 中添加完整的图像处理功能,支持裁切和缩放模式 - 更新依赖添加 golang.org/x/image 用于高质量图像缩放 - 添加 .claude 到 .gitignore 文件
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
dist/
|
||||
.idea/
|
||||
.exe
|
||||
.claude
|
||||
@@ -69,12 +69,64 @@ func ProxyRequest(ctx context.Context, method, path string, body io.Reader, head
|
||||
|
||||
// Upload Proxy Structures
|
||||
|
||||
// CompressionConfig 压缩配置
|
||||
type CompressionConfig struct {
|
||||
Enabled bool `json:"enabled"` // 是否启用压缩
|
||||
Quality int `json:"quality"` // 压缩质量 (1-100)
|
||||
}
|
||||
|
||||
// ThumbnailConfig 缩略图配置
|
||||
type ThumbnailConfig struct {
|
||||
Mode string `json:"mode"` // 缩略图模式: "cut"(裁切) 或 "shrink"(缩放)
|
||||
ShrinkRatio float64 `json:"shrinkRatio"` // 缩放/裁切比例 (0-1)
|
||||
Quality int `json:"quality"` // 缩略图压缩质量 (1-100)
|
||||
}
|
||||
|
||||
// ImageProcessingConfig 图像处理配置
|
||||
type ImageProcessingConfig struct {
|
||||
SourceCompression *CompressionConfig `json:"sourceCompression,omitempty"` // 原图压缩配置
|
||||
Thumbnail *ThumbnailConfig `json:"thumbnail,omitempty"` // 缩略图配置
|
||||
}
|
||||
|
||||
// GetEffectiveConfig 返回有效的图像处理配置(应用默认值)
|
||||
// 默认值:原图不压缩;缩略图裁切模式,比例0.5,质量75
|
||||
func (c *ImageProcessingConfig) GetEffectiveConfig() *ImageProcessingConfig {
|
||||
effective := &ImageProcessingConfig{
|
||||
SourceCompression: &CompressionConfig{
|
||||
Enabled: false, // 默认不压缩原图
|
||||
Quality: 100,
|
||||
},
|
||||
Thumbnail: &ThumbnailConfig{
|
||||
Mode: "cut", // 默认裁切模式
|
||||
ShrinkRatio: 0.5, // 默认比例0.5
|
||||
Quality: 75, // 默认质量75
|
||||
},
|
||||
}
|
||||
|
||||
if c == nil {
|
||||
return effective
|
||||
}
|
||||
|
||||
// 覆盖原图压缩配置
|
||||
if c.SourceCompression != nil {
|
||||
effective.SourceCompression = c.SourceCompression
|
||||
}
|
||||
|
||||
// 覆盖缩略图配置
|
||||
if c.Thumbnail != nil {
|
||||
effective.Thumbnail = c.Thumbnail
|
||||
}
|
||||
|
||||
return effective
|
||||
}
|
||||
|
||||
type UploadConfig struct {
|
||||
TaskID int64 `json:"taskId"`
|
||||
FaceUploadURL string `json:"faceUploadUrl"`
|
||||
ThumbnailUploadURL string `json:"thumbnailUploadUrl"`
|
||||
SourceUploadURL string `json:"sourceUploadUrl"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
TaskID int64 `json:"taskId"`
|
||||
FaceUploadURL string `json:"faceUploadUrl"`
|
||||
ThumbnailUploadURL string `json:"thumbnailUploadUrl"`
|
||||
SourceUploadURL string `json:"sourceUploadUrl"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ImageProcessing *ImageProcessingConfig `json:"imageProcessing,omitempty"` // 图像处理配置
|
||||
}
|
||||
|
||||
type ProxyResponse struct {
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"ZhenTuLocalPassiveAdapter/config"
|
||||
"ZhenTuLocalPassiveAdapter/logger"
|
||||
"ZhenTuLocalPassiveAdapter/util"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
@@ -177,32 +176,21 @@ func HandleUploadFaces(c *gin.Context) {
|
||||
pos.RightBtmY = *face.RightBtmY
|
||||
}
|
||||
|
||||
// 3. Generate Thumbnail (if Source Image and Coordinates exist)
|
||||
var thumbImg []byte
|
||||
if len(srcImg) > 0 && pos.LeftTopX > 0 && pos.LeftTopY > 0 { // Basic validation
|
||||
// Use Source Image to generate thumbnail
|
||||
// The thumbnail is 1/2 original size, centered on face
|
||||
var err error
|
||||
thumbImg, err = util.GenerateThumbnailFromCoords(srcImg, pos.LeftTopX, pos.LeftTopY, pos.RightBtmX, pos.RightBtmY)
|
||||
if err != nil {
|
||||
logger.Error("Failed to generate thumbnail", zap.String("faceId", face.FaceID), zap.Error(err))
|
||||
// Continue without thumbnail? or fail?
|
||||
// Usually continue, but log error.
|
||||
}
|
||||
}
|
||||
// 3. Construct Face Rectangle for image processing
|
||||
faceRect := [4]int{pos.LeftTopX, pos.LeftTopY, pos.RightBtmX, pos.RightBtmY}
|
||||
|
||||
// 4. Execute Upload Flow
|
||||
// 4. Execute Upload Flow with Server-side Image Processing Configuration
|
||||
scenicId := config.Config.Viid.ScenicId
|
||||
|
||||
go func(fID, dID string, fData, tData, sData []byte, p FacePositionInfo) {
|
||||
go func(fID, dID string, fData, sData []byte, rect [4]int, p FacePositionInfo) {
|
||||
// Create a detached context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := UploadFaceData(ctx, scenicId, dID, fData, tData, sData, p); err != nil {
|
||||
if err := UploadFaceDataWithProcessing(ctx, scenicId, dID, fData, sData, rect, p); err != nil {
|
||||
logger.Error("Failed to process face upload", zap.String("faceId", fID), zap.Error(err))
|
||||
}
|
||||
}(face.FaceID, deviceID, faceImg, thumbImg, srcImg, pos)
|
||||
}(face.FaceID, deviceID, faceImg, srcImg, faceRect, pos)
|
||||
}
|
||||
|
||||
// Respond Success
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"ZhenTuLocalPassiveAdapter/logger"
|
||||
"ZhenTuLocalPassiveAdapter/util"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
@@ -9,9 +10,109 @@ import (
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// UploadFaceData implements the coordinated upload flow: Get Config -> Upload -> Notify
|
||||
// UploadFaceDataWithProcessing 带图像处理的上传流程
|
||||
// 根据服务器返回的配置处理图像后上传
|
||||
func UploadFaceDataWithProcessing(ctx context.Context, scenicId int64, deviceNo string, faceImg, srcImg []byte, faceRect [4]int, facePos FacePositionInfo) error {
|
||||
// 1. 获取上传配置(包含图像处理配置)
|
||||
uploadConfig, err := GetUploadConfig(ctx, scenicId, deviceNo)
|
||||
if err != nil {
|
||||
logger.Error("获取上传配置失败", zap.String("deviceNo", deviceNo), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("获取到VIID任务ID",
|
||||
zap.Int64("taskId", uploadConfig.TaskID),
|
||||
zap.Bool("hasImageProcessing", uploadConfig.ImageProcessing != nil))
|
||||
|
||||
// 2. 将服务器配置转换为图像处理选项
|
||||
imgOpts := configToImageOptions(uploadConfig.ImageProcessing)
|
||||
|
||||
// 3. 处理图像
|
||||
processedImages, err := util.ProcessImages(faceImg, srcImg, faceRect, imgOpts)
|
||||
if err != nil {
|
||||
SubmitFailure(ctx, uploadConfig.TaskID, "IMAGE_PROCESSING_FAILED", err.Error())
|
||||
logger.Error("图像处理失败", zap.Int64("taskId", uploadConfig.TaskID), zap.Error(err))
|
||||
return fmt.Errorf("image processing failed: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("图像处理完成",
|
||||
zap.Int64("taskId", uploadConfig.TaskID),
|
||||
zap.Int("faceSize", len(processedImages.FaceData)),
|
||||
zap.Int("thumbSize", len(processedImages.ThumbData)),
|
||||
zap.Int("sourceSize", len(processedImages.SourceData)))
|
||||
|
||||
// 4. 并行上传到OSS
|
||||
g, subCtx := errgroup.WithContext(ctx)
|
||||
|
||||
// 上传人脸图
|
||||
g.Go(func() error {
|
||||
if len(processedImages.FaceData) > 0 {
|
||||
if err := UploadFileToOSS(subCtx, uploadConfig.FaceUploadURL, processedImages.FaceData, "image/jpeg"); err != nil {
|
||||
return fmt.Errorf("upload face image failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// 上传缩略图
|
||||
g.Go(func() error {
|
||||
if len(processedImages.ThumbData) > 0 {
|
||||
if err := UploadFileToOSS(subCtx, uploadConfig.ThumbnailUploadURL, processedImages.ThumbData, "image/jpeg"); err != nil {
|
||||
return fmt.Errorf("upload thumbnail image failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// 上传原图
|
||||
g.Go(func() error {
|
||||
if len(processedImages.SourceData) > 0 {
|
||||
if err := UploadFileToOSS(subCtx, uploadConfig.SourceUploadURL, processedImages.SourceData, "image/jpeg"); err != nil {
|
||||
return fmt.Errorf("upload source image failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
SubmitFailure(ctx, uploadConfig.TaskID, "UPLOAD_FAILED", err.Error())
|
||||
logger.Error("文件上传失败", zap.Int64("taskId", uploadConfig.TaskID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 提交结果
|
||||
if err := SubmitResult(ctx, uploadConfig.TaskID, facePos); err != nil {
|
||||
logger.Error("提交结果失败", zap.Int64("taskId", uploadConfig.TaskID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("VIID任务完成", zap.Int64("taskId", uploadConfig.TaskID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// configToImageOptions 将服务器配置转换为图像处理选项
|
||||
func configToImageOptions(config *ImageProcessingConfig) *util.ImageProcessingOptions {
|
||||
// 应用默认值
|
||||
effectiveConfig := config.GetEffectiveConfig()
|
||||
|
||||
opts := &util.ImageProcessingOptions{
|
||||
// 原图压缩配置
|
||||
SourceCompressionEnabled: effectiveConfig.SourceCompression.Enabled,
|
||||
SourceQuality: effectiveConfig.SourceCompression.Quality,
|
||||
|
||||
// 缩略图配置
|
||||
ThumbnailMode: effectiveConfig.Thumbnail.Mode,
|
||||
ThumbnailShrinkRatio: effectiveConfig.Thumbnail.ShrinkRatio,
|
||||
ThumbnailQuality: effectiveConfig.Thumbnail.Quality,
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
// UploadFaceData 已有的上传流程(向后兼容)
|
||||
// 已废弃:请使用 UploadFaceDataWithProcessing
|
||||
func UploadFaceData(ctx context.Context, scenicId int64, deviceNo string, faceImg, thumbImg, srcImg []byte, facePos FacePositionInfo) error {
|
||||
// 1. Get Upload Config
|
||||
// 1. 获取上传配置
|
||||
uploadConfig, err := GetUploadConfig(ctx, scenicId, deviceNo)
|
||||
if err != nil {
|
||||
logger.Error("获取上传配置失败", zap.String("deviceNo", deviceNo), zap.Error(err))
|
||||
@@ -20,10 +121,10 @@ func UploadFaceData(ctx context.Context, scenicId int64, deviceNo string, faceIm
|
||||
|
||||
logger.Info("获取到VIID任务ID", zap.Int64("taskId", uploadConfig.TaskID))
|
||||
|
||||
// 2. Parallel Upload to OSS
|
||||
// 2. 并行上传到OSS
|
||||
g, subCtx := errgroup.WithContext(ctx)
|
||||
|
||||
// Upload Face Image
|
||||
// 上传人脸图
|
||||
g.Go(func() error {
|
||||
if len(faceImg) > 0 {
|
||||
if err := UploadFileToOSS(subCtx, uploadConfig.FaceUploadURL, faceImg, "image/jpeg"); err != nil {
|
||||
@@ -33,7 +134,7 @@ func UploadFaceData(ctx context.Context, scenicId int64, deviceNo string, faceIm
|
||||
return nil
|
||||
})
|
||||
|
||||
// Upload Thumbnail Image
|
||||
// 上传缩略图
|
||||
g.Go(func() error {
|
||||
if len(thumbImg) > 0 {
|
||||
if err := UploadFileToOSS(subCtx, uploadConfig.ThumbnailUploadURL, thumbImg, "image/jpeg"); err != nil {
|
||||
@@ -43,7 +144,7 @@ func UploadFaceData(ctx context.Context, scenicId int64, deviceNo string, faceIm
|
||||
return nil
|
||||
})
|
||||
|
||||
// Upload Source Image
|
||||
// 上传原图
|
||||
g.Go(func() error {
|
||||
if len(srcImg) > 0 {
|
||||
if err := UploadFileToOSS(subCtx, uploadConfig.SourceUploadURL, srcImg, "image/jpeg"); err != nil {
|
||||
@@ -54,13 +155,12 @@ func UploadFaceData(ctx context.Context, scenicId int64, deviceNo string, faceIm
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
// Report failure
|
||||
SubmitFailure(ctx, uploadConfig.TaskID, "UPLOAD_FAILED", err.Error())
|
||||
logger.Error("文件上传失败", zap.Int64("taskId", uploadConfig.TaskID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Submit Result
|
||||
// 3. 提交结果
|
||||
if err := SubmitResult(ctx, uploadConfig.TaskID, facePos); err != nil {
|
||||
logger.Error("提交结果失败", zap.Int64("taskId", uploadConfig.TaskID), zap.Error(err))
|
||||
return err
|
||||
|
||||
15
go.mod
15
go.mod
@@ -17,7 +17,8 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0
|
||||
go.opentelemetry.io/otel/sdk v1.35.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/sync v0.16.0
|
||||
golang.org/x/image v0.34.0
|
||||
golang.org/x/sync v0.19.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
@@ -73,13 +74,13 @@ require (
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/crypto v0.44.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
google.golang.org/grpc v1.71.0 // indirect
|
||||
|
||||
30
go.sum
30
go.sum
@@ -156,23 +156,25 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
||||
|
||||
198
util/image.go
198
util/image.go
@@ -4,16 +4,111 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
sdraw "image/draw" // 标准库draw,用于裁切
|
||||
"image/jpeg"
|
||||
_ "image/png" // Register PNG decoder just in case
|
||||
|
||||
xdraw "golang.org/x/image/draw" // 扩展库draw,用于高质量缩放
|
||||
)
|
||||
|
||||
// GenerateThumbnail creates a thumbnail by cropping the original image.
|
||||
// The thumbnail size is 1/2 of the original dimensions.
|
||||
// The crop is centered on the provided coordinates (centerX, centerY), constrained to image bounds.
|
||||
func GenerateThumbnail(srcData []byte, centerX, centerY int) ([]byte, error) {
|
||||
// 1. Decode image
|
||||
// ImageProcessingOptions 图像处理选项
|
||||
type ImageProcessingOptions struct {
|
||||
// 原图压缩
|
||||
SourceCompressionEnabled bool
|
||||
SourceQuality int // 1-100,100为不压缩
|
||||
|
||||
// 缩略图配置
|
||||
ThumbnailMode string // "cut"(裁切) 或 "shrink"(缩放)
|
||||
ThumbnailShrinkRatio float64 // 缩放/裁切比例 (0-1)
|
||||
ThumbnailQuality int // 缩略图压缩质量 (1-100)
|
||||
}
|
||||
|
||||
// DefaultImageProcessingOptions 返回默认的图像处理选项
|
||||
// 默认值:原图不压缩;缩略图裁切模式,比例0.5,质量75
|
||||
func DefaultImageProcessingOptions() *ImageProcessingOptions {
|
||||
return &ImageProcessingOptions{
|
||||
SourceCompressionEnabled: false,
|
||||
SourceQuality: 100,
|
||||
ThumbnailMode: "cut",
|
||||
ThumbnailShrinkRatio: 0.5,
|
||||
ThumbnailQuality: 75,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessedImages 处理后的图像结果
|
||||
type ProcessedImages struct {
|
||||
FaceData []byte // 人脸图(不压缩)
|
||||
ThumbData []byte // 缩略图
|
||||
SourceData []byte // 原图(可能压缩)
|
||||
}
|
||||
|
||||
// ProcessImages 根据配置处理所有图像
|
||||
// faceData: 人脸图数据
|
||||
// srcData: 原图数据
|
||||
// faceRect: 人脸区域坐标 (leftTopX, leftTopY, rightBtmX, rightBtmY)
|
||||
// opts: 处理选项(如果为nil,使用默认值)
|
||||
func ProcessImages(faceData, srcData []byte, faceRect [4]int, opts *ImageProcessingOptions) (*ProcessedImages, error) {
|
||||
if opts == nil {
|
||||
opts = DefaultImageProcessingOptions()
|
||||
}
|
||||
|
||||
result := &ProcessedImages{}
|
||||
|
||||
// 1. 人脸图不压缩,直接使用原始数据
|
||||
result.FaceData = faceData
|
||||
|
||||
// 2. 处理原图(根据配置决定是否压缩)
|
||||
if len(srcData) > 0 {
|
||||
var err error
|
||||
if opts.SourceCompressionEnabled && opts.SourceQuality < 100 {
|
||||
result.SourceData, err = CompressImage(srcData, opts.SourceQuality)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compress source image failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
result.SourceData = srcData
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 生成缩略图
|
||||
if len(srcData) > 0 {
|
||||
var err error
|
||||
switch opts.ThumbnailMode {
|
||||
case "shrink":
|
||||
// 整体缩放模式
|
||||
result.ThumbData, err = GenerateThumbnailShrink(srcData, opts.ThumbnailShrinkRatio, opts.ThumbnailQuality)
|
||||
default: // "cut" 或其他默认为裁切模式
|
||||
// 以人脸为中心裁切
|
||||
result.ThumbData, err = GenerateThumbnailCut(srcData, faceRect, opts.ThumbnailShrinkRatio, opts.ThumbnailQuality)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate thumbnail failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CompressImage 压缩图片到指定质量
|
||||
func CompressImage(srcData []byte, quality int) ([]byte, error) {
|
||||
img, _, err := image.Decode(bytes.NewReader(srcData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode image failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode image failed: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GenerateThumbnailShrink 整体缩放模式生成缩略图
|
||||
// ratio: 缩放比例(0-1),如0.5表示缩放到原图50%大小
|
||||
// quality: JPEG压缩质量(1-100)
|
||||
func GenerateThumbnailShrink(srcData []byte, ratio float64, quality int) ([]byte, error) {
|
||||
img, _, err := image.Decode(bytes.NewReader(srcData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode image failed: %v", err)
|
||||
@@ -23,20 +118,65 @@ func GenerateThumbnail(srcData []byte, centerX, centerY int) ([]byte, error) {
|
||||
origW := bounds.Dx()
|
||||
origH := bounds.Dy()
|
||||
|
||||
// 2. Calculate Target Dimensions (1/2 of original)
|
||||
targetW := origW / 2
|
||||
targetH := origH / 2
|
||||
// 计算目标尺寸
|
||||
targetW := int(float64(origW) * ratio)
|
||||
targetH := int(float64(origH) * ratio)
|
||||
|
||||
if targetW == 0 || targetH == 0 {
|
||||
return nil, fmt.Errorf("image too small to generate half-size thumbnail")
|
||||
return nil, fmt.Errorf("image too small to generate thumbnail with ratio %.2f", ratio)
|
||||
}
|
||||
|
||||
// 3. Calculate Crop Origin (Top-Left)
|
||||
// cropX = centerX - targetW / 2
|
||||
// 创建目标图像并使用高质量缩放
|
||||
dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, xdraw.Over, nil)
|
||||
|
||||
// 编码为JPEG
|
||||
var buf bytes.Buffer
|
||||
err = jpeg.Encode(&buf, dst, &jpeg.Options{Quality: quality})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode thumbnail failed: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GenerateThumbnailCut 以人脸为中心裁切模式生成缩略图
|
||||
// faceRect: 人脸区域坐标 [leftTopX, leftTopY, rightBtmX, rightBtmY]
|
||||
// ratio: 裁切比例(0-1),裁切区域为原图尺寸的ratio倍
|
||||
// quality: JPEG压缩质量(1-100)
|
||||
func GenerateThumbnailCut(srcData []byte, faceRect [4]int, ratio float64, quality int) ([]byte, error) {
|
||||
img, _, err := image.Decode(bytes.NewReader(srcData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode image failed: %v", err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
origW := bounds.Dx()
|
||||
origH := bounds.Dy()
|
||||
|
||||
// 计算目标尺寸(原图的ratio倍)
|
||||
targetW := int(float64(origW) * ratio)
|
||||
targetH := int(float64(origH) * ratio)
|
||||
|
||||
if targetW == 0 || targetH == 0 {
|
||||
return nil, fmt.Errorf("image too small to generate thumbnail with ratio %.2f", ratio)
|
||||
}
|
||||
|
||||
// 计算人脸中心
|
||||
centerX := (faceRect[0] + faceRect[2]) / 2
|
||||
centerY := (faceRect[1] + faceRect[3]) / 2
|
||||
|
||||
// 如果人脸坐标无效,使用图片中心
|
||||
if centerX <= 0 || centerY <= 0 {
|
||||
centerX = origW / 2
|
||||
centerY = origH / 2
|
||||
}
|
||||
|
||||
// 计算裁切起点(以人脸中心为中心)
|
||||
cropX := centerX - targetW/2
|
||||
cropY := centerY - targetH/2
|
||||
|
||||
// 4. Constrain to Bounds
|
||||
// 约束到图像边界
|
||||
if cropX < 0 {
|
||||
cropX = 0
|
||||
}
|
||||
@@ -50,7 +190,7 @@ func GenerateThumbnail(srcData []byte, centerX, centerY int) ([]byte, error) {
|
||||
cropY = origH - targetH
|
||||
}
|
||||
|
||||
// Double check after adjustment
|
||||
// 再次检查边界(处理图像尺寸小于目标尺寸的情况)
|
||||
if cropX < 0 {
|
||||
cropX = 0
|
||||
}
|
||||
@@ -58,12 +198,12 @@ func GenerateThumbnail(srcData []byte, centerX, centerY int) ([]byte, error) {
|
||||
cropY = 0
|
||||
}
|
||||
|
||||
// 5. Perform Crop
|
||||
// 执行裁切
|
||||
cropRect := image.Rect(cropX, cropY, cropX+targetW, cropY+targetH)
|
||||
|
||||
var dst image.Image
|
||||
|
||||
// Try to use SubImage if supported for performance
|
||||
// 尝试使用SubImage优化性能
|
||||
type SubImager interface {
|
||||
SubImage(r image.Rectangle) image.Image
|
||||
}
|
||||
@@ -71,15 +211,15 @@ func GenerateThumbnail(srcData []byte, centerX, centerY int) ([]byte, error) {
|
||||
if sub, ok := img.(SubImager); ok {
|
||||
dst = sub.SubImage(cropRect)
|
||||
} else {
|
||||
// Fallback: create new image and draw
|
||||
// 回退方案:创建新图像并绘制
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
|
||||
draw.Draw(rgba, rgba.Bounds(), img, cropRect.Min, draw.Src)
|
||||
sdraw.Draw(rgba, rgba.Bounds(), img, cropRect.Min, sdraw.Src)
|
||||
dst = rgba
|
||||
}
|
||||
|
||||
// 6. Encode to JPEG
|
||||
// 编码为JPEG
|
||||
var buf bytes.Buffer
|
||||
err = jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85})
|
||||
err = jpeg.Encode(&buf, dst, &jpeg.Options{Quality: quality})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode thumbnail failed: %v", err)
|
||||
}
|
||||
@@ -87,10 +227,16 @@ func GenerateThumbnail(srcData []byte, centerX, centerY int) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Helper to process using LeftTop and RightBottom coordinates
|
||||
func GenerateThumbnailFromCoords(srcData []byte, ltX, ltY, rbX, rbY int) ([]byte, error) {
|
||||
// Calculate center based on the provided coordinates
|
||||
centerX := (ltX + rbX) / 2
|
||||
centerY := (ltY + rbY) / 2
|
||||
return GenerateThumbnail(srcData, centerX, centerY)
|
||||
// GenerateThumbnail creates a thumbnail by cropping the original image.
|
||||
// The thumbnail size is 1/2 of the original dimensions.
|
||||
// The crop is centered on the provided coordinates (centerX, centerY), constrained to image bounds.
|
||||
// 已废弃:请使用 GenerateThumbnailCut 或 GenerateThumbnailShrink
|
||||
func GenerateThumbnail(srcData []byte, centerX, centerY int) ([]byte, error) {
|
||||
return GenerateThumbnailCut(srcData, [4]int{centerX, centerY, centerX, centerY}, 0.5, 85)
|
||||
}
|
||||
|
||||
// GenerateThumbnailFromCoords Helper to process using LeftTop and RightBottom coordinates
|
||||
// 已废弃:请使用 ProcessImages
|
||||
func GenerateThumbnailFromCoords(srcData []byte, ltX, ltY, rbX, rbY int) ([]byte, error) {
|
||||
return GenerateThumbnailCut(srcData, [4]int{ltX, ltY, rbX, rbY}, 0.5, 85)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user