package handler import ( "bytes" "encoding/base64" "encoding/json" "fmt" "image" "image/color" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "git.yeij.top/AskaEth/Cyrene/pkg/logger" "net/http" "os" "sort" "strings" "github.com/gin-gonic/gin" "git.yeij.top/AskaEth/Cyrene/gateway/internal/config" "git.yeij.top/AskaEth/Cyrene/gateway/internal/middleware" "git.yeij.top/AskaEth/Cyrene/gateway/internal/store" ) // ImageHandler 图片分析处理器 type ImageHandler struct { cfg *config.Config fileStore *store.FileStore } // NewImageHandler 创建图片分析处理器 func NewImageHandler(cfg *config.Config, fileStore *store.FileStore) *ImageHandler { return &ImageHandler{ cfg: cfg, fileStore: fileStore, } } // ImageAnalysis 图片分析结果 type ImageAnalysis struct { Format string `json:"format"` Width int `json:"width"` Height int `json:"height"` FileSize int64 `json:"file_size"` Description string `json:"description"` TopColors []ColorInfo `json:"top_colors,omitempty"` EXIF map[string]string `json:"exif,omitempty"` AnalyzedBy string `json:"analyzed_by"` // "openai_vision" | "local" } // ColorInfo 颜色信息 type ColorInfo struct { Hex string `json:"hex"` Percent float64 `json:"percent"` } // AnalyzeRequestBody 分析请求体 type AnalyzeRequestBody struct { FileID string `json:"file_id"` } // ========== POST /api/v1/images/analyze ========== // Analyze 分析上传的图片 (multipart/form-data 或 JSON) func (h *ImageHandler) Analyze(c *gin.Context) { userID := middleware.GetUserID(c) // 尝试 JSON body: {"file_id": "xxx"} contentType := c.GetHeader("Content-Type") if strings.HasPrefix(contentType, "application/json") { var body AnalyzeRequestBody if err := c.ShouldBindJSON(&body); err != nil || body.FileID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 file_id 字段", "errorType": "invalid_request"}) return } h.analyzeByFileID(c, userID, body.FileID) return } // 尝试 multipart/form-data: 直接上传图片分析 file, header, err := c.Request.FormFile("file") if err != nil { // 也尝试 "image" 字段名 file, header, err = c.Request.FormFile("image") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "未找到图片文件 (使用 file 或 image 字段)", "errorType": "missing_file"}) return } } defer file.Close() h.analyzeUploadedFile(c, userID, file, header.Filename, header.Size) } // ========== GET /api/v1/images/analyze/:file_id ========== // AnalyzeByID 对已上传的文件进行分析 func (h *ImageHandler) AnalyzeByID(c *gin.Context) { userID := middleware.GetUserID(c) fileID := c.Param("file_id") if fileID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 file_id", "errorType": "invalid_request"}) return } h.analyzeByFileID(c, userID, fileID) } // analyzeByFileID 根据文件ID分析已存储的图片 func (h *ImageHandler) analyzeByFileID(c *gin.Context, userID, fileID string) { if h.fileStore == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"}) return } f, err := h.fileStore.GetFile(fileID) if err != nil { logger.Printf("[ImageHandler] 查询文件失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"}) return } if f == nil { c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在", "errorType": "file_not_found"}) return } if f.UserID != userID && !f.IsPublic { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"}) return } if !isImageType(f.MimeType) { c.JSON(http.StatusBadRequest, gin.H{"error": "文件不是图片类型: " + f.MimeType, "errorType": "unsupported_type"}) return } result, err := h.analyzeImage(f.StoredPath, f.MimeType, f.Size) if err != nil { logger.Printf("[ImageHandler] 图片分析失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "图片分析失败: " + err.Error(), "errorType": "analysis_error"}) return } c.JSON(http.StatusOK, result) } // analyzeUploadedFile 分析直接上传的图片文件 func (h *ImageHandler) analyzeUploadedFile(c *gin.Context, userID string, file io.Reader, filename string, fileSize int64) { // 检查文件大小 (10MB 限制) const maxImageSize = 10 * 1024 * 1024 if fileSize > maxImageSize { c.JSON(http.StatusBadRequest, gin.H{"error": "图片大小超过限制 (最大 10MB)", "errorType": "file_too_large"}) return } // 读取文件到内存 data, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "errorType": "read_error"}) return } // 检测格式 _, format, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无法解码图片: " + err.Error(), "errorType": "decode_error"}) return } mimeType := "image/" + format supportedFormats := map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, } if !supportedFormats[mimeType] { // 允许所有 image/* 格式,但只对常见格式做深入分析 } // 写入临时文件进行分析 tmpFile, err := os.CreateTemp("", "cyrene-image-*."+format) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败", "errorType": "server_error"}) return } defer os.Remove(tmpFile.Name()) defer tmpFile.Close() if _, err := tmpFile.Write(data); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "写入临时文件失败", "errorType": "server_error"}) return } result, err := h.analyzeImage(tmpFile.Name(), mimeType, int64(len(data))) if err != nil { logger.Printf("[ImageHandler] 图片分析失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "图片分析失败: " + err.Error(), "errorType": "analysis_error"}) return } c.JSON(http.StatusOK, result) } // analyzeImage 核心分析逻辑:先尝试 OpenAI Vision,失败则降级到本地分析 func (h *ImageHandler) analyzeImage(filePath, mimeType string, fileSize int64) (*ImageAnalysis, error) { // 如果配置了 OpenAI API Key,尝试使用 Vision API apiKey := h.cfg.LLMAPIKey if apiKey != "" { result, err := h.analyzeWithOpenAIVision(filePath, mimeType) if err == nil { return result, nil } logger.Printf("[ImageHandler] OpenAI Vision 分析失败,降级到本地分析: %v", err) } // 降级到本地分析 return analyzeImageLocally(filePath, mimeType, fileSize) } // analyzeWithOpenAIVision 使用 OpenAI Vision API 分析图片 func (h *ImageHandler) analyzeWithOpenAIVision(filePath, mimeType string) (*ImageAnalysis, error) { // 读取图片并编码为 base64 data, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("读取图片文件失败: %w", err) } base64Data := base64.StdEncoding.EncodeToString(data) dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data) // 获取本地基本信息 localInfo, err := analyzeImageLocally(filePath, mimeType, int64(len(data))) if err != nil { localInfo = &ImageAnalysis{} } // 构建 OpenAI Vision API 请求 reqBody := map[string]interface{}{ "model": h.cfg.LLMModel, "messages": []map[string]interface{}{ { "role": "user", "content": []map[string]interface{}{ { "type": "text", "text": "请详细描述这张图片的内容。用中文回答。请描述:1) 图片中的主要物体/人物 2) 场景/环境 3) 颜色和色调 4) 文字内容(如果有)5) 整体氛围和风格。请尽可能详细。", }, { "type": "image_url", "image_url": map[string]string{ "url": dataURL, }, }, }, }, }, "max_tokens": 500, } jsonBody, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("序列化请求失败: %w", err) } apiURL := strings.TrimRight(h.cfg.LLMAPIURL, "/") + "/chat/completions" httpReq, err := http.NewRequest("POST", apiURL, bytes.NewReader(jsonBody)) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+h.cfg.LLMAPIKey) httpClient := &http.Client{} resp, err := httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("API 请求失败: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API 返回错误 (%d): %s", resp.StatusCode, string(body)) } var result struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("解析响应失败: %w", err) } var description string if len(result.Choices) > 0 { description = result.Choices[0].Message.Content } return &ImageAnalysis{ Format: localInfo.Format, Width: localInfo.Width, Height: localInfo.Height, FileSize: localInfo.FileSize, Description: description, TopColors: localInfo.TopColors, EXIF: localInfo.EXIF, AnalyzedBy: "openai_vision", }, nil } // analyzeImageLocally 使用 Go 标准库进行本地图片分析 func analyzeImageLocally(filePath, mimeType string, fileSize int64) (*ImageAnalysis, error) { // 1. 读取文件 data, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("读取文件失败: %w", err) } // 2. 解码图片 img, format, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("解码图片失败: %w", err) } // 3. 获取尺寸 bounds := img.Bounds() width := bounds.Dx() height := bounds.Dy() // 4. 计算颜色直方图 (采样像素) topColors := computeColorHistogram(img, 5) // 5. 读取 EXIF (简单实现: 仅 JPEG) exif := extractEXIF(data, format) // 6. 生成描述文本 description := generateLocalDescription(format, width, height, fileSize, topColors) return &ImageAnalysis{ Format: format, Width: width, Height: height, FileSize: fileSize, Description: description, TopColors: topColors, EXIF: exif, AnalyzedBy: "local", }, nil } // computeColorHistogram 计算颜色直方图,返回 top N 颜色 func computeColorHistogram(img image.Image, topN int) []ColorInfo { bounds := img.Bounds() width := bounds.Dx() height := bounds.Dy() // 采样间隔:每 step 个像素采样一个 step := 1 totalPixels := width * height if totalPixels > 10000 { step = (width * height) / 10000 if step < 1 { step = 1 } } colorCount := make(map[string]int) sampledCount := 0 for y := bounds.Min.Y; y < bounds.Max.Y; y += step { for x := bounds.Min.X; x < bounds.Max.X; x += step { r, g, b, _ := img.At(x, y).RGBA() // 量化到 8-bit 并聚类(每 32 级一分组,减少颜色种类) qr := int(r>>8) / 32 qg := int(g>>8) / 32 qb := int(b>>8) / 32 key := fmt.Sprintf("%02d_%02d_%02d", qr, qg, qb) colorCount[key]++ sampledCount++ } } if sampledCount == 0 { return nil } // 排序取 topN type kv struct { key string count int } var sorted []kv for k, v := range colorCount { sorted = append(sorted, kv{k, v}) } sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count }) result := make([]ColorInfo, 0, topN) for i := 0; i < topN && i < len(sorted); i++ { var qr, qg, qb int fmt.Sscanf(sorted[i].key, "%d_%d_%d", &qr, &qg, &qb) // 量化组的中间值 r := qr*32 + 16 g := qg*32 + 16 b := qb*32 + 16 hex := fmt.Sprintf("#%02X%02X%02X", r, g, b) pct := float64(sorted[i].count) / float64(sampledCount) * 100 result = append(result, ColorInfo{ Hex: hex, Percent: pct, }) } return result } // extractEXIF 简单提取 JPEG EXIF 信息 func extractEXIF(data []byte, format string) map[string]string { if format != "jpeg" { return nil } exif := make(map[string]string) // 查找 EXIF 标记 (0xFFE1) for i := 0; i < len(data)-4; i++ { if data[i] == 0xFF && data[i+1] == 0xE1 { if i+10 >= len(data) { break } // 验证 EXIF 标识 "Exif\0\0" if string(data[i+4:i+10]) != "Exif\x00\x00" { continue } exifStart := i + 10 if exifStart+8 >= len(data) { break } // 判断字节序 var bigEndian bool if data[exifStart] == 'M' && data[exifStart+1] == 'M' { bigEndian = true } else if data[exifStart] == 'I' && data[exifStart+1] == 'I' { bigEndian = false } else { break } // 读取 IFD0 tiffStart := exifStart readUint16 := func(offset int) uint16 { if offset+2 > len(data) { return 0 } if bigEndian { return uint16(data[offset])<<8 | uint16(data[offset+1]) } return uint16(data[offset+1])<<8 | uint16(data[offset]) } ifd0Offset := int(readUint16(tiffStart + 4)) if ifd0Offset < 8 { break } ifd0Addr := tiffStart + ifd0Offset if ifd0Addr+2 >= len(data) { break } numEntries := int(readUint16(ifd0Addr)) entryAddr := ifd0Addr + 2 // 常见 EXIF 标签 tagNames := map[uint16]string{ 0x010F: "Make", 0x0110: "Model", 0x0112: "Orientation", 0x0132: "DateTime", 0x829A: "ExposureTime", 0x829D: "FNumber", 0x8827: "ISO", 0x9003: "DateTimeOriginal", 0x920A: "FocalLength", } for j := 0; j < numEntries && entryAddr+12 <= len(data); j++ { tag := readUint16(entryAddr) dataType := readUint16(entryAddr + 2) dataCount := int(readUint16(entryAddr + 4)) entryAddr += 12 if name, ok := tagNames[tag]; ok { valueLen := dataCount switch dataType { case 2: // ASCII valueLen = dataCount case 3, 4: // SHORT, LONG valueLen = dataCount * 2 case 5: // RATIONAL valueLen = dataCount * 8 } if valueLen <= 4 { // 值在 tag 自身中 valData := data[entryAddr-4 : entryAddr] valStr := extractASCIIValue(valData, dataType, dataCount, bigEndian) if valStr != "" { exif[name] = valStr } } } } break // 只处理第一个 EXIF 块 } } if len(exif) == 0 { return nil } return exif } // extractASCIIValue 从 EXIF 数据中提取 ASCII 值 func extractASCIIValue(data []byte, dataType uint16, count int, bigEndian bool) string { switch dataType { case 2: // ASCII string s := string(data) if idx := strings.IndexByte(s, 0); idx >= 0 { s = s[:idx] } return s case 3: // SHORT if len(data) >= 2 { var val uint16 if bigEndian { val = uint16(data[0])<<8 | uint16(data[1]) } else { val = uint16(data[1])<<8 | uint16(data[0]) } return fmt.Sprintf("%d", val) } case 5: // RATIONAL // 简化处理:返回原始字节 return "" } return "" } // generateLocalDescription 生成本地图片描述文本 func generateLocalDescription(format string, width, height int, fileSize int64, topColors []ColorInfo) string { var sb strings.Builder formatNames := map[string]string{ "jpeg": "JPEG", "jpg": "JPEG", "png": "PNG", "gif": "GIF", "webp": "WebP", "bmp": "BMP", } formatName := strings.ToUpper(format) if name, ok := formatNames[strings.ToLower(format)]; ok { formatName = name } sb.WriteString(fmt.Sprintf("这是一张 %s 格式的图片,", formatName)) sb.WriteString(fmt.Sprintf("分辨率为 %d×%d 像素,", width, height)) sb.WriteString(fmt.Sprintf("文件大小为 %s。", formatFileSize(fileSize))) // 判断大致比例 ratio := float64(width) / float64(height) if ratio > 1.8 { sb.WriteString("图片呈宽幅横幅比例。") } else if ratio < 0.6 { sb.WriteString("图片呈竖幅比例。") } else if ratio > 1.2 { sb.WriteString("图片接近横向画幅。") } else if ratio < 0.8 { sb.WriteString("图片接近纵向画幅。") } else { sb.WriteString("图片接近正方形比例。") } // 描述主要颜色 if len(topColors) > 0 { sb.WriteString(" 主要色调为") for i, c := range topColors { if i > 0 { if i == len(topColors)-1 { sb.WriteString(" 和 ") } else { sb.WriteString("、") } } colorName := getColorName(c.Hex) sb.WriteString(fmt.Sprintf("%s(%s, %.0f%%)", colorName, c.Hex, c.Percent)) } sb.WriteString("。") } return sb.String() } // formatFileSize 格式化文件大小 func formatFileSize(size int64) string { if size < 1024 { return fmt.Sprintf("%d B", size) } if size < 1024*1024 { return fmt.Sprintf("%.1f KB", float64(size)/1024) } return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024)) } // getColorName 根据 hex 颜色获取中文颜色名 func getColorName(hex string) string { if len(hex) < 7 { return hex } var r, g, b uint8 fmt.Sscanf(hex, "#%02X%02X%02X", &r, &g, &b) // 灰度判断 if absDiff(r, g) < 20 && absDiff(g, b) < 20 && absDiff(r, b) < 20 { if r < 40 { return "黑色" } if r < 100 { return "深灰色" } if r < 180 { return "灰色" } if r < 230 { return "浅灰色" } return "白色" } // HSL 近似判断色调 maxC := max(r, max(g, b)) minC := min(r, min(g, b)) delta := maxC - minC if delta < 30 { if maxC < 60 { return "暗色" } if maxC > 200 { return "浅色" } return "中性色" } var hue string switch { case r == maxC: if g >= b { hue = "红色" } else { hue = "品红色" } case g == maxC: if b >= r { hue = "绿色" } else { hue = "黄绿色" } default: if r >= g { hue = "紫红色" } else { hue = "蓝色" } } // 亮度修饰 if maxC < 80 { hue = "深" + hue } else if minC > 200 { hue = "浅" + hue } return hue } func absDiff(a, b uint8) int { if a > b { return int(a - b) } return int(b - a) } func max(a, b uint8) uint8 { if a > b { return a } return b } func min(a, b uint8) uint8 { if a < b { return a } return b } // ========== color.RGBA → string 辅助 ========== var _ = color.RGBA{} // 确保 color 包被使用