feat(api): 添加图像处理配置和服务器端图像处理功能

- 新增 CompressionConfig、ThumbnailConfig 和 ImageProcessingConfig 结构体用于图像处理配置
- 实现 GetEffectiveConfig 方法提供图像处理配置的默认值
- 在 UploadConfig 中添加 ImageProcessing 字段传递服务器配置
- 移除客户端本地缩略图生成功能,改用服务器端处理
- 添加 UploadFaceDataWithProcessing 函数实现带图像处理的上传流程
- 实现 configToImageOptions 函数将服务器配置转换为图像处理选项
- 在 util/image.go 中添加完整的图像处理功能,支持裁切和缩放模式
- 更新依赖添加 golang.org/x/image 用于高质量图像缩放
- 添加 .claude 到 .gitignore 文件
This commit is contained in:
2025-12-30 11:51:17 +08:00
parent 669ae42051
commit 0b42dad969
7 changed files with 368 additions and 78 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
dist/
.idea/
.exe
.claude

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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)
}