image_hash_matcher.go 6.1 KB

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