190 lines
7.1 KiB
Go
190 lines
7.1 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"sort"
|
||
|
||
"fashionapi/internal/repository"
|
||
)
|
||
|
||
// SimilarHint 是一条「疑似同款」提示:某张图片的一个近邻。
|
||
//
|
||
// 带上近邻的图片 key / 名称 / 所属图集标题,后台才能直接显示「和哪张图像」。
|
||
type SimilarHint struct {
|
||
ImageID uint32 // 近邻图片 id
|
||
OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转)
|
||
Similar float64 // 余弦相似度 0~1
|
||
Image string // 近邻图片存储 key(前端显示缩略图)
|
||
Name string // 近邻图片名(如 "Look 12")
|
||
OwnerTitle string // 近邻所属图集标题
|
||
}
|
||
|
||
// DupStat 是一个图集的「跨图集重复」概况,供文章列表直接标出哪些图集有重复。
|
||
type DupStat struct {
|
||
Total int // 该图集存活图片总数(实时统计,不用过期的 image_count 缓存列)
|
||
Images int // 其中有跨图集重复的图片数
|
||
FirstImageID uint32 // 其中 id 最小的一张,列表点进去可直接锚点定位
|
||
}
|
||
|
||
// SimilarService 组装后台「疑似同款」提示。
|
||
//
|
||
// 定位(对应规格 §8/§9):**只提示、不隐藏、不计入重复率、不参与任何去重**。
|
||
//
|
||
// 分工:
|
||
// - PrecomputeForGallery 在**入库时**(或 dbtool similarbackfill 回填时)算好近邻并落库;
|
||
// - HintsForImages 只读结果表,供页面渲染。
|
||
//
|
||
// 为什么必须预计算:原实现在**打开图集页时**对每张图实时检索,一个 116 张图的图集要发
|
||
// 116 次查询、每次约 20KB SQL(768 维向量文本)。本机 PostgreSQL 跑在 WSL2 里、Windows 经
|
||
// wslrelay 访问,该链路大 payload 吞吐实测约 0.47MB/s —— 服务端执行单条只要 0.45ms,
|
||
// 客户端往返却要 45ms,合计 116×45ms ≈ 5 秒。挪到入库时后页面只读一次小查询。
|
||
type SimilarService struct {
|
||
embedRep repository.EmbeddingRepository
|
||
threshold float64
|
||
}
|
||
|
||
// NewSimilarService 构造 SimilarService。threshold 为余弦相似度阈值。
|
||
// embedRep 可为 nil(视觉服务未启用),此时所有查询返回空结果。
|
||
func NewSimilarService(embedRep repository.EmbeddingRepository, threshold float64) *SimilarService {
|
||
return &SimilarService{embedRep: embedRep, threshold: threshold}
|
||
}
|
||
|
||
// Enabled 报告是否可用(仓储已注入)。
|
||
func (s *SimilarService) Enabled() bool {
|
||
return s != nil && s.embedRep != nil
|
||
}
|
||
|
||
// Threshold 返回当前的「疑似同款」相似度阈值,供页面展示。
|
||
func (s *SimilarService) Threshold() float64 {
|
||
if s == nil {
|
||
return 0
|
||
}
|
||
return s.threshold
|
||
}
|
||
|
||
// HintsForImages 读取一个图集全部图片的「疑似同款」提示(只读结果表,不计算)。
|
||
//
|
||
// 返回 map[图片 id] -> 近邻提示列表(按相似度降序、每张最多 similarNeighborLimit 条)。
|
||
// **不返回 error**:提示是增值信息,读取失败只记日志并返回空,
|
||
// 绝不能让「查不到同款」影响后台页面渲染。
|
||
func (s *SimilarService) HintsForImages(ctx context.Context, kind string, imageIDs []uint32) map[uint32][]SimilarHint {
|
||
if !s.Enabled() || len(imageIDs) == 0 {
|
||
return nil
|
||
}
|
||
pairs, err := s.embedRep.ListSimilarPairs(ctx, kind, imageIDs)
|
||
if err != nil {
|
||
log.Printf("[similar] 读近邻失败 kind=%s 数量=%d err=%v(页面不显示同款提示)", kind, len(imageIDs), err)
|
||
return nil
|
||
}
|
||
if len(pairs) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[uint32][]SimilarHint, len(pairs))
|
||
for selfID, ns := range pairs {
|
||
if len(ns) == 0 {
|
||
continue
|
||
}
|
||
sort.Slice(ns, func(i, j int) bool { return ns[i].Similar > ns[j].Similar })
|
||
if len(ns) > similarNeighborLimit {
|
||
ns = ns[:similarNeighborLimit]
|
||
}
|
||
hints := make([]SimilarHint, 0, len(ns))
|
||
for _, n := range ns {
|
||
hints = append(hints, SimilarHint{
|
||
ImageID: n.OtherID,
|
||
OwnerID: n.OwnerID,
|
||
Similar: n.Similar,
|
||
Image: n.Image,
|
||
Name: n.Name,
|
||
OwnerTitle: n.OwnerTitle,
|
||
})
|
||
}
|
||
out[selfID] = hints
|
||
}
|
||
return out
|
||
}
|
||
|
||
// DupStats 汇总 runway / street 两类图集的跨图集重复情况(kind -> 图集 id -> 概况)。
|
||
//
|
||
// 供文章列表直接标出「哪些图集有重复」——否则只能一个个点进图集才能发现。
|
||
//
|
||
// **不返回 error**:与提示同理,汇总失败只记日志并跳过该类,
|
||
// 绝不能让列表页因为查不到重复而渲染失败。
|
||
func (s *SimilarService) DupStats(ctx context.Context) map[string]map[uint32]DupStat {
|
||
if !s.Enabled() {
|
||
return nil
|
||
}
|
||
out := make(map[string]map[uint32]DupStat, 2)
|
||
for _, kind := range []string{"runway", "street"} {
|
||
raw, err := s.embedRep.DupStatsByOwner(ctx, kind)
|
||
if err != nil {
|
||
log.Printf("[similar] 汇总跨图集重复失败 kind=%s err=%v(列表不显示重复列)", kind, err)
|
||
continue
|
||
}
|
||
if len(raw) == 0 {
|
||
continue
|
||
}
|
||
m := make(map[uint32]DupStat, len(raw))
|
||
for ownerID, st := range raw {
|
||
m[ownerID] = DupStat{Total: st.Total, Images: st.Images, FirstImageID: st.FirstImageID}
|
||
}
|
||
out[kind] = m
|
||
}
|
||
return out
|
||
}
|
||
|
||
// PrecomputeForGallery 计算并落库一个图集全部图片的跨图集近邻。
|
||
//
|
||
// imageIDs 须为该图集的全部图片 id:它同时充当「同图集排除集」——需求是
|
||
// 「本图集的图与其他图集有没有重复」,同图集内所有照片共享背景/灯光/模特/造型,
|
||
// 相似度天然偏高(实测同场中位 0.69、最高 0.91),参与比对只会淹没真正的跨图集重复。
|
||
//
|
||
// 结果**双向**写入(A→B 与 B→A):这样打开任一侧所在的图集都能看到提示。
|
||
//
|
||
// 说明:单张查询失败只记日志并跳过,整体不中断——预计算是增值数据,
|
||
// 绝不能让「算不出同款」影响入库或回填。
|
||
func (s *SimilarService) PrecomputeForGallery(ctx context.Context, kind string, imageIDs []uint32) error {
|
||
if !s.Enabled() || len(imageIDs) == 0 {
|
||
return nil
|
||
}
|
||
vecByImage, err := s.embedRep.ListEmbeddings(ctx, kind, imageIDs)
|
||
if err != nil {
|
||
return fmt.Errorf("取向量: %w", err)
|
||
}
|
||
if len(vecByImage) == 0 {
|
||
return nil // 这批图还没有向量(视觉服务未启用),无需预计算
|
||
}
|
||
|
||
rows := make([]repository.SimilarPair, 0, len(vecByImage)*similarNeighborLimit*2)
|
||
done := 0
|
||
for imageID, vec := range vecByImage {
|
||
if len(vec) == 0 {
|
||
continue
|
||
}
|
||
neighbors, ferr := s.embedRep.FindSimilarImages(
|
||
ctx, kind, imageID, imageIDs, vec, s.threshold, similarNeighborLimit)
|
||
if ferr != nil {
|
||
log.Printf("[similar] 预计算近邻失败 kind=%s 图片=%d err=%v", kind, imageID, ferr)
|
||
continue
|
||
}
|
||
done++
|
||
for _, n := range neighbors {
|
||
rows = append(rows,
|
||
repository.SimilarPair{SelfID: imageID, OtherID: n.ImageID, Similar: n.Similar},
|
||
repository.SimilarPair{SelfID: n.ImageID, OtherID: imageID, Similar: n.Similar},
|
||
)
|
||
}
|
||
}
|
||
if err := s.embedRep.ReplaceSimilarPairs(ctx, kind, imageIDs, rows); err != nil {
|
||
return fmt.Errorf("写入近邻: %w", err)
|
||
}
|
||
log.Printf("[similar] 预计算完成 kind=%s 图=%d 已算=%d 对=%d 阈值=%.2f",
|
||
kind, len(imageIDs), done, len(rows), s.threshold)
|
||
return nil
|
||
}
|
||
|
||
// similarNeighborLimit 每张图最多展示的「疑似同款」条数:只作人工参考,多了是噪音。
|
||
const similarNeighborLimit = 5
|