This commit is contained in:
toom1996
2026-10-18 17:42:10 +08:00
parent 1b06bd5bda
commit 1de1ec46a6
58 changed files with 5281 additions and 19191 deletions

View File

@ -1,127 +0,0 @@
// Package phash 计算图片的感知哈希(dHash,Difference Hash)。
//
// dHash 是 64-bit 轻量指纹,用于判断「同一张图被转码 / 加水印 / 改尺寸」这类近重复。
// 去重检索不在这里做——交给 PostgreSQL + pgvector 的 HNSW 索引(索引随全库结构一起,
// 由 cmd/dbtool 导出的 SQL 维护),本包只负责产出指纹,复杂度极低、零 CGO、零 ML 依赖。
//
// 与之前被移除的全表扫描实现不同:现在指纹只写库、检索交给索引,O(1) 桶内比对即可,
// 图片量级再大也扛得住。
package phash
import (
"bytes"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"math"
"strings"
)
// Size 是哈希位数(9x8 灰度 → 8 行 × 8 差 = 64 bit)。
const Size = 64
// DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。
// 历史取值:10(明显过松,实测混入大量噪声)→ 4(用户指定,收紧判定)。
const DefaultThreshold = 4
// Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。
// 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。
func Of(data []byte) uint64 {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return 0
}
return dhash(img)
}
const (
dw = 9 // 灰度矩阵宽度(列数)
dh = 8 // 灰度矩阵高度(行数)
)
// dhash 把图像缩到 9x8 灰度,逐行比较相邻像素亮度,得到 64-bit 指纹。
func dhash(img image.Image) uint64 {
b := img.Bounds()
scaleX := float64(b.Dx()) / float64(dw)
scaleY := float64(b.Dy()) / float64(dh)
gray := make([][]float64, dh)
for y := 0; y < dh; y++ {
gray[y] = make([]float64, dw)
y0 := int(float64(y) * scaleY)
y1 := int(math.Ceil(float64(y+1) * scaleY))
if y1 <= y0 {
y1 = y0 + 1
}
for x := 0; x < dw; x++ {
x0 := int(float64(x) * scaleX)
x1 := int(math.Ceil(float64(x+1) * scaleX))
if x1 <= x0 {
x1 = x0 + 1
}
var sum float64
var n int
for sy := y0; sy < y1 && sy < b.Dy(); sy++ {
for sx := x0; sx < x1 && sx < b.Dx(); sx++ {
r, g, bl, _ := img.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
// 感知亮度(ITU-R BT.601)
lum := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
sum += lum
n++
}
}
if n > 0 {
gray[y][x] = sum / float64(n)
}
}
}
var hash uint64
i := 0
for y := 0; y < dh; y++ {
for x := 0; x < dw-1; x++ {
if gray[y][x] > gray[y][x+1] {
hash |= 1 << uint(i)
}
i++
}
}
return hash
}
// ToVectorBits 把 64-bit 指纹转成 pgvector 的二进制向量字符串 "[0,1,...,1]"(长度 64),
// 用于写入 vector(64) 列,并以 vector_l2_ops 建 HNSW 索引做汉明近邻检索(L2²==汉明)。
// h 为 0(解码失败)时返回空串,调用方据此存 NULL、不参与检索。
func ToVectorBits(h uint64) string {
if h == 0 {
return ""
}
var sb strings.Builder
sb.Grow(Size + 1)
sb.WriteByte('[')
for i := 0; i < Size; i++ {
if i > 0 {
sb.WriteByte(',')
}
if (h>>uint(i))&1 == 1 {
sb.WriteByte('1')
} else {
sb.WriteByte('0')
}
}
sb.WriteByte(']')
return sb.String()
}
// Hamming 返回两个指纹的汉明距离(不同 bit 数)。仅在 Go 侧需要手工比对时使用;
// 检索场景直接交给 pgvector 的 <~> 算子。
func Hamming(a, b uint64) int {
c := 0
x := a ^ b
for x != 0 {
x &= x - 1
c++
}
return c
}

View File

@ -1,122 +0,0 @@
package phash
import (
"bytes"
"image"
"image/color"
"image/png"
"testing"
)
// genGradient 生成一张水平灰阶渐变的 PNG 字节流(w 宽、h 高)。
// 用于验证 dHash 对「同一图案」的稳定性,以及对「缩放 / 转码」的鲁棒性。
func genGradient(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
v := uint8(x * 255 / w)
img.Set(x, y, color.Gray{Y: v})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
panic(err)
}
return buf.Bytes()
}
// genGradientReverse 生成一张「左亮右暗」的反向渐变 PNG。
// 与 genGradient(左暗右亮)方向相反,dHash 的相邻比较结果完全相反,
// 汉明距离应为 64(最大),用于验证「差异显著」的图能被正确区分。
func genGradientReverse(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
v := uint8(255 - x*255/w)
img.Set(x, y, color.Gray{Y: v})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
panic(err)
}
return buf.Bytes()
}
// genSolid 生成一张纯色 PNG。
func genSolid(w, h int, v uint8) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
c := color.Gray{Y: v}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
panic(err)
}
return buf.Bytes()
}
// TestOfIdentical 同一字节流 → 指纹完全一致。
func TestOfIdentical(t *testing.T) {
a := genGradient(64, 64)
b := genGradient(64, 64)
if Of(a) != Of(b) {
t.Fatalf("相同图片应得到相同 dHash,got %016x vs %016x", Of(a), Of(b))
}
}
// TestOfResizeSimilar 同一图案缩放到不同尺寸 → 汉明距离很小(近重复可容忍范围内)。
func TestOfResizeSimilar(t *testing.T) {
a := genGradient(64, 64)
b := genGradient(128, 128)
d := Hamming(Of(a), Of(b))
if d > 20 {
t.Fatalf("同一图案缩放后汉明距离应很小,got %d", d)
}
}
// TestOfDifferent 方向相反的图案(左暗右亮 vs 左亮右暗)→ 汉明距离应显著大于近重复阈值。
func TestOfDifferent(t *testing.T) {
a := genGradient(64, 64)
b := genGradientReverse(64, 64)
d := Hamming(Of(a), Of(b))
if d <= DefaultThreshold {
t.Fatalf("不同图片汉明距离应大于阈值 %d,got %d", DefaultThreshold, d)
}
}
// TestToVectorBits 验证向量串格式与维度(64 位、方括号包裹、0/1 组成)。
func TestToVectorBits(t *testing.T) {
s := ToVectorBits(0b1) // 仅最低位为 1
if len(s) != 2*Size+1 { // 64 个 0/1 + 63 个逗号 + 2 个方括号 = 129
t.Fatalf("向量串长度应为 %d,got %d(%s)", 2*Size+1, len(s), s)
}
if s[0] != '[' || s[len(s)-1] != ']' {
t.Fatalf("向量串应以方括号包裹,got %s", s)
}
if s[1] != '1' { // bit0 = 1
t.Fatalf("第 0 维应为 1,got %c", s[1])
}
if s[3] != '0' { // bit1 = 0([1,0,...] 中第二个元素)
t.Fatalf("第 1 维应为 0,got %c", s[3])
}
if ToVectorBits(0) != "" {
t.Fatalf("解码失败(0)应返回空串,got %q", ToVectorBits(0))
}
}
// TestHamming 验证汉明距离的对称性与已知值。
func TestHamming(t *testing.T) {
if Hamming(0, 0) != 0 {
t.Fatalf("相同指纹汉明距离应为 0")
}
if Hamming(0b1111, 0b0000) != 4 {
t.Fatalf("1111 vs 0000 汉明距离应为 4")
}
if Hamming(0b1111, 0b0000) != Hamming(0b0000, 0b1111) {
t.Fatalf("汉明距离应对称")
}
}