364 lines
9.2 KiB
Go
364 lines
9.2 KiB
Go
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))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|