From 747ab253259e5d2e3a468967c3092616b62f5eb2 Mon Sep 17 00:00:00 2001 From: zzw <1464003642@qq.com> Date: Tue, 27 Oct 2026 17:17:43 +0800 Subject: [PATCH] feat: add facepp --- pkg/facepp/annotate.go | 86 ++++++ pkg/facepp/boxes.go | 49 ++++ pkg/facepp/client.go | 104 ++++++++ pkg/facepp/skinanalyze.go | 165 ++++++++++++ pkg/facepp/types.go | 127 +++++++++ pkg/facepp/yolo.go | 127 +++++++++ services/analysis/internal/callback/facepp.go | 249 ++++++++++++++++++ .../analysis/internal/callback/facepp_file.go | 168 ++++++++++++ .../analysis/internal/callback/process.go | 4 +- services/analysis/internal/dao/facepp.go | 20 +- services/user/internal/dao/session.go | 6 + services/user/internal/logic/infoLogic.go | 33 +++ 12 files changed, 1133 insertions(+), 5 deletions(-) create mode 100644 pkg/facepp/annotate.go create mode 100644 pkg/facepp/boxes.go create mode 100644 pkg/facepp/client.go create mode 100644 pkg/facepp/skinanalyze.go create mode 100644 pkg/facepp/types.go create mode 100644 pkg/facepp/yolo.go create mode 100644 services/analysis/internal/callback/facepp.go create mode 100644 services/analysis/internal/callback/facepp_file.go diff --git a/pkg/facepp/annotate.go b/pkg/facepp/annotate.go new file mode 100644 index 0000000..5e47ad9 --- /dev/null +++ b/pkg/facepp/annotate.go @@ -0,0 +1,86 @@ +package facepp + +import ( + "image" + "image/color" + "image/draw" + "image/jpeg" + "os" + + "github.com/disintegration/imaging" +) + +func DrawBoxesOnFile(srcPath, dstPath string, boxes []PixelBox, stroke color.RGBA, thickness int) (int, int, error) { + img, err := imaging.Open(srcPath, imaging.AutoOrientation(true)) + if err != nil { + return 0, 0, err + } + if thickness <= 0 { + thickness = 3 + } + + bounds := img.Bounds() + dst := image.NewNRGBA(bounds) + draw.Draw(dst, bounds, img, bounds.Min, draw.Src) + + for _, box := range boxes { + drawRectBorder(dst, box, stroke, thickness) + } + + if err = saveJPEG(dst, dstPath); err != nil { + return 0, 0, err + } + return bounds.Dx(), bounds.Dy(), nil +} + +func drawRectBorder(img *image.NRGBA, box PixelBox, c color.RGBA, thickness int) { + x0 := int(box.Left) + y0 := int(box.Top) + x1 := int(box.Left + box.Width) + y1 := int(box.Top + box.Height) + + b := img.Bounds() + if x0 < b.Min.X { + x0 = b.Min.X + } + if y0 < b.Min.Y { + y0 = b.Min.Y + } + if x1 > b.Max.X { + x1 = b.Max.X + } + if y1 > b.Max.Y { + y1 = b.Max.Y + } + if x1 <= x0 || y1 <= y0 { + return + } + + for t := 0; t < thickness; t++ { + for x := x0; x < x1; x++ { + setNRGBA(img, x, y0+t, c) + setNRGBA(img, x, y1-1-t, c) + } + for y := y0; y < y1; y++ { + setNRGBA(img, x0+t, y, c) + setNRGBA(img, x1-1-t, y, c) + } + } +} + +func setNRGBA(img *image.NRGBA, x, y int, c color.RGBA) { + b := img.Bounds() + if x < b.Min.X || x >= b.Max.X || y < b.Min.Y || y >= b.Max.Y { + return + } + img.SetNRGBA(x, y, color.NRGBA{R: c.R, G: c.G, B: c.B, A: 255}) +} + +func saveJPEG(img image.Image, path string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return jpeg.Encode(f, img, &jpeg.Options{Quality: 92}) +} diff --git a/pkg/facepp/boxes.go b/pkg/facepp/boxes.go new file mode 100644 index 0000000..e7d2aa2 --- /dev/null +++ b/pkg/facepp/boxes.go @@ -0,0 +1,49 @@ +package facepp + +import ( + "encoding/json" +) + +type PixelBox struct { + Left float64 `json:"left"` + Top float64 `json:"top"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +var MapTypeKeys = map[string][]string{ + "acne": {"acne", "acne_pustule", "acne_nodule", "acne_mark"}, + "closed_comedones": {"closed_comedones"}, + "brown_spot": {"brown_spot"}, + "mole": {"mole"}, +} + +func ExtractPixelBoxes(result json.RawMessage, keys ...string) ([]PixelBox, error) { + if len(result) == 0 || string(result) == "null" { + return nil, nil + } + var root map[string]json.RawMessage + if err := json.Unmarshal(result, &root); err != nil { + return nil, err + } + + var out []PixelBox + for _, key := range keys { + raw, ok := root[key] + if !ok { + continue + } + for _, rect := range locatedRectangles(raw) { + if rect.Width <= 0 || rect.Height <= 0 { + continue + } + out = append(out, PixelBox{ + Left: rect.Left, + Top: rect.Top, + Width: rect.Width, + Height: rect.Height, + }) + } + } + return out, nil +} diff --git a/pkg/facepp/client.go b/pkg/facepp/client.go new file mode 100644 index 0000000..501b9db --- /dev/null +++ b/pkg/facepp/client.go @@ -0,0 +1,104 @@ +package facepp + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + DefaultEndpoint = "https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro" + defaultTimeout = 60 * time.Second + + MapRedArea = "red_area" + MapBrownArea = "brown_area" + MapTextureEnhancedPores = "texture_enhanced_pores" + MapTextureEnhancedLines = "texture_enhanced_lines" + + DefaultReturnMaps = "red_area,brown_area,texture_enhanced_pores,texture_enhanced_lines" + DefaultReturnMarks = "wrinkle_mark,right_nasolabial_list,right_mouth_list,right_eye_wrinkle_list,right_crowsfeet_list,right_cheek_list,left_nasolabial_list,left_mouth_list,left_eye_wrinkle_list,left_crowsfeet_list,left_cheek_list,glabella_wrinkle_list,forehead_wrinkle_list,dark_circle_outline,sensitivity_mark,melanin_mark,cheekbone_mark,blackheads_mark,pores_mark" +) + +type Client struct { + apiKey string + apiSecret string + endpoint string + httpClient *http.Client +} + +func NewClient(apiKey, apiSecret string) *Client { + return &Client{ + apiKey: apiKey, + apiSecret: apiSecret, + endpoint: DefaultEndpoint, + httpClient: &http.Client{ + Timeout: defaultTimeout, + }, + } +} + +func (c *Client) WithEndpoint(endpoint string) *Client { + if endpoint != "" { + c.endpoint = endpoint + } + return c +} + +func (c *Client) WithHTTPClient(httpClient *http.Client) *Client { + if httpClient != nil { + c.httpClient = httpClient + } + return c +} + +func (c *Client) postForm(values url.Values) ([]byte, error) { + if c.apiKey == "" || c.apiSecret == "" { + return nil, errors.New("facepp: api_key or api_secret is empty") + } + + values.Set("api_key", c.apiKey) + values.Set("api_secret", c.apiSecret) + + req, err := http.NewRequest(http.MethodPost, c.endpoint, strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if err := checkAPIError(body); err != nil { + return body, err + } + + if resp.StatusCode != http.StatusOK { + return body, fmt.Errorf("facepp: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + return body, nil +} + +func checkAPIError(body []byte) error { + var apiErr APIError + if err := json.Unmarshal(body, &apiErr); err != nil { + return nil + } + if apiErr.ErrorMessage == "" { + return nil + } + return &apiErr +} diff --git a/pkg/facepp/skinanalyze.go b/pkg/facepp/skinanalyze.go new file mode 100644 index 0000000..d7a77e2 --- /dev/null +++ b/pkg/facepp/skinanalyze.go @@ -0,0 +1,165 @@ +package facepp + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +func (c *Client) SkinAnalyze(opts SkinAnalyzeOptions) (*SkinAnalyzeResponse, error) { + if opts.ImageFile != "" { + return c.skinAnalyzeFile(opts) + } + + values := url.Values{} + switch { + case opts.ImageBase64 != "": + values.Set("image_base64", opts.ImageBase64) + case opts.ImageURL != "": + values.Set("image_url", opts.ImageURL) + default: + return nil, errors.New("facepp: image_url, image_file or image_base64 is required") + } + + applyOptionalParams(values, opts) + + return decodeSkinAnalyzeResult(c.postForm(values)) +} + +func (c *Client) SkinAnalyzeFromFile(path string, opts ...SkinAnalyzeOptions) (*SkinAnalyzeResponse, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + req := SkinAnalyzeOptions{} + if len(opts) > 0 { + req = opts[0] + } + req.ImageBase64 = base64.StdEncoding.EncodeToString(raw) + req.ImageFile = "" + req.ImageURL = "" + + return c.SkinAnalyze(req) +} + +func (c *Client) skinAnalyzeFile(opts SkinAnalyzeOptions) (*SkinAnalyzeResponse, error) { + if c.apiKey == "" || c.apiSecret == "" { + return nil, errors.New("facepp: api_key or api_secret is empty") + } + + file, err := os.Open(opts.ImageFile) + if err != nil { + return nil, err + } + defer file.Close() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + if err := writer.WriteField("api_key", c.apiKey); err != nil { + return nil, err + } + if err := writer.WriteField("api_secret", c.apiSecret); err != nil { + return nil, err + } + if err := writeOptionalFields(writer, opts); err != nil { + return nil, err + } + + part, err := writer.CreateFormFile("image_file", filepath.Base(opts.ImageFile)) + if err != nil { + return nil, err + } + if _, err := io.Copy(part, file); err != nil { + return nil, err + } + if err := writer.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, c.endpoint, &buf) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if err := checkAPIError(body); err != nil { + return decodeSkinAnalyzeResult(body, err) + } + + if resp.StatusCode != http.StatusOK { + return decodeSkinAnalyzeResult(body, fmt.Errorf("facepp: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))) + } + + return decodeSkinAnalyzeResult(body, nil) +} + +func applyOptionalParams(values url.Values, opts SkinAnalyzeOptions) { + if opts.ReturnMaps != "" { + values.Set("return_maps", opts.ReturnMaps) + } + if opts.ReturnMarks != "" { + values.Set("return_marks", opts.ReturnMarks) + } +} + +func writeOptionalFields(writer *multipart.Writer, opts SkinAnalyzeOptions) error { + if opts.ReturnMaps != "" { + if err := writer.WriteField("return_maps", opts.ReturnMaps); err != nil { + return err + } + } + if opts.ReturnMarks != "" { + return writer.WriteField("return_marks", opts.ReturnMarks) + } + return nil +} + +func decodeSkinAnalyzeResult(body []byte, reqErr error) (*SkinAnalyzeResponse, error) { + if len(body) == 0 { + return nil, reqErr + } + + resp, err := decodeSkinAnalyze(body) + if err != nil { + if reqErr != nil { + return nil, reqErr + } + return nil, err + } + + if reqErr != nil { + return resp, reqErr + } + + return resp, nil +} + +func decodeSkinAnalyze(body []byte) (*SkinAnalyzeResponse, error) { + var result SkinAnalyzeResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/pkg/facepp/types.go b/pkg/facepp/types.go new file mode 100644 index 0000000..e585a3a --- /dev/null +++ b/pkg/facepp/types.go @@ -0,0 +1,127 @@ +package facepp + +import ( + "encoding/json" + "fmt" +) + +// SkinAnalyzeOptions 专业版请求参数。 +type SkinAnalyzeOptions struct { + ImageURL string + ImageBase64 string + ImageFile string + ReturnMaps string + ReturnMarks string +} + +type APIError struct { + TimeUsed int `json:"time_used"` + RequestID string `json:"request_id"` + ErrorMessage string `json:"error_message"` +} + +func (e *APIError) Error() string { + if e.RequestID == "" { + return fmt.Sprintf("facepp: %s", e.ErrorMessage) + } + return fmt.Sprintf("facepp: %s (request_id=%s)", e.ErrorMessage, e.RequestID) +} + +type SkinAnalyzeResponse struct { + RequestID string `json:"request_id"` + TimeUsed int `json:"time_used"` + ErrorMessage string `json:"error_message"` + ImageID string `json:"image_id"` + FaceRectangle FaceRectangle `json:"face_rectangle"` + Result json.RawMessage `json:"result"` + LeftSideResult json.RawMessage `json:"left_side_result"` + RightSideResult json.RawMessage `json:"right_side_result"` + FaceMaps FaceMaps `json:"face_maps,omitempty"` + Warning []string `json:"warning,omitempty"` +} + +type FaceRectangle struct { + Top int `json:"top"` + Left int `json:"left"` + Width int `json:"width"` + Height int `json:"height"` +} + +type FlagResult struct { + Value int `json:"value"` + Confidence float64 `json:"confidence"` +} + +type LocatedResult struct { + Value int `json:"value"` + Confidence float64 `json:"confidence"` + Rect []Rect `json:"rect,omitempty"` +} + +type Rect struct { + Top int `json:"top"` + Left int `json:"left"` + Width int `json:"width"` + Height int `json:"height"` +} + +type SkinTypeResult struct { + SkinType int `json:"skin_type"` + Details map[string]FlagResult `json:"details"` +} + +type DarkCircleResult struct { + Value int `json:"value"` + Confidence float64 `json:"confidence"` + DarkCircleType int `json:"dark_circle_type"` +} + +type SkinAgeResult struct { + Value int `json:"value"` +} + +type FineLineRegion struct { + Count int `json:"count"` + Area float64 `json:"area"` +} + +type FineLineResult struct { + Value int `json:"value"` + Forehead FineLineRegion `json:"forehead"` + UnderEye FineLineRegion `json:"under_eye"` +} + +type FaceMaps struct { + RedArea string `json:"red_area,omitempty"` + BrownArea string `json:"brown_area,omitempty"` + TextureEnhancedPores string `json:"texture_enhanced_pores,omitempty"` + TextureEnhancedLines string `json:"texture_enhanced_lines,omitempty"` +} + +type AnalyzeResult struct { + SkinType SkinTypeResult `json:"skin_type"` + OilyIntensity FlagResult `json:"oily_intensity"` + SkinColor FlagResult `json:"skin_color"` + SkinTone FlagResult `json:"skin_tone"` + Sensitivity FlagResult `json:"sensitivity"` + ClosedComedones LocatedResult `json:"closed_comedones"` + Blackhead FlagResult `json:"blackhead"` + BlackheadCount int `json:"blackhead_count"` + Mole LocatedResult `json:"mole"` + SkinSpot LocatedResult `json:"skin_spot"` + Acne LocatedResult `json:"acne"` + DarkCircle DarkCircleResult `json:"dark_circle"` + EyePouch FlagResult `json:"eye_pouch"` + ForeheadWrinkle FlagResult `json:"forehead_wrinkle"` + CrowsFeet FlagResult `json:"crows_feet"` + EyeFinelines FlagResult `json:"eye_finelines"` + GlabellaWrinkle FlagResult `json:"glabella_wrinkle"` + NasolabialFold FlagResult `json:"nasolabial_fold"` + PoresForehead FlagResult `json:"pores_forehead"` + PoresLeftCheek FlagResult `json:"pores_left_cheek"` + PoresRightCheek FlagResult `json:"pores_right_cheek"` + PoresJaw FlagResult `json:"pores_jaw"` + SkinAge SkinAgeResult `json:"skin_age"` + FineLine FineLineResult `json:"fine_line"` + Melanin FlagResult `json:"melanin"` +} diff --git a/pkg/facepp/yolo.go b/pkg/facepp/yolo.go new file mode 100644 index 0000000..c7e4a83 --- /dev/null +++ b/pkg/facepp/yolo.go @@ -0,0 +1,127 @@ +package facepp + +import ( + "encoding/json" + "fmt" + "strings" +) + +const ( + YOLOClassAcne = 0 + YOLOClassMole = 1 + YOLOClassBrownSpot = 2 +) + +type yoloField struct { + Key string + ClassID int +} + +var yoloRectangleFields = []yoloField{ + {Key: "acne_pustule", ClassID: YOLOClassAcne}, + {Key: "closed_comedones", ClassID: YOLOClassAcne}, + {Key: "acne_mark", ClassID: YOLOClassAcne}, + {Key: "acne", ClassID: YOLOClassAcne}, + {Key: "acne_nodule", ClassID: YOLOClassAcne}, + {Key: "mole", ClassID: YOLOClassMole}, + {Key: "brown_spot", ClassID: YOLOClassBrownSpot}, +} + +type yoloBox struct { + ClassID int + XC float64 + YC float64 + W float64 + H float64 +} + +type yoloRect struct { + Top float64 `json:"top"` + Left float64 `json:"left"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +type yoloLocated struct { + Rectangle []yoloRect `json:"rectangle"` + Rect []yoloRect `json:"rect"` +} + +func ResultToYOLO(result json.RawMessage, imgW, imgH int) (string, error) { + if len(result) == 0 || string(result) == "null" { + return "", nil + } + if imgW <= 0 || imgH <= 0 { + return "", fmt.Errorf("facepp: invalid image size %dx%d", imgW, imgH) + } + + var root map[string]json.RawMessage + if err := json.Unmarshal(result, &root); err != nil { + return "", err + } + + imgWf := float64(imgW) + imgHf := float64(imgH) + var boxes []yoloBox + for _, field := range yoloRectangleFields { + raw, ok := root[field.Key] + if !ok { + continue + } + for _, rect := range locatedRectangles(raw) { + box, ok := pixelToYOLO(field.ClassID, rect.Left, rect.Top, rect.Width, rect.Height, imgWf, imgHf) + if !ok { + continue + } + boxes = append(boxes, box) + } + } + + var b strings.Builder + for _, box := range boxes { + fmt.Fprintf(&b, "%d %.6f %.6f %.6f %.6f\n", box.ClassID, box.XC, box.YC, box.W, box.H) + } + return b.String(), nil +} + +func locatedRectangles(raw json.RawMessage) []yoloRect { + var obj yoloLocated + if err := json.Unmarshal(raw, &obj); err != nil { + return nil + } + if len(obj.Rectangle) > 0 { + return obj.Rectangle + } + return obj.Rect +} + +func pixelToYOLO(classID int, xMin, yMin, boxW, boxH, imgW, imgH float64) (yoloBox, bool) { + xMax := xMin + boxW + yMax := yMin + boxH + if xMin < 0 { + xMin = 0 + } + if yMin < 0 { + yMin = 0 + } + if xMax > imgW { + xMax = imgW + } + if yMax > imgH { + yMax = imgH + } + + boxW = xMax - xMin + boxH = yMax - yMin + if boxW <= 0 || boxH <= 0 { + return yoloBox{}, false + } + + return yoloBox{ + ClassID: classID, + XC: (xMin + boxW/2) / imgW, + YC: (yMin + boxH/2) / imgH, + W: boxW / imgW, + H: boxH / imgH, + }, true +} diff --git a/services/analysis/internal/callback/facepp.go b/services/analysis/internal/callback/facepp.go new file mode 100644 index 0000000..65dba90 --- /dev/null +++ b/services/analysis/internal/callback/facepp.go @@ -0,0 +1,249 @@ +package callback + +import ( + "encoding/json" + "errors" + "fmt" + "image/color" + "math/rand" + "os" + "path/filepath" + "strings" + "time" + + "lone-services/pkg/facepp" + "lone-services/pkg/modelbase" + pkgoss "lone-services/pkg/oss" + "lone-services/pkg/utils" + "lone-services/services/analysis/internal/dao" + "lone-services/services/analysis/internal/model" + + "github.com/zeromicro/go-zero/core/logx" +) + +var faceppMapStroke = map[string]color.RGBA{ + dao.FaceppMapTypeAcne: {R: 0xFE, G: 0x01, B: 0x00, A: 0xFF}, + dao.FaceppMapTypeClosedComedones: {R: 0x00, G: 0xFF, B: 0x00, A: 0xFF}, + dao.FaceppMapTypeBrownSpot: {R: 0x7E, G: 0x2A, B: 0x28, A: 0xFF}, + dao.FaceppMapTypeMole: {R: 0x00, G: 0x66, B: 0xFF, A: 0xFF}, +} + +func RunFacepp(taskID, finalDir string) { + defer func() { + if r := recover(); r != nil { + logx.Errorf("facepp panic task_id=%s: %v", taskID, r) + } + }() + + originalPath := filepath.Join(finalDir, "cross_mid.jpg") + if _, err := os.Stat(originalPath); err != nil { + if os.IsNotExist(err) { + logx.Infof("skip facepp: cross_mid.jpg not found in %s", finalDir) + return + } + logx.Errorf("stat cross_mid.jpg failed: %v", err) + return + } + + expandPath, err := expandImageToTemp(originalPath, defaultExpandRatio) + if err != nil { + logx.Errorf("expand image failed: %v", err) + return + } + defer os.Remove(expandPath) + + trainPath, err := saveFaceppTrainImage(expandPath) + if err != nil { + logx.Errorf("save facepp train image failed: %v", err) + return + } + + client := facepp.NewClient( + utils.GetConfigString("facepp.api_key"), + utils.GetConfigString("facepp.api_secret"), + ) + resp, analyzeErr := client.SkinAnalyzeFromFile(expandPath, facepp.SkinAnalyzeOptions{ + ReturnMarks: facepp.DefaultReturnMarks, + }) + + resultPath := utils.StringEmpty + resultWeb := utils.StringEmpty + leftWeb := utils.StringEmpty + rightWeb := utils.StringEmpty + + if analyzeErr != nil { + logx.Errorf("facepp skin analyze failed: %v", analyzeErr) + } else if resp != nil { + var annErr error + resultPath, resultWeb, annErr = saveFaceppTrainAnnotations(trainPath, resp.Result) + if annErr != nil { + logx.Errorf("save facepp train annotations failed: %v", annErr) + } + + base := strings.TrimSuffix(filepath.Base(trainPath), filepath.Ext(trainPath)) + _, leftWeb, err = saveFaceppJSON(base, "_left", resp.LeftSideResult) + if err != nil { + logx.Errorf("save facepp left json failed: %v", err) + } + _, rightWeb, err = saveFaceppJSON(base, "_right", resp.RightSideResult) + if err != nil { + logx.Errorf("save facepp right json failed: %v", err) + } + + if mapErr := saveFaceppMaps(taskID, expandPath, resp.Result); mapErr != nil { + logx.Errorf("save facepp maps failed: %v", mapErr) + } + } + + if err = saveFaceppRecord(taskID, originalPath, trainPath, resultPath, resultWeb, leftWeb, rightWeb, resp, analyzeErr); err != nil { + logx.Errorf("save facepp record failed: %v", err) + } +} + +func saveFaceppRecord(taskID, originalPath, expandPath, resultPath, resultWeb, leftWeb, rightWeb string, resp *facepp.SkinAnalyzeResponse, analyzeErr error) error { + ossResultPath := utils.StringEmpty + if resultPath != utils.StringEmpty { + uploaded, err := uploadJSONToOSS(resultPath) + if err != nil { + logx.Errorf("upload facepp json to oss failed: %v", err) + } else { + ossResultPath = uploaded + } + } + + record := dao.FaceppAnalysis{ + TaskId: taskID, + OriginalPath: originalPath, + ExpandPath: expandPath, + Result: resultWeb, + LeftSideResult: leftWeb, + RightSideResult: rightWeb, + ResultPath: resultPath, + ResultOssPath: ossResultPath, + CreatedAt: utils.Now(), + } + if resp != nil { + record.RequestId = resp.RequestID + record.FaceTop = resp.FaceRectangle.Top + record.FaceLeft = resp.FaceRectangle.Left + record.FaceWidth = resp.FaceRectangle.Width + record.FaceHeight = resp.FaceRectangle.Height + record.TimeUsed = resp.TimeUsed + record.ErrorMessage = resp.ErrorMessage + } + if record.ErrorMessage == utils.StringEmpty && analyzeErr != nil { + record.ErrorMessage = analyzeErr.Error() + } + + m := model.FaceppAnalysisModel{}.Init() + return m.Create(&record) +} + +func uploadJSONToOSS(localPath string) (string, error) { + data, err := os.ReadFile(localPath) + if err != nil { + return utils.StringEmpty, err + } + fileDir := utils.GetConfigString("oss.fileDir") + fileName := fmt.Sprintf("%d%d.json", time.Now().UnixNano(), rand.Intn(10000)) + res, err := pkgoss.Upload(pkgoss.UploadInput{ + Filename: fileName, + Content: data, + ContentType: "application/json", + Dir: fileDir, + }) + if err != nil { + return utils.StringEmpty, err + } + return res.URL, nil +} + +func saveFaceppMaps(taskID, imagePath string, result json.RawMessage) error { + if imagePath == utils.StringEmpty { + return errors.New("facepp map image path empty") + } + now := utils.Now() + for _, meta := range dao.FaceppMapTypeMeta { + keys := facepp.MapTypeKeys[meta.Type] + pixelBoxes, err := facepp.ExtractPixelBoxes(result, keys...) + if err != nil { + return fmt.Errorf("extract %s boxes: %w", meta.Type, err) + } + + tmpFile, err := os.CreateTemp("", "facepp-map-*.jpg") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + _ = tmpFile.Close() + + stroke := faceppMapStroke[meta.Type] + imgW, imgH, err := facepp.DrawBoxesOnFile(imagePath, tmpPath, pixelBoxes, stroke, 3) + if err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("draw %s: %w", meta.Type, err) + } + + ossURL, uploadErr := uploadFaceppImageToOSS(tmpPath) + _ = os.Remove(tmpPath) + if uploadErr != nil { + return fmt.Errorf("upload %s: %w", meta.Type, uploadErr) + } + + if err = upsertFaceppMapImage(dao.FaceppMap{ + TaskId: taskID, + Type: meta.Type, + Path: ossURL, + ImageWidth: imgW, + ImageHeight: imgH, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return err + } + } + return nil +} + +func upsertFaceppMapImage(record dao.FaceppMap) error { + m := model.FaceppMapModel{}.Init() + var existing dao.FaceppMap + w := modelbase.Params{ + Eq: map[string]string{ + "task_id": record.TaskId, + "type": record.Type, + }, + } + if err := m.GetOne(w, &existing); err != nil { + return err + } + if existing.Id > utils.NumberZero { + _, err := m.Edit(w, map[string]interface{}{ + "path": record.Path, + "image_width": record.ImageWidth, + "image_height": record.ImageHeight, + "updated_at": record.UpdatedAt, + }) + return err + } + return m.Create(&record) +} + +func uploadFaceppImageToOSS(localPath string) (string, error) { + data, err := os.ReadFile(localPath) + if err != nil { + return utils.StringEmpty, err + } + imageDir := utils.GetConfigString("oss.imageDir") + fileName := fmt.Sprintf("%d%d.jpg", time.Now().UnixNano(), rand.Intn(10000)) + res, err := pkgoss.Upload(pkgoss.UploadInput{ + Filename: fileName, + Content: data, + ContentType: "image/jpeg", + Dir: imageDir, + }) + if err != nil { + return utils.StringEmpty, err + } + return res.URL, nil +} diff --git a/services/analysis/internal/callback/facepp_file.go b/services/analysis/internal/callback/facepp_file.go new file mode 100644 index 0000000..6da7d9f --- /dev/null +++ b/services/analysis/internal/callback/facepp_file.go @@ -0,0 +1,168 @@ +package callback + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "image" + "image/color" + _ "image/jpeg" + _ "image/png" + "io" + "math/rand" + "os" + "path/filepath" + "strings" + "time" + + "lone-services/pkg/facepp" + "lone-services/pkg/utils" + + "github.com/disintegration/imaging" +) + +const defaultExpandRatio = 0.15 + +func expandImageToTemp(srcPath string, ratio float64) (string, error) { + if ratio <= 0 { + ratio = defaultExpandRatio + } + img, err := imaging.Open(srcPath) + if err != nil { + return utils.StringEmpty, err + } + img = imaging.Rotate90(img) + + w := img.Bounds().Dx() + h := img.Bounds().Dy() + newW := int(float64(w) * (1 + ratio)) + newH := int(float64(h) * (1 + ratio)) + + canvas := imaging.New(newW, newH, color.Black) + out := imaging.PasteCenter(canvas, img) + + ext := strings.ToLower(filepath.Ext(srcPath)) + if ext == utils.StringEmpty { + ext = ".jpg" + } + tmpFile, err := os.CreateTemp("", "expand-*"+ext) + if err != nil { + return utils.StringEmpty, err + } + tmpPath := tmpFile.Name() + if err = tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return utils.StringEmpty, err + } + if err = imaging.Save(out, tmpPath); err != nil { + _ = os.Remove(tmpPath) + return utils.StringEmpty, err + } + return tmpPath, nil +} + +func saveFaceppTrainImage(srcPath string) (string, error) { + saveDir := utils.GetConfigString("facepp.train_image_path") + if saveDir == utils.StringEmpty { + saveDir = "/data/facepp/train/images" + } + if err := os.MkdirAll(saveDir, os.ModePerm); err != nil { + return utils.StringEmpty, err + } + filename := fmt.Sprintf("%d_%d.jpg", time.Now().UnixMilli(), rand.Intn(1000000)) + dstPath := filepath.Join(saveDir, filename) + if err := copyLocalFile(srcPath, dstPath); err != nil { + return utils.StringEmpty, err + } + return dstPath, nil +} + +func saveFaceppTrainAnnotations(trainPath string, result json.RawMessage) (string, string, error) { + if trainPath == utils.StringEmpty { + return utils.StringEmpty, utils.StringEmpty, errors.New("train image path is empty") + } + base := strings.TrimSuffix(filepath.Base(trainPath), filepath.Ext(trainPath)) + origPath, webPath, err := saveFaceppJSON(base, utils.StringEmpty, result) + if err != nil { + return utils.StringEmpty, utils.StringEmpty, err + } + + labelDir := utils.GetConfigString("facepp.train_label_path") + if labelDir == utils.StringEmpty { + labelDir = "/data/facepp/train/labels" + } + if err = os.MkdirAll(labelDir, os.ModePerm); err != nil { + return origPath, webPath, err + } + imgW, imgH, err := imageSize(trainPath) + if err != nil { + return origPath, webPath, err + } + yolo, err := facepp.ResultToYOLO(result, imgW, imgH) + if err != nil { + return origPath, webPath, err + } + if err = os.WriteFile(filepath.Join(labelDir, base+".txt"), []byte(yolo), 0644); err != nil { + return origPath, webPath, err + } + return origPath, webPath, nil +} + +func saveFaceppJSON(base, suffix string, raw json.RawMessage) (string, string, error) { + if len(raw) == 0 || string(raw) == "null" { + return utils.StringEmpty, utils.StringEmpty, nil + } + jsonDir := utils.GetConfigString("facepp.train_json_path") + if jsonDir == utils.StringEmpty { + jsonDir = "/data/facepp/train/json" + } + if err := os.MkdirAll(jsonDir, os.ModePerm); err != nil { + return utils.StringEmpty, utils.StringEmpty, err + } + origPath := filepath.Join(jsonDir, base+suffix+".json") + if err := writePrettyJSON(origPath, raw); err != nil { + return utils.StringEmpty, utils.StringEmpty, err + } + return origPath, filepath.Base(origPath), nil +} + +func writePrettyJSON(path string, raw json.RawMessage) error { + if len(raw) == 0 || string(raw) == "null" { + raw = json.RawMessage("{}") + } + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + buf.Reset() + buf.Write(raw) + } + return os.WriteFile(path, buf.Bytes(), 0644) +} + +func imageSize(path string) (int, int, error) { + f, err := os.Open(path) + if err != nil { + return 0, 0, err + } + defer f.Close() + cfg, _, err := image.DecodeConfig(f) + if err != nil { + return 0, 0, err + } + return cfg.Width, cfg.Height, nil +} + +func copyLocalFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} diff --git a/services/analysis/internal/callback/process.go b/services/analysis/internal/callback/process.go index cde20b2..b99f07f 100644 --- a/services/analysis/internal/callback/process.go +++ b/services/analysis/internal/callback/process.go @@ -55,8 +55,8 @@ func ProcessUpload(ctx context.Context, payload analysisoss.CallbackPayload) err logx.WithContext(ctx).Errorf("cache uploaded lights failed: %v", err) } - if utils.GetConfigInt("switch.facepp") == utils.NumberOne { - logx.WithContext(ctx).Infof("facepp skip task_id=%s", payload.TaskId) + if utils.GetConfigInt("switch.facepp") == utils.NumberOne && coverPath != utils.StringEmpty { + go RunFacepp(payload.TaskId, pathRes.FinalDir) } if utils.GetConfigInt("switch.analysis") == utils.NumberOne { diff --git a/services/analysis/internal/dao/facepp.go b/services/analysis/internal/dao/facepp.go index 04143ab..649462c 100644 --- a/services/analysis/internal/dao/facepp.go +++ b/services/analysis/internal/dao/facepp.go @@ -23,9 +23,23 @@ var FaceppMapTypeMeta = []FaceppMapMeta{ } type FaceppAnalysis struct { - Id int64 `gorm:"column:id;primaryKey" json:"id"` - TaskId string `gorm:"column:task_id" json:"task_id"` - ResultOssPath string `gorm:"column:result_oss_path" json:"result_oss_path"` + Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + TaskId string `gorm:"column:task_id" json:"task_id"` + OriginalPath string `gorm:"column:original_path" json:"original_path"` + ExpandPath string `gorm:"column:expand_path" json:"expand_path"` + RequestId string `gorm:"column:request_id" json:"request_id"` + Result string `gorm:"column:result" json:"result"` + LeftSideResult string `gorm:"column:left_side_result" json:"left_side_result"` + RightSideResult string `gorm:"column:right_side_result" json:"right_side_result"` + ResultPath string `gorm:"column:result_path" json:"result_path"` + ResultOssPath string `gorm:"column:result_oss_path" json:"result_oss_path"` + FaceTop int `gorm:"column:face_top" json:"face_top"` + FaceLeft int `gorm:"column:face_left" json:"face_left"` + FaceWidth int `gorm:"column:face_width" json:"face_width"` + FaceHeight int `gorm:"column:face_height" json:"face_height"` + TimeUsed int `gorm:"column:time_used" json:"time_used"` + ErrorMessage string `gorm:"column:error_message" json:"error_message"` + CreatedAt utils.CustomTime `gorm:"column:created_at" json:"created_at"` } type FaceppMap struct { diff --git a/services/user/internal/dao/session.go b/services/user/internal/dao/session.go index 4a7069c..625d8f4 100644 --- a/services/user/internal/dao/session.go +++ b/services/user/internal/dao/session.go @@ -26,6 +26,7 @@ type UserLoginSession struct { type ProfileData struct { Type uint8 `json:"type"` Base ProfileBase `json:"base"` + Client *ProfileClient `json:"client,omitempty"` Detail *ProfileDetail `json:"detail,omitempty"` Sale *ProfileSaleData `json:"sale,omitempty"` Store any `json:"store,omitempty"` @@ -41,6 +42,11 @@ type ProfileBase struct { Status uint8 `json:"status"` } +type ProfileClient struct { + ClientCode string `json:"client_code"` + OpenedAt string `json:"opened_at"` +} + type ProfileSaleData struct { Id int64 `json:"id"` SaleId int64 `json:"sale_id"` diff --git a/services/user/internal/logic/infoLogic.go b/services/user/internal/logic/infoLogic.go index 1f7a6c1..c527f3c 100644 --- a/services/user/internal/logic/infoLogic.go +++ b/services/user/internal/logic/infoLogic.go @@ -2,10 +2,13 @@ package logic import ( "context" + "strconv" + "lone-services/pkg/modelbase" "lone-services/pkg/utils" user "lone-services/rpc/user/pb" "lone-services/services/user/internal/dao" + "lone-services/services/user/internal/model" "lone-services/services/user/internal/svc" "github.com/zeromicro/go-zero/core/logx" @@ -66,6 +69,15 @@ func (l *InfoLogic) Info(in *user.InfoReq) (*user.Response, error) { ret.Detail = &dao.ProfileDetail{} } + if loginUser.ClientCode != utils.StringEmpty { + clientInfo, clientErr := loadProfileClient(row.Id, loginUser.ClientCode) + if clientErr != nil { + l.Errorf("info load user client: %v", clientErr) + return failResponse(utils.Fail), nil + } + ret.Client = clientInfo + } + switch userType { case dao.TypeUser: return okResponse(ret), nil @@ -103,3 +115,24 @@ func (l *InfoLogic) loadSaleProfile(loginUser utils.UserInfo) (*dao.ProfileSaleD } return toProfileSale(data), nil } + +func loadProfileClient(userId int64, clientCode string) (*dao.ProfileClient, error) { + var row dao.UserClient + err := model.UserClientModel{}.Init().GetOne(modelbase.Params{ + Eq: map[string]string{ + "user_id": strconv.FormatInt(userId, utils.NumberTen), + "client_code": clientCode, + "status": strconv.Itoa(int(dao.StatusEnabled)), + }, + }, &row) + if err != nil { + return nil, err + } + if row.Id < utils.NumberOne { + return nil, nil + } + return &dao.ProfileClient{ + ClientCode: row.ClientCode, + OpenedAt: row.OpenedAt.Format(), + }, nil +}