update
This commit is contained in:
@ -70,6 +70,9 @@ import (
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"fashionapi/internal/config"
|
||||
"fashionapi/internal/database"
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/repository"
|
||||
"fashionapi/internal/service"
|
||||
)
|
||||
|
||||
@ -109,14 +112,14 @@ func main() {
|
||||
runDump(os.Args[2:])
|
||||
case "import":
|
||||
runImport(os.Args[2:])
|
||||
case "fixdup":
|
||||
runFixDup(os.Args[2:])
|
||||
case "filltitlecn":
|
||||
runFillTitleCn(os.Args[2:])
|
||||
case "purge-rejected":
|
||||
runPurgeRejected(os.Args[2:])
|
||||
case "migrate":
|
||||
runMigrate(os.Args[2:])
|
||||
case "similarbackfill":
|
||||
runSimilarBackfill(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
@ -131,9 +134,6 @@ func usage() {
|
||||
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
||||
dbtool import [-config <yml>] [-in <file>] [-clean]
|
||||
把 dump 出来的 SQL 文件灌入目标库
|
||||
dbtool fixdup [-config <yml>] [-dry-run]
|
||||
清理「悬空」重复标记(is_duplicate=1 但其源图已删/软删),修编辑页误显「重复」
|
||||
-dry-run 只统计/列出将受影响的行,不实际改写(推荐先跑一次确认)
|
||||
dbtool filltitlecn [-config <yml>] [-dry-run]
|
||||
历史回填中文标题:扫描 brand_runways / street_snaps 里 title_cn 为空且英文标题
|
||||
非空的存活记录,用与入库同源的规则(记忆优先、规则兜底)补全;规则无法完整翻译
|
||||
@ -147,6 +147,10 @@ func usage() {
|
||||
每个文件执行成功后记入 schema_migrations,重跑已记录的会跳过;库内「已存在」
|
||||
视为已应用(也记入记录表后跳过),因此历史已手动 psql 过的迁移重跑不会报错中断。
|
||||
-in 指定单个文件时,只执行该文件。本子命令替代 psql,免装客户端即可落库。
|
||||
dbtool similarbackfill [-config <yml>] [-kind runway|street] [-limit N] [-dry-run]
|
||||
给存量图集回填「疑似同款」近邻(image_similar_pairs 表)。近邻改为「入库时预计算」
|
||||
后,此前入库的图集在结果表里没有数据、后台看不到提示,本命令按图集重算一遍
|
||||
(可重复执行,同一图集先删后插)。-dry-run 只列出将处理的图集与图片数。
|
||||
|
||||
说明:
|
||||
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
||||
@ -159,6 +163,97 @@ func usage() {
|
||||
import 等价于 psql -v ON_ERROR_STOP=1 -f;整个脚本在一个隐式事务里执行,出错整体回滚。`)
|
||||
}
|
||||
|
||||
// runSimilarBackfill 给存量图集回填「疑似同款」近邻(image_similar_pairs 预计算表)。
|
||||
//
|
||||
// 为什么需要:近邻从「打开页面时实时算」改为「入库时算一次存表」后,改动之前的存量图集
|
||||
// 在结果表里没有数据,后台就看不到提示。本命令按图集重算一遍。
|
||||
//
|
||||
// 可重复执行:PrecomputeForGallery 对同一图集是先删后插。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// dbtool similarbackfill [-config <yml>] [-kind runway|street] [-limit N] [-dry-run]
|
||||
func runSimilarBackfill(args []string) {
|
||||
fs := flag.NewFlagSet("similarbackfill", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml 叠加本地覆盖)")
|
||||
kind := fs.String("kind", "", "只处理某一类:runway | street;留空=两类都做")
|
||||
limit := fs.Int("limit", 0, "最多处理多少个图集(0=不限;调试用)")
|
||||
dryRun := fs.Bool("dry-run", false, "只列出将处理的图集与图片数,不计算不写库")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
log.Fatalf("参数解析失败: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
db, err := database.New(cfg.Database)
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close(db) }()
|
||||
|
||||
ctx := context.Background()
|
||||
svc := service.NewSimilarService(repository.NewEmbeddingRepository(db), cfg.Vision.SimilarThreshold)
|
||||
|
||||
kinds := []string{dto.IngestKindRunway, dto.IngestKindStreet}
|
||||
if *kind != "" {
|
||||
kinds = []string{*kind}
|
||||
}
|
||||
|
||||
processed := 0
|
||||
for _, k := range kinds {
|
||||
table, fk, ok := repository.OwnerTable(k)
|
||||
if !ok {
|
||||
log.Printf("[backfill] 未知 kind=%s,跳过", k)
|
||||
continue
|
||||
}
|
||||
var ownerIDs []uint32
|
||||
if err := db.WithContext(ctx).
|
||||
Raw("SELECT DISTINCT " + fk + " AS id FROM " + table +
|
||||
" WHERE is_deleted = 0 ORDER BY " + fk).Scan(&ownerIDs).Error; err != nil {
|
||||
log.Printf("[backfill] 列出图集失败 kind=%s: %v", k, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[backfill] kind=%s 图集数=%d", k, len(ownerIDs))
|
||||
|
||||
for i, ownerID := range ownerIDs {
|
||||
if *limit > 0 && i >= *limit {
|
||||
log.Printf("[backfill] kind=%s 已达 -limit=%d,停止", k, *limit)
|
||||
break
|
||||
}
|
||||
var imgIDs []uint32
|
||||
if err := db.WithContext(ctx).
|
||||
Raw("SELECT id FROM "+table+" WHERE "+fk+" = ? AND is_deleted = 0 ORDER BY id", ownerID).
|
||||
Scan(&imgIDs).Error; err != nil {
|
||||
log.Printf("[backfill] kind=%s 图集=%d 取图片失败: %v", k, ownerID, err)
|
||||
continue
|
||||
}
|
||||
if len(imgIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
if *dryRun {
|
||||
processed++
|
||||
log.Printf("[backfill] [dry-run] kind=%s 图集=%d 图=%d", k, ownerID, len(imgIDs))
|
||||
continue
|
||||
}
|
||||
start := time.Now()
|
||||
if err := svc.PrecomputeForGallery(ctx, k, imgIDs); err != nil {
|
||||
log.Printf("[backfill] kind=%s 图集=%d 预计算失败: %v", k, ownerID, err)
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
log.Printf("[backfill] kind=%s 图集=%d 图=%d 耗时=%v",
|
||||
k, ownerID, len(imgIDs), time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
if *dryRun {
|
||||
log.Printf("[backfill] [dry-run] 将处理 %d 个图集(未写库)", processed)
|
||||
return
|
||||
}
|
||||
log.Printf("[backfill] 完成,处理 %d 个图集", processed)
|
||||
}
|
||||
|
||||
// openDB 按 config 加载 DSN 并探活。
|
||||
func openDB(cfgPath string) (*sql.DB, *config.DatabaseConfig, error) {
|
||||
cfg, err := config.Load(cfgPath)
|
||||
@ -308,86 +403,6 @@ func runImport(args []string) {
|
||||
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
|
||||
}
|
||||
|
||||
// runFixDup 清理「悬空」重复标记:is_duplicate=1 但 dup_of 指向的源图行已不存在或已被软删
|
||||
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
|
||||
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
|
||||
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
||||
// runFixDup 清理「悬空」重复标记:is_duplicate=1 但 dup_of 指向的源图行已不存在或已被软删
|
||||
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
|
||||
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
|
||||
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
||||
//
|
||||
// -dry-run 只统计并打印将受影响的行(含其 dup_of,便于确认源确已消失),不实际改写;
|
||||
// 不带该参数则直接 UPDATE。
|
||||
func runFixDup(args []string) {
|
||||
fs := flag.NewFlagSet("fixdup", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||
dryRun := fs.Bool("dry-run", false, "只统计并打印将受影响的行,不实际改写")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
db, _, err := openDB(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("✗ %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
for _, t := range []string{"brand_runway_images", "street_snap_images"} {
|
||||
// 悬空判定:标了重复,但其 dup_of 为空 / 为 NULL / 不在「存活源」集合里。
|
||||
// 子查询只取 is_deleted=0 的 id,因此「源已软删」与「源行已不存在」两种情况都被覆盖。
|
||||
where := `is_duplicate = 1
|
||||
AND (dup_of IS NULL OR dup_of = '' OR dup_of NOT IN (
|
||||
SELECT CAST(id AS text) FROM public.` + qi(t) + ` WHERE is_deleted = 0))`
|
||||
|
||||
if *dryRun {
|
||||
var cnt int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM public.` + qi(t) + ` WHERE ` + where).Scan(&cnt); err != nil {
|
||||
log.Fatalf("✗ 统计 %s 失败: %v", t, err)
|
||||
}
|
||||
log.Printf("→ %s: 将清理悬空重复标记 %d 行(dry-run,未改写)", t, cnt)
|
||||
const sampleLimit = 50
|
||||
if cnt > 0 {
|
||||
rows, qerr := db.Query(fmt.Sprintf(
|
||||
`SELECT id, dup_of FROM public.%s WHERE %s ORDER BY id LIMIT %d`, qi(t), where, sampleLimit))
|
||||
if qerr != nil {
|
||||
log.Fatalf("✗ 列举 %s 失败: %v", t, qerr)
|
||||
}
|
||||
type aff struct {
|
||||
ID uint32
|
||||
DupOf string
|
||||
}
|
||||
var list []aff
|
||||
for rows.Next() {
|
||||
var a aff
|
||||
if err := rows.Scan(&a.ID, &a.DupOf); err != nil {
|
||||
rows.Close()
|
||||
log.Fatalf("✗ 扫描 %s 失败: %v", t, err)
|
||||
}
|
||||
list = append(list, a)
|
||||
}
|
||||
rows.Close()
|
||||
for _, a := range list {
|
||||
log.Printf(" id=%d dup_of=%q", a.ID, a.DupOf)
|
||||
}
|
||||
if cnt > sampleLimit {
|
||||
log.Printf(" …(仅显示前 %d 行,共 %d 行)", sampleLimit, cnt)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
UPDATE public.%[1]s
|
||||
SET is_duplicate = 0, dup_of = ''
|
||||
WHERE %[2]s;`, qi(t), where)
|
||||
res, err := db.Exec(q)
|
||||
if err != nil {
|
||||
log.Fatalf("✗ 清理 %s 失败: %v", t, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
log.Printf("✓ %s: 清理悬空重复标记 %d 行", t, n)
|
||||
}
|
||||
}
|
||||
|
||||
// runPurgeRejected 把现存「已驳回(rejected)」且未下架的记录转删除:
|
||||
// 级联软删其全部图片、记录自身置 is_deleted=1(后台 legWhere 强制 is_deleted=0,记录即从列表消失),
|
||||
// 并把图片 key 作为 media_cleanup 任务入队,由运行中的 worker 按引用计数真删 S4 孤儿文件。
|
||||
@ -432,7 +447,10 @@ func runFillTitleCn(args []string) {
|
||||
if qerr != nil {
|
||||
log.Fatalf("✗ 扫描 %s 失败: %v", t.table, qerr)
|
||||
}
|
||||
type aff struct{ ID uint32; En, Cn string }
|
||||
type aff struct {
|
||||
ID uint32
|
||||
En, Cn string
|
||||
}
|
||||
var list []aff
|
||||
for rows.Next() {
|
||||
var id uint32
|
||||
|
||||
@ -1,363 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/pkg/phash"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
const (
|
||||
dsn = "host=127.0.0.1 port=5432 user=fashion password=fashion_dev_2026 dbname=fashion sslmode=disable"
|
||||
base = "https://toomstudio.s3.bitiful.net/"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
ID int64
|
||||
Image string
|
||||
Phash sql.NullString
|
||||
IsDuplicate int16
|
||||
DupOf string
|
||||
RunwayID int64
|
||||
BrandID int64
|
||||
}
|
||||
|
||||
func queryRow(db *sql.DB, like string) *row {
|
||||
r := &row{}
|
||||
q := `SELECT id, image, phash, is_duplicate, dup_of, runway_id, brand_id
|
||||
FROM brand_runway_images WHERE image LIKE $1 ORDER BY id LIMIT 1`
|
||||
err := db.QueryRow(q, like).Scan(&r.ID, &r.Image, &r.Phash, &r.IsDuplicate, &r.DupOf, &r.RunwayID, &r.BrandID)
|
||||
if err != nil {
|
||||
fmt.Printf(" 查询 %s 失败: %v\n", like, err)
|
||||
return nil
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// vectorToUint64 把 "[0,1,...,1]" 转回 uint64(与 phash.ToVectorBits 互逆)。
|
||||
func vectorToUint64(s string) uint64 {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "[")
|
||||
s = strings.TrimSuffix(s, "]")
|
||||
parts := strings.Split(s, ",")
|
||||
var h uint64
|
||||
for i, p := range parts {
|
||||
if i >= 64 {
|
||||
break
|
||||
}
|
||||
if strings.TrimSpace(p) == "1" {
|
||||
h |= 1 << uint(i)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func hamming(a, b uint64) int {
|
||||
c := 0
|
||||
x := a ^ b
|
||||
for x != 0 {
|
||||
x &= x - 1
|
||||
c++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// fetchImage 下载原图,返回解码后的 image 与原始字节。
|
||||
func fetchImage(key string) (image.Image, []byte, bool) {
|
||||
url := base + strings.TrimPrefix(key, "/")
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
fmt.Printf(" 下载 %s 失败: %v\n", url, err)
|
||||
return nil, nil, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
fmt.Printf(" 下载 %s 状态 %d\n", url, resp.StatusCode)
|
||||
return nil, nil, false
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf(" 读 %s 失败: %v\n", url, err)
|
||||
return nil, nil, false
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
fmt.Printf(" 解码 %s 失败: %v\n", url, err)
|
||||
return nil, nil, false
|
||||
}
|
||||
return img, data, true
|
||||
}
|
||||
|
||||
// aHash 平均哈希:缩到 8x8 灰度,与整图均值比大小,得 64-bit。比 dHash 更关注整体明暗分布。
|
||||
func aHash(img image.Image) uint64 {
|
||||
const n = 8
|
||||
gray := make([][]float64, n)
|
||||
for i := range gray {
|
||||
gray[i] = make([]float64, n)
|
||||
}
|
||||
b := img.Bounds()
|
||||
var total float64
|
||||
for y := 0; y < n; y++ {
|
||||
for x := 0; x < n; x++ {
|
||||
sx := b.Min.X + (x*b.Dx())/n + b.Dx()/(2*n)
|
||||
sy := b.Min.Y + (y*b.Dy())/n + b.Dy()/(2*n)
|
||||
r, g, bl, _ := img.At(sx, sy).RGBA()
|
||||
l := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
|
||||
gray[y][x] = l
|
||||
total += l
|
||||
}
|
||||
}
|
||||
mean := total / float64(n*n)
|
||||
var h uint64
|
||||
i := 0
|
||||
for y := 0; y < n; y++ {
|
||||
for x := 0; x < n; x++ {
|
||||
if gray[y][x] > mean {
|
||||
h |= 1 << uint(i)
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// pHash DCT 感知哈希:缩到 32x32 灰度 → 2D DCT → 取左上 8x8 低频系数 → 与中位数比大小得 64-bit。
|
||||
func pHash(img image.Image) uint64 {
|
||||
const N = 32
|
||||
g := [N][N]float64{}
|
||||
b := img.Bounds()
|
||||
for cy := 0; cy < N; cy++ {
|
||||
for cx := 0; cx < N; cx++ {
|
||||
x0 := b.Min.X + cx*b.Dx()/N
|
||||
x1 := b.Min.X + (cx+1)*b.Dx()/N
|
||||
y0 := b.Min.Y + cy*b.Dy()/N
|
||||
y1 := b.Min.Y + (cy+1)*b.Dy()/N
|
||||
if x1 <= x0 {
|
||||
x1 = x0 + 1
|
||||
}
|
||||
if y1 <= y0 {
|
||||
y1 = y0 + 1
|
||||
}
|
||||
var sum float64
|
||||
var n int
|
||||
for y := y0; y < y1 && y < b.Max.Y; y++ {
|
||||
for x := x0; x < x1 && x < b.Max.X; x++ {
|
||||
r, gg, bl, _ := img.At(x, y).RGBA()
|
||||
sum += 0.299*float64(r>>8) + 0.587*float64(gg>>8) + 0.114*float64(bl>>8)
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
g[cy][cx] = sum / float64(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 行 DCT-II
|
||||
var rows [N][N]float64
|
||||
for y := 0; y < N; y++ {
|
||||
for u := 0; u < N; u++ {
|
||||
var s float64
|
||||
for x := 0; x < N; x++ {
|
||||
s += g[y][x] * math.Cos(math.Pi*float64(u)*(float64(x)+0.5)/float64(N))
|
||||
}
|
||||
rows[y][u] = s
|
||||
}
|
||||
}
|
||||
// 列 DCT-II
|
||||
var dct [N][N]float64
|
||||
for x := 0; x < N; x++ {
|
||||
for v := 0; v < N; v++ {
|
||||
var s float64
|
||||
for y := 0; y < N; y++ {
|
||||
s += rows[y][x] * math.Cos(math.Pi*float64(v)*(float64(y)+0.5)/float64(N))
|
||||
}
|
||||
dct[v][x] = s
|
||||
}
|
||||
}
|
||||
// 取左上 8x8 低频,与中位数比大小
|
||||
var coeffs [64]float64
|
||||
i := 0
|
||||
for v := 0; v < 8; v++ {
|
||||
for u := 0; u < 8; u++ {
|
||||
coeffs[i] = dct[v][u]
|
||||
i++
|
||||
}
|
||||
}
|
||||
sorted := append([]float64{}, coeffs[:]...)
|
||||
for a := 0; a < len(sorted); a++ {
|
||||
for c := a + 1; c < len(sorted); c++ {
|
||||
if sorted[c] < sorted[a] {
|
||||
sorted[a], sorted[c] = sorted[c], sorted[a]
|
||||
}
|
||||
}
|
||||
}
|
||||
median := sorted[len(sorted)/2]
|
||||
var h uint64
|
||||
for k := 0; k < 64; k++ {
|
||||
if coeffs[k] > median {
|
||||
h |= 1 << uint(k)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// luminanceStats 算亮度均值/标准差,并用 phash.Of 复算指纹。
|
||||
func luminanceStats(img image.Image, data []byte) (mean, std float64, recomputed uint64, ok bool) {
|
||||
recomputed = phash.Of(data)
|
||||
b := img.Bounds()
|
||||
var sum, sum2 float64
|
||||
var n int
|
||||
step := 1
|
||||
if b.Dx() > 300 || b.Dy() > 300 {
|
||||
step = int(math.Max(1, float64(b.Dx())/300))
|
||||
}
|
||||
for y := b.Min.Y; y < b.Max.Y; y += step {
|
||||
for x := b.Min.X; x < b.Max.X; x += step {
|
||||
r, g, bl, _ := img.At(x, y).RGBA()
|
||||
lum := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
|
||||
sum += lum
|
||||
sum2 += lum * lum
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
mean = sum / float64(n)
|
||||
variance := sum2/float64(n) - mean*mean
|
||||
if variance < 0 {
|
||||
variance = 0
|
||||
}
|
||||
std = math.Sqrt(variance)
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
func dumpBits(h uint64) string {
|
||||
var sb strings.Builder
|
||||
for i := 0; i < 64; i++ {
|
||||
if (h>>uint(i))&1 == 1 {
|
||||
sb.WriteByte('1')
|
||||
} else {
|
||||
sb.WriteByte('0')
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func main() {
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
fmt.Println("open db:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
k1 := "6837152087e6b9849368860811920d98d6b986dc"
|
||||
k2 := "53f47b14a8799e682864fb7ffc1f942caaeeb56b"
|
||||
if len(os.Args) >= 3 {
|
||||
k1, k2 = os.Args[1], os.Args[2]
|
||||
}
|
||||
|
||||
r1 := queryRow(db, "%"+k1+"%")
|
||||
r2 := queryRow(db, "%"+k2+"%")
|
||||
if r1 == nil || r2 == nil {
|
||||
fmt.Println("未能取到两行,退出")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("==== 行 1 ====")
|
||||
fmt.Printf("id=%d image=%s is_duplicate=%d dup_of=%s runway_id=%d brand_id=%d\n",
|
||||
r1.ID, r1.Image, r1.IsDuplicate, r1.DupOf, r1.RunwayID, r1.BrandID)
|
||||
fmt.Println("==== 行 2 ====")
|
||||
fmt.Printf("id=%d image=%s is_duplicate=%d dup_of=%s runway_id=%d brand_id=%d\n",
|
||||
r2.ID, r2.Image, r2.IsDuplicate, r2.DupOf, r2.RunwayID, r2.BrandID)
|
||||
|
||||
var h1, h2 uint64
|
||||
if r1.Phash.Valid {
|
||||
h1 = vectorToUint64(r1.Phash.String)
|
||||
fmt.Printf("phash1=%s\nbits1=%s\n", r1.Phash.String, dumpBits(h1))
|
||||
} else {
|
||||
fmt.Println("phash1=NULL")
|
||||
}
|
||||
if r2.Phash.Valid {
|
||||
h2 = vectorToUint64(r2.Phash.String)
|
||||
fmt.Printf("phash2=%s\nbits2=%s\n", r2.Phash.String, dumpBits(h2))
|
||||
} else {
|
||||
fmt.Println("phash2=NULL")
|
||||
}
|
||||
|
||||
if r1.Phash.Valid && r2.Phash.Valid {
|
||||
ham := hamming(h1, h2)
|
||||
fmt.Printf("\n>>> 两张图之间的真实汉明距离 = %d (系统阈值 DefaultThreshold=4)\n", ham)
|
||||
if ham <= 4 {
|
||||
fmt.Println(">>> 系统会把这两者判为近重复(≤4)。")
|
||||
} else {
|
||||
fmt.Println(">>> 这两张彼此并不在 ≤4 内;若被标重,必是各自 dup_of 指向了不同的第三者。")
|
||||
}
|
||||
}
|
||||
|
||||
// 亮度标准差 + 指纹复算(验证“平淡图哈希塌缩”假说)+ aHash 区分度
|
||||
fmt.Println("\n==== 像素核验(原图,无 style)====")
|
||||
var imgs [2]image.Image
|
||||
var ahs [2]uint64
|
||||
var phs [2]uint64
|
||||
for i, r := range []*row{r1, r2} {
|
||||
img, data, ok := fetchImage(r.Image)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
imgs[i] = img
|
||||
ahs[i] = aHash(img)
|
||||
phs[i] = pHash(img)
|
||||
mean, std, recomputed, ok2 := luminanceStats(img, data)
|
||||
if !ok2 {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("image=%s\n 亮度均值=%.1f 标准差=%.1f (标准差越低越“平”,哈希越易塌缩)\n",
|
||||
r.Image, mean, std)
|
||||
stored := vectorToUint64(r.Phash.String)
|
||||
fmt.Printf(" 复算phash与库存phash一致=%v 复算bits=%s\n", recomputed == stored, dumpBits(recomputed))
|
||||
fmt.Printf(" aHash bits=%s\n", dumpBits(ahs[i]))
|
||||
fmt.Printf(" pHash bits=%s\n", dumpBits(phs[i]))
|
||||
}
|
||||
if imgs[0] != nil && imgs[1] != nil {
|
||||
dh := 0
|
||||
if r1.Phash.Valid && r2.Phash.Valid {
|
||||
dh = hamming(vectorToUint64(r1.Phash.String), vectorToUint64(r2.Phash.String))
|
||||
}
|
||||
fmt.Printf("\n>>> dHash 汉明距离 = %d(库存判定,≤4 判重)\n", dh)
|
||||
fmt.Printf(">>> aHash 汉明距离 = %d\n", hamming(ahs[0], ahs[1]))
|
||||
fmt.Printf(">>> pHash 汉明距离 = %d (pHash 常规判重阈值约 10~15,远超 dHash 的 4)\n", hamming(phs[0], phs[1]))
|
||||
}
|
||||
|
||||
// 若某行 dup_of 指向第三者,把它也拉出来看
|
||||
for _, r := range []*row{r1, r2} {
|
||||
if r.DupOf != "" && r.DupOf != "0" {
|
||||
tgt := &row{}
|
||||
err := db.QueryRow(`SELECT id, image, phash, runway_id, brand_id FROM brand_runway_images WHERE id=$1`, r.DupOf).
|
||||
Scan(&tgt.ID, &tgt.Image, &tgt.Phash, &tgt.RunwayID, &tgt.BrandID)
|
||||
if err == nil {
|
||||
fmt.Printf("\n>>> 行 %d 的 dup_of=%s 指向:\n id=%d image=%s runway_id=%d brand_id=%d\n",
|
||||
r.ID, r.DupOf, tgt.ID, tgt.Image, tgt.RunwayID, tgt.BrandID)
|
||||
if tgt.Phash.Valid {
|
||||
th := vectorToUint64(tgt.Phash.String)
|
||||
fmt.Printf(" 与该目标汉明距离=%d\n", hamming(vectorToUint64(r.Phash.String), th))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,7 @@ import (
|
||||
"fashionapi/internal/repository"
|
||||
"fashionapi/internal/router"
|
||||
"fashionapi/internal/service"
|
||||
"fashionapi/internal/vision"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@ -169,9 +170,30 @@ func main() {
|
||||
} else {
|
||||
slog.Info("图片存储(本地)", "prefix", cfg.Upload.URLPrefix)
|
||||
}
|
||||
// 视觉服务(FashionSigLIP):可选,仅用于后台「疑似同款」提示,不参与任何去重。
|
||||
// 未启用时两者均为 nil,IngestService 会整体跳过 embed(零开销),入库行为与启用前一致。
|
||||
var (
|
||||
visionClient *vision.Client
|
||||
embedRepo repository.EmbeddingRepository
|
||||
)
|
||||
if cfg.Vision.Enabled {
|
||||
visionClient = vision.New(
|
||||
cfg.Vision.Endpoint,
|
||||
cfg.Vision.Dim,
|
||||
time.Duration(cfg.Vision.TimeoutSeconds)*time.Second,
|
||||
cfg.Vision.FailThreshold,
|
||||
)
|
||||
embedRepo = repository.NewEmbeddingRepository(db)
|
||||
slog.Info("视觉服务已启用",
|
||||
"endpoint", cfg.Vision.Endpoint,
|
||||
"dim", cfg.Vision.Dim,
|
||||
"similar_threshold", cfg.Vision.SimilarThreshold)
|
||||
} else {
|
||||
slog.Info("视觉服务未启用(不产出语义向量,后台无「疑似同款」提示)")
|
||||
}
|
||||
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
||||
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
||||
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy, translateSvc)
|
||||
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy, translateSvc, visionClient, embedRepo)
|
||||
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
|
||||
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||
@ -183,13 +205,21 @@ func main() {
|
||||
Brand: handler.NewBrandHandler(brandSvc),
|
||||
StreetSnap: handler.NewStreetSnapHandler(snapSvc, favSvc),
|
||||
Auth: handler.NewAuthHandler(authSvc),
|
||||
Favorite: handler.NewFavoriteHandler(favSvc),
|
||||
History: handler.NewHistoryHandler(histSvc),
|
||||
Health: handler.NewHealthHandler(),
|
||||
Favorite: handler.NewFavoriteHandler(favSvc),
|
||||
History: handler.NewHistoryHandler(histSvc),
|
||||
Health: handler.NewHealthHandler(),
|
||||
})
|
||||
|
||||
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
|
||||
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo, translateSvc)
|
||||
// 「疑似同款」服务与视觉服务同开关:未启用时 embedRepo 为 nil,页面上不出现该角标。
|
||||
similarSvc := service.NewSimilarService(embedRepo, cfg.Vision.SimilarThreshold)
|
||||
// 把「疑似同款」服务注入入库流程:写完向量后顺手预计算近邻并落库,
|
||||
// 后台页面因此只需读一次小查询(原先是页面内对每张图实时检索,一个 116 张图的图集要 5 秒)。
|
||||
ingestSvc.SetSimilar(similarSvc)
|
||||
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo, translateSvc, similarSvc)
|
||||
// 手工建稿:复用入库仓储的写入方法(Create*WithImages,事务内完成)与同一套存储上传
|
||||
// (imgUp = S4/本地主上传器,sha1 内容寻址),保证与爬虫入库产出的 key / 行结构一致。
|
||||
backstageHandler.SetManual(service.NewManualArticleService(ingestRepo, brandRepo, imgUp, localUp, visionClient, embedRepo))
|
||||
// 爬虫上报 handler(HMAC 验签入口)
|
||||
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user