1
0

image_hash_matcher.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. package testutil
  2. /*
  3. #cgo pkg-config: vips
  4. #cgo CFLAGS: -O3
  5. #cgo LDFLAGS: -lm
  6. #include <vips/vips.h>
  7. #include "image_hash_matcher.h"
  8. */
  9. import "C"
  10. import (
  11. "bytes"
  12. "fmt"
  13. "image"
  14. "io"
  15. "os"
  16. "path"
  17. "path/filepath"
  18. "strings"
  19. "testing"
  20. "unsafe"
  21. "github.com/corona10/goimagehash"
  22. "github.com/imgproxy/imgproxy/v3/imagetype"
  23. "github.com/stretchr/testify/require"
  24. )
  25. const (
  26. // hashPath is a path to hash data in testdata folder
  27. hashPath = "test-hashes"
  28. // If TEST_CREATE_MISSING_HASHES is set, matcher would create missing hash files
  29. createMissingHashesEnv = "TEST_CREATE_MISSING_HASHES"
  30. // If this is set, the images are saved to this folder before hash is calculated
  31. saveTmpImagesPathEnv = "TEST_SAVE_TMP_IMAGES_PATH"
  32. )
  33. // ImageHashMatcher is a helper struct for image hash comparison in tests
  34. type ImageHashMatcher struct {
  35. hashesPath string
  36. createMissingHashes bool
  37. saveTmpImagesPath string
  38. }
  39. // NewImageHashMatcher creates a new ImageHashMatcher instance
  40. func NewImageHashMatcher(testDataProvider *TestDataProvider) *ImageHashMatcher {
  41. hashesPath := testDataProvider.Path(hashPath)
  42. createMissingHashes := len(os.Getenv(createMissingHashesEnv)) > 0
  43. saveTmpImagesPath := os.Getenv(saveTmpImagesPathEnv)
  44. return &ImageHashMatcher{
  45. hashesPath: hashesPath,
  46. createMissingHashes: createMissingHashes,
  47. saveTmpImagesPath: saveTmpImagesPath,
  48. }
  49. }
  50. // ImageHashMatches is a testing helper, which accepts image as reader, calculates
  51. // difference hash and compares it with a hash saved to testdata/test-hashes
  52. // folder.
  53. func (m *ImageHashMatcher) ImageMatches(t *testing.T, img io.Reader, key string, maxDistance int) {
  54. t.Helper()
  55. // Read image in memory
  56. buf, err := io.ReadAll(img)
  57. require.NoError(t, err)
  58. // Save tmp image if requested
  59. m.saveTmpImage(t, key, buf)
  60. // Convert to RGBA and read into memory using VIPS
  61. var data unsafe.Pointer
  62. var size C.size_t
  63. var width, height C.int
  64. // no one knows why this triggers linter
  65. //nolint:gocritic
  66. readErr := C.vips_image_read_from_to_memory(unsafe.Pointer(unsafe.SliceData(buf)), C.size_t(len(buf)), &data, &size, &width, &height)
  67. if readErr != 0 {
  68. t.Fatal("failed to load image using VIPS") // FailNow()
  69. }
  70. defer C.vips_memory_buffer_free(data)
  71. // Convert raw RGBA pixel data to Go image.Image
  72. goImg, err := createRGBAFromRGBAPixels(int(width), int(height), data, size)
  73. require.NoError(t, err)
  74. sourceHash, err := goimagehash.DifferenceHash(goImg)
  75. require.NoError(t, err)
  76. // Calculate image hash path (create folder if missing)
  77. hashPath, err := m.makeTargetPath(t, m.hashesPath, t.Name(), key, "hash")
  78. require.NoError(t, err)
  79. // Try to read or create the hash file
  80. f, err := os.Open(hashPath)
  81. if os.IsNotExist(err) {
  82. // If the hash file does not exist, and we are not allowed to create it, fail
  83. if !m.createMissingHashes {
  84. require.NoError(t, err, "failed to read target hash from %s, use TEST_CREATE_MISSING_HASHES=true to create it", hashPath)
  85. }
  86. // Create missing hash file
  87. h, hashErr := os.Create(hashPath)
  88. require.NoError(t, hashErr, "failed to create target hash file %s", hashPath)
  89. defer h.Close()
  90. // Dump calculated source hash to this hash file
  91. hashErr = sourceHash.Dump(h)
  92. require.NoError(t, hashErr, "failed to write target hash to %s", hashPath)
  93. t.Logf("Created missing hash in %s", hashPath)
  94. return
  95. }
  96. // Otherwise, if there is no error or error is something else
  97. require.NoError(t, err)
  98. // Load image hash from hash file
  99. targetHash, err := goimagehash.LoadImageHash(f)
  100. require.NoError(t, err, "failed to load target hash from %s", hashPath)
  101. // Ensure distance is OK
  102. distance, err := sourceHash.Distance(targetHash)
  103. require.NoError(t, err, "failed to calculate hash distance for %s", key)
  104. require.LessOrEqual(t, distance, maxDistance, "image hashes are too different for %s: distance %d", key, distance)
  105. }
  106. // makeTargetPath creates the target directory and returns file path for saving
  107. // the image or hash.
  108. func (m *ImageHashMatcher) makeTargetPath(t *testing.T, base, folder, filename, ext string) (string, error) {
  109. // Create the target directory if it doesn't exist
  110. targetDir := path.Join(base, folder)
  111. err := os.MkdirAll(targetDir, 0755)
  112. require.NoError(t, err, "failed to create %s target directory", targetDir)
  113. // Replace the extension with the detected one
  114. filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + "." + ext
  115. // Create the target file
  116. targetPath := path.Join(targetDir, filename)
  117. return targetPath, nil
  118. }
  119. // saveTmpImage saves the provided image data to a temporary file
  120. func (m *ImageHashMatcher) saveTmpImage(t *testing.T, key string, buf []byte) {
  121. if m.saveTmpImagesPath == "" {
  122. return
  123. }
  124. // Detect the image type to get the correct extension
  125. ext, err := imagetype.Detect(bytes.NewReader(buf))
  126. require.NoError(t, err)
  127. targetPath, err := m.makeTargetPath(t, m.saveTmpImagesPath, t.Name(), key, ext.String())
  128. require.NoError(t, err, "failed to create TEST_SAVE_TMP_IMAGES target path for %s/%s", t.Name(), key)
  129. targetFile, err := os.Create(targetPath)
  130. require.NoError(t, err, "failed to create TEST_SAVE_TMP_IMAGES target file %s", targetPath)
  131. defer targetFile.Close()
  132. // Write the image data to the file
  133. _, err = io.Copy(targetFile, bytes.NewReader(buf))
  134. require.NoError(t, err, "failed to write to TEST_SAVE_TMP_IMAGES target file %s", targetPath)
  135. t.Logf("Saved temporary image to %s", targetPath)
  136. }
  137. // createRGBAFromRGBAPixels creates a Go image.Image from raw RGBA VIPS pixel data
  138. func createRGBAFromRGBAPixels(width, height int, data unsafe.Pointer, size C.size_t) (*image.RGBA, error) {
  139. // RGBA should have 4 bands
  140. expectedSize := width * height * 4
  141. if int(size) != expectedSize {
  142. return nil, fmt.Errorf("size mismatch: expected %d bytes for RGBA, got %d", expectedSize, int(size))
  143. }
  144. pixels := unsafe.Slice((*byte)(data), int(size))
  145. // Create RGBA image
  146. img := image.NewRGBA(image.Rect(0, 0, width, height))
  147. // Copy RGBA pixel data directly
  148. copy(img.Pix, pixels)
  149. return img, nil
  150. }