pdq_hasher.go (16245B)
1 package pdq 2 3 //lint:file-ignore U1000 Ignore all unused code, it's pending tests 4 5 import ( 6 "log" 7 8 "math" 9 10 "github.com/MTRNord/pdqhash-go/helpers" 11 "github.com/MTRNord/pdqhash-go/types" 12 "github.com/davidbyttow/govips/v2/vips" 13 14 _ "image/jpeg" 15 ) 16 17 // From Wikipedia: standard RGB to luminance (the 'Y' in 'YUV'). 18 const LUMA_FROM_R_COEFF = 0.299 19 const LUMA_FROM_G_COEFF = 0.587 20 const LUMA_FROM_B_COEFF = 0.114 21 22 func DCT_MATRIX_SCALE_FACTOR() float64 { 23 return math.Sqrt(2.0 / 64.0) 24 } 25 26 // Wojciech Jarosz 'Fast Image Convolutions' ACM SIGGRAPH 2001: 27 // X,Y,X,Y passes of 1-D box filters produces a 2D tent filter. 28 const PDQ_NUM_JAROSZ_XY_PASSES = 2 29 30 /* 31 Since PDQ uses 64x64 blocks, 1/64th of the image height/width 32 respectively is a full block. But since we use two passes, we want half 33 that window size per pass. Example: 1024x1024 full-resolution input. PDQ 34 downsamples to 64x64. Each 16x16 block of the input produces a single 35 downsample pixel. X,Y passes with window size 8 (= 1024/128) average 36 pixels with 8x8 neighbors. The second X,Y pair of 1D box-filter passes 37 accumulate data from all 16x16. 38 */ 39 const PDQ_JAROSZ_WINDOW_SIZE_DIVISOR = 128 40 41 // Flags for which dihedral-transforms are desired to be produced. 42 const PDQ_DO_DIH_ORIGINAL = 0x01 43 const PDQ_DO_DIH_ROTATE_90 = 0x02 44 const PDQ_DO_DIH_ROTATE_180 = 0x04 45 const PDQ_DO_DIH_ROTATE_270 = 0x08 46 const PDQ_DO_DIH_FLIPX = 0x10 47 const PDQ_DO_DIH_FLIPY = 0x20 48 const PDQ_DO_DIH_FLIP_PLUS1 = 0x40 49 const PDQ_DO_DIH_FLIP_MINUS1 = 0x80 50 const PDQ_DO_DIH_ALL = 0xFF 51 52 /** 53 * The only class state is the DCT matrix, so this class may either be 54 * instantiated once per image, or instantiated once and used for all images; 55 * the latter will be slightly faster as the DCT matrix will not need to be 56 * recomputed once per image. 57 */ 58 type PDQHasher struct { 59 DCT_matrix [][]float64 60 } 61 62 /** 63 * Container for multiple-value object: the hash is a 64-character hex 64 * string and the quality is an integer in the range 0..100. 65 */ 66 type HashAndQuality struct { 67 Hash *types.Hash256 68 Quality int 69 } 70 71 type HashesAndQuality struct { 72 hash *types.Hash256 73 hashRotate90 *types.Hash256 74 hashRotate180 *types.Hash256 75 hashRotate270 *types.Hash256 76 hashFlipX *types.Hash256 77 hashFlipY *types.Hash256 78 hashFlipPlus1 *types.Hash256 79 hashFlipMinus1 *types.Hash256 80 quality int 81 } 82 83 func NewPDQHasher() *PDQHasher { 84 return &PDQHasher{ 85 DCT_matrix: ComputeDCTMatrix(), 86 } 87 } 88 89 func ComputeDCTMatrix() [][]float64 { 90 d := make([][]float64, 16) 91 for i := 0; i < len(d); i++ { 92 di := make([]float64, 64) 93 for j := 0; j < len(di); j++ { 94 di[j] = DCT_MATRIX_SCALE_FACTOR() * math.Cos((math.Pi/2.0/64.0)*(float64(i)+1.0)*(2.0*float64(j)+1.0)) 95 } 96 d[i] = di 97 } 98 99 return d 100 } 101 102 func allocateMatrix(numRows, numCols int) [][]float64 { 103 // Create a slice of slices to represent the matrix 104 matrix := make([][]float64, numRows) 105 106 // Allocate memory for each row 107 for i := range matrix { 108 matrix[i] = make([]float64, numCols) 109 } 110 111 return matrix 112 } 113 114 func (p *PDQHasher) FromFile(filename string) HashAndQuality { 115 params := vips.NewImportParams() 116 params.AutoRotate.Set(false) 117 118 image, err := vips.LoadImageFromFile(filename, params) 119 if err != nil { 120 log.Fatalf("Error opening file: %v", err) 121 } 122 123 // resizing the image proportionally to max 512px width and max 512px height 124 err = image.ThumbnailWithSize(512, 512, vips.InterestingNone, vips.SizeDown) 125 if err != nil { 126 log.Fatalf("Error resizing image: %v", err) 127 } 128 numCols := image.Width() 129 numRows := image.Height() 130 131 buffer1 := make([]float64, numCols*numRows) 132 buffer2 := make([]float64, numCols*numRows) 133 buffer64x64 := allocateMatrix(64, 64) 134 buffer16x64 := allocateMatrix(16, 64) 135 buffer16x16 := allocateMatrix(16, 16) 136 137 return p.FromImage(image, buffer1, buffer2, buffer64x64, buffer16x64, buffer16x16) 138 } 139 140 func (p *PDQHasher) FromImage(image *vips.ImageRef, buffer1, buffer2 []float64, buffer64x64, buffer16x64, buffer16x16 [][]float64) HashAndQuality { 141 numCols := image.Width() 142 numRows := image.Height() 143 144 p.fillFloatLumaFromBufferImage(image, &buffer1) 145 146 return p.pdqHash256FromFloatLuma(buffer1, buffer2, numRows, numCols, buffer64x64, buffer16x64, buffer16x16) 147 } 148 149 func (p *PDQHasher) fillFloatLumaFromBufferImage(image *vips.ImageRef, luma *[]float64) { 150 numCols := image.Width() 151 numRows := image.Height() 152 153 err := image.ToColorSpace(vips.InterpretationSRGB) 154 if err != nil { 155 log.Fatalf("Error converting to RGB: %v", err) 156 } 157 158 for i := 0; i < numRows; i++ { 159 for j := 0; j < numCols; j++ { 160 colorArray, err := image.GetPoint(j, i) 161 if err != nil { 162 log.Fatalf("Error getting pixel: %v", err) 163 } 164 r := colorArray[0] 165 g := colorArray[1] 166 b := colorArray[2] 167 (*luma)[i*numCols+j] = LUMA_FROM_R_COEFF*float64(r) + LUMA_FROM_G_COEFF*float64(g) + LUMA_FROM_B_COEFF*float64(b) 168 } 169 } 170 } 171 172 func (p *PDQHasher) pdqHash256FromFloatLuma(fullBuffer1, fullBuffer2 []float64, numRows, numCols int, buffer64x64, buffer16x64, buffer16x16 [][]float64) HashAndQuality { 173 windowSizeAlongRows := p.computeJaroszWindowSize(numCols) 174 windowSizeAlongCols := p.computeJaroszWindowSize(numRows) 175 p.jaroszFilterFloat(&fullBuffer1, &fullBuffer2, numRows, numCols, windowSizeAlongRows, windowSizeAlongCols, PDQ_NUM_JAROSZ_XY_PASSES) 176 177 p.decimateFloat(&fullBuffer1, numRows, numCols, &buffer64x64) 178 quality := p.computePDQImageDomainQualityMetric(buffer64x64) 179 p.dct64To16(&buffer64x64, &buffer16x64, &buffer16x16) 180 hash := p.pdqBuffer16x16ToBits(buffer16x16) 181 182 return HashAndQuality{hash, quality} 183 } 184 185 func (p *PDQHasher) DihedralFromFile(filename string, dihedralFlags int) HashesAndQuality { 186 image, err := vips.NewImageFromFile(filename) 187 if err != nil { 188 log.Fatalf("Error opening file: %v", err) 189 } 190 191 numRows := image.Height() 192 numCols := image.Width() 193 194 buffer1 := make([]float64, numCols*numRows) 195 buffer2 := make([]float64, numCols*numRows) 196 197 buffer64x64 := allocateMatrix(64, 64) 198 buffer16x64 := allocateMatrix(16, 64) 199 buffer16x16 := allocateMatrix(16, 16) 200 buffer16x16Aux := allocateMatrix(16, 16) 201 202 return p.dihedralFromBufferedImage(image, buffer1, buffer2, buffer64x64, buffer16x64, buffer16x16, buffer16x16Aux, dihedralFlags) 203 } 204 205 func (p *PDQHasher) dihedralFromBufferedImage(image *vips.ImageRef, buffer1, buffer2 []float64, buffer64x64, buffer16x64, buffer16x16, buffer16x16Aux [][]float64, dihedralFlags int) HashesAndQuality { 206 numRows := image.Height() 207 numCols := image.Width() 208 209 p.fillFloatLumaFromBufferImage(image, &buffer1) 210 211 return p.pdqHash256esFromFloatLuma(buffer1, buffer2, numRows, numCols, buffer64x64, buffer16x64, buffer16x16, buffer16x16Aux, dihedralFlags) 212 } 213 214 func (p *PDQHasher) pdqHash256esFromFloatLuma(fullBuffer1, fullBuffer2 []float64, numRows, numCols int, buffer64x64, buffer16x64, buffer16x16, buffer16x16Aux [][]float64, dihedralFlags int) HashesAndQuality { 215 windowSizeAlongRows := p.computeJaroszWindowSize(numCols) 216 windowSizeAlongCols := p.computeJaroszWindowSize(numRows) 217 p.jaroszFilterFloat(&fullBuffer1, &fullBuffer2, numRows, numCols, windowSizeAlongRows, windowSizeAlongCols, PDQ_NUM_JAROSZ_XY_PASSES) 218 219 p.decimateFloat(&fullBuffer1, numRows, numCols, &buffer64x64) 220 quality := p.computePDQImageDomainQualityMetric(buffer64x64) 221 p.dct64To16(&buffer64x64, &buffer16x64, &buffer16x16) 222 223 var hash *types.Hash256 224 var hashRotate90 *types.Hash256 225 var hashRotate180 *types.Hash256 226 var hashRotate270 *types.Hash256 227 var hashFlipX *types.Hash256 228 var hashFlipY *types.Hash256 229 var hashFlipPlus1 *types.Hash256 230 var hashFlipMinus1 *types.Hash256 231 232 if dihedralFlags&PDQ_DO_DIH_ORIGINAL != 0 { 233 hash = p.pdqBuffer16x16ToBits(buffer16x16) 234 } 235 236 if dihedralFlags&PDQ_DO_DIH_ROTATE_90 != 0 { 237 p.dct16OriginalToRotate90(&buffer16x16, &buffer16x16Aux) 238 hashRotate90 = p.pdqBuffer16x16ToBits(buffer16x16Aux) 239 } 240 241 if dihedralFlags&PDQ_DO_DIH_ROTATE_180 != 0 { 242 p.dct16OriginalToRotate180(&buffer16x16, &buffer16x16Aux) 243 hashRotate180 = p.pdqBuffer16x16ToBits(buffer16x16Aux) 244 } 245 246 if dihedralFlags&PDQ_DO_DIH_ROTATE_270 != 0 { 247 p.dct16OriginalToRotate270(&buffer16x16, &buffer16x16Aux) 248 hashRotate270 = p.pdqBuffer16x16ToBits(buffer16x16Aux) 249 } 250 251 if dihedralFlags&PDQ_DO_DIH_FLIPX != 0 { 252 p.dct16OriginalToFlipX(&buffer16x16, &buffer16x16Aux) 253 hashFlipX = p.pdqBuffer16x16ToBits(buffer16x16Aux) 254 } 255 256 if dihedralFlags&PDQ_DO_DIH_FLIPY != 0 { 257 p.dct16OriginalToFlipY(&buffer16x16, &buffer16x16Aux) 258 hashFlipY = p.pdqBuffer16x16ToBits(buffer16x16Aux) 259 } 260 261 if dihedralFlags&PDQ_DO_DIH_FLIP_PLUS1 != 0 { 262 p.dct16OriginalToFlipPlus1(&buffer16x16, &buffer16x16Aux) 263 hashFlipPlus1 = p.pdqBuffer16x16ToBits(buffer16x16Aux) 264 } 265 266 if dihedralFlags&PDQ_DO_DIH_FLIP_MINUS1 != 0 { 267 p.dct16OriginalToFlipMinus1(&buffer16x16, &buffer16x16Aux) 268 hashFlipMinus1 = p.pdqBuffer16x16ToBits(buffer16x16Aux) 269 } 270 271 return HashesAndQuality{hash, hashRotate90, hashRotate180, hashRotate270, hashFlipX, hashFlipY, hashFlipPlus1, hashFlipMinus1, quality} 272 } 273 274 // numRows x numCols in row-major order 275 func (p *PDQHasher) decimateFloat(in *[]float64, inNumRows, inNumCols int, out *[][]float64) { 276 for i := 0; i < 64; i++ { 277 ini := int(((float64(i) + 0.5) * float64(inNumRows)) / 64.0) 278 for j := 0; j < 64; j++ { 279 inj := int(((float64(j) + 0.5) * float64(inNumCols)) / 64.0) 280 (*out)[i][j] = (*in)[ini*inNumCols+inj] 281 } 282 } 283 } 284 285 /** 286 * This is all heuristic (see the PDQ hashing doc). Quantization 287 * matters since we want to count *significant* gradients, not just the 288 * some of many small ones. The constants are all manually selected, and 289 * tuned as described in the document. 290 */ 291 func (p *PDQHasher) computePDQImageDomainQualityMetric(buffer64x64 [][]float64) int { 292 gradientSum := 0 293 for i := 0; i < 63; i++ { 294 for j := 0; j < 64; j++ { 295 u := buffer64x64[i][j] 296 v := buffer64x64[i+1][j] 297 d := int(((u - v) * 100.0) / 255.0) 298 gradientSum += int(helpers.Abs(d)) 299 } 300 } 301 for i := 0; i < 64; i++ { 302 for j := 0; j < 63; j++ { 303 u := buffer64x64[i][j] 304 v := buffer64x64[i][j+1] 305 d := int(((u - v) * 100.0) / 255.0) 306 gradientSum += int(helpers.Abs(d)) 307 } 308 } 309 quality := float64(gradientSum) / 90.0 310 if quality > 100 { 311 quality = 100 312 } 313 return int(quality) 314 } 315 316 /** 317 * Full 64x64 to 64x64 can be optimized e.g. the Lee algorithm. 318 * But here we only want slots (1-16)x(1-16) of the full 64x64 output. 319 * Careful experiments showed that using Lee along all 64 slots in one 320 * dimension, then Lee along 16 slots in the second, followed by 321 * extracting slots 1-16 of the output, was actually slower than the 322 * current implementation which is completely non-clever/non-Lee but 323 * computes only what is needed. 324 */ 325 func (p *PDQHasher) dct64To16(A, T, B *([][]float64)) { 326 D := p.DCT_matrix 327 328 *T = make([][]float64, 16) 329 for i := 0; i < 16; i++ { 330 ti := make([]float64, 64) 331 332 for j := 0; j < 64; j++ { 333 tij := float64(0.0) 334 for k := 0; k < 64; k++ { 335 tij += D[i][k] * (*A)[k][j] 336 } 337 ti[j] = tij 338 } 339 (*T)[i] = ti 340 } 341 342 for i := 0; i < 16; i++ { 343 for j := 0; j < 16; j++ { 344 sumk := float64(0.0) 345 for k := 0; k < 64; k++ { 346 sumk += (*T)[i][k] * D[j][k] 347 } 348 (*B)[i][j] = sumk 349 } 350 } 351 } 352 353 /* 354 ------------------------------------- 355 orig rot90 rot180 rot270 356 noxpose xpose noxpose xpose 357 + + + + - + - + + - + - - - - - 358 + + + + - + - + - + - + + + + + 359 + + + + - + - + + - + - - - - - 360 + + + + - + - + - + - + + + + + 361 362 flipx flipy flipplus flipminus 363 noxpose noxpose xpose xpose 364 - - - - - + - + + + + + + - + - 365 + + + + - + - + + + + + - + - + 366 - - - - - + - + + + + + + - + - 367 + + + + - + - + + + + + - + - + 368 ------------------------------------- 369 */ 370 371 func (p *PDQHasher) dct16OriginalToRotate90(A, B *[][]float64) { 372 for i := 0; i < 16; i++ { 373 for j := 0; j < 16; j++ { 374 if (j & 1) != 0 { 375 (*B)[j][i] = (*A)[i][j] 376 } else { 377 (*B)[j][i] = -(*A)[i][j] 378 } 379 } 380 } 381 } 382 383 func (p *PDQHasher) dct16OriginalToRotate180(A, B *[][]float64) { 384 for i := 0; i < 16; i++ { 385 for j := 0; j < 16; j++ { 386 if ((i + j) & 1) != 0 { 387 (*B)[i][j] = -(*A)[i][j] 388 } else { 389 (*B)[i][j] = (*A)[i][j] 390 } 391 } 392 } 393 } 394 395 func (p *PDQHasher) dct16OriginalToRotate270(A, B *[][]float64) { 396 for i := 0; i < 16; i++ { 397 for j := 0; j < 16; j++ { 398 if (i & 1) != 0 { 399 (*B)[j][i] = (*A)[i][j] 400 } else { 401 (*B)[j][i] = -(*A)[i][j] 402 } 403 } 404 } 405 } 406 407 func (p *PDQHasher) dct16OriginalToFlipX(A, B *[][]float64) { 408 for i := 0; i < 16; i++ { 409 for j := 0; j < 16; j++ { 410 if (i & 1) != 0 { 411 (*B)[i][j] = (*A)[i][j] 412 } else { 413 (*B)[i][j] = -(*A)[i][j] 414 } 415 } 416 } 417 } 418 419 func (p *PDQHasher) dct16OriginalToFlipY(A, B *[][]float64) { 420 for i := 0; i < 16; i++ { 421 for j := 0; j < 16; j++ { 422 if (j & 1) != 0 { 423 (*B)[i][j] = (*A)[i][j] 424 } else { 425 (*B)[i][j] = -(*A)[i][j] 426 } 427 } 428 } 429 } 430 431 func (p *PDQHasher) dct16OriginalToFlipPlus1(A, B *[][]float64) { 432 for i := 0; i < 16; i++ { 433 for j := 0; j < 16; j++ { 434 (*B)[j][i] = (*A)[i][j] 435 } 436 } 437 } 438 439 func (p *PDQHasher) dct16OriginalToFlipMinus1(A, B *[][]float64) { 440 for i := 0; i < 16; i++ { 441 for j := 0; j < 16; j++ { 442 if ((i + j) & 1) != 0 { 443 (*B)[j][i] = -(*A)[i][j] 444 } else { 445 (*B)[j][i] = (*A)[i][j] 446 } 447 } 448 } 449 } 450 451 /** 452 * Each bit of the 16x16 output hash is for whether the given frequency 453 * component is greater than the median frequency component or not. 454 */ 455 func (p *PDQHasher) pdqBuffer16x16ToBits(dctOutput16x16 [][]float64) *types.Hash256 { 456 hash := types.Hash256{} 457 dctMedian := helpers.Torben(dctOutput16x16, 16, 16) 458 for i := 0; i < 16; i++ { 459 for j := 0; j < 16; j++ { 460 if dctOutput16x16[i][j] > dctMedian { 461 hash.SetBit(i*16 + j) 462 } 463 } 464 } 465 return &hash 466 } 467 468 // Round up. 469 func (p *PDQHasher) computeJaroszWindowSize(dimension int) int { 470 result := (float64(dimension) + float64(PDQ_JAROSZ_WINDOW_SIZE_DIVISOR) - 1.0) / float64(PDQ_JAROSZ_WINDOW_SIZE_DIVISOR) 471 return int(result) 472 } 473 474 func (p *PDQHasher) jaroszFilterFloat(buffer1, buffer2 *[]float64, numRows, numCols, windowSizeAlongRows, windowSizeAlongCols, nreps int) { 475 for i := 0; i < nreps; i++ { 476 p.boxAlongRowsFloat(buffer1, buffer2, numRows, numCols, windowSizeAlongRows) 477 p.boxAlongColsFloat(buffer2, buffer1, numRows, numCols, windowSizeAlongCols) 478 } 479 } 480 481 func (p *PDQHasher) box1DFloat(invec *[]float64, inStartOffset int, outvec *[]float64, outStartOffset, vectorLength, stride, fullWindowSize int) { 482 halfWindowSize := int(((float64(fullWindowSize) + 2.0) / 2.0)) 483 phase_1_nreps := int(halfWindowSize - 1) 484 phase_2_nreps := int(fullWindowSize - halfWindowSize + 1) 485 phase_3_nreps := int(vectorLength - fullWindowSize) 486 phase_4_nreps := int(halfWindowSize - 1) 487 li := 0 // Index of left edge of read window, for subtracts 488 ri := 0 // Index of right edge of read windows, for adds 489 oi := 0 // Index of output vector 490 sum := float64(0.0) 491 currentWindowSize := 0 492 493 // PHASE 1: ACCUMULATE FIRST SUM NO WRITES 494 for i := 0; i < phase_1_nreps; i++ { 495 sum += (*invec)[inStartOffset+ri] 496 currentWindowSize += 1 497 ri += stride 498 } 499 500 // PHASE 2: INITIAL WRITES WITH SMALL WINDOW 501 for i := 0; i < phase_2_nreps; i++ { 502 sum += (*invec)[inStartOffset+ri] 503 currentWindowSize += 1 504 (*outvec)[outStartOffset+oi] = sum / float64(currentWindowSize) 505 ri += stride 506 oi += stride 507 } 508 509 // PHASE 3: WRITES WITH FULL WINDOW 510 for i := 0; i < phase_3_nreps; i++ { 511 sum += (*invec)[inStartOffset+ri] 512 sum -= (*invec)[inStartOffset+li] 513 (*outvec)[outStartOffset+oi] = sum / float64(currentWindowSize) 514 li += stride 515 ri += stride 516 oi += stride 517 } 518 519 // PHASE 4: FINAL WRITES WITH SMALL WINDOW 520 for i := 0; i < phase_4_nreps; i++ { 521 sum -= (*invec)[inStartOffset+li] 522 currentWindowSize -= 1 523 (*outvec)[outStartOffset+oi] = sum / float64(currentWindowSize) 524 li += stride 525 oi += stride 526 } 527 } 528 529 /** 530 * input - matrix as numRows x numCols in row-major order 531 * output - matrix as numRows x numCols in row-major order 532 */ 533 func (p *PDQHasher) boxAlongRowsFloat(input, output *[]float64, numRows, numCols, windowSize int) { 534 for i := 0; i < numRows; i++ { 535 p.box1DFloat(input, i*numCols, output, i*numCols, numCols, 1, windowSize) 536 } 537 } 538 539 func (p *PDQHasher) boxAlongColsFloat(input, output *[]float64, numRows, numCols, windowSize int) { 540 for i := 0; i < numCols; i++ { 541 p.box1DFloat(input, i, output, i, numRows, numCols, windowSize) 542 } 543 }