From 0b42dad9690570439eaefb764bf0069d0a947992 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 30 Dec 2025 11:51:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=B7=BB=E5=8A=A0=E5=9B=BE?= =?UTF-8?q?=E5=83=8F=E5=A4=84=E7=90=86=E9=85=8D=E7=BD=AE=E5=92=8C=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E7=AB=AF=E5=9B=BE=E5=83=8F=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CompressionConfig、ThumbnailConfig 和 ImageProcessingConfig 结构体用于图像处理配置 - 实现 GetEffectiveConfig 方法提供图像处理配置的默认值 - 在 UploadConfig 中添加 ImageProcessing 字段传递服务器配置 - 移除客户端本地缩略图生成功能,改用服务器端处理 - 添加 UploadFaceDataWithProcessing 函数实现带图像处理的上传流程 - 实现 configToImageOptions 函数将服务器配置转换为图像处理选项 - 在 util/image.go 中添加完整的图像处理功能,支持裁切和缩放模式 - 更新依赖添加 golang.org/x/image 用于高质量图像缩放 - 添加 .claude 到 .gitignore 文件 --- .gitignore | 1 + api/viid_client.go | 62 ++++++++++++-- api/viid_handler.go | 24 ++---- api/viid_upload.go | 116 ++++++++++++++++++++++++-- go.mod | 15 ++-- go.sum | 30 +++---- util/image.go | 198 ++++++++++++++++++++++++++++++++++++++------ 7 files changed, 368 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index c706074..30436e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ .idea/ .exe +.claude \ No newline at end of file diff --git a/api/viid_client.go b/api/viid_client.go index 8aba659..9d20cab 100644 --- a/api/viid_client.go +++ b/api/viid_client.go @@ -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 { diff --git a/api/viid_handler.go b/api/viid_handler.go index 96a7720..5b799fa 100644 --- a/api/viid_handler.go +++ b/api/viid_handler.go @@ -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 diff --git a/api/viid_upload.go b/api/viid_upload.go index 9caecae..16787d1 100644 --- a/api/viid_upload.go +++ b/api/viid_upload.go @@ -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 diff --git a/go.mod b/go.mod index 2abc6d6..1c3183c 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index ac2d260..a51b12f 100644 --- a/go.sum +++ b/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= diff --git a/util/image.go b/util/image.go index a686018..f5045b5 100644 --- a/util/image.go +++ b/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) }