123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189 |
- package testutil
- /*
- #cgo pkg-config: vips
- #cgo CFLAGS: -O3
- #cgo LDFLAGS: -lm
- #include <vips/vips.h>
- #include "image_hash_matcher.h"
- */
- import "C"
- import (
- "bytes"
- "fmt"
- "image"
- "io"
- "os"
- "path"
- "path/filepath"
- "strings"
- "testing"
- "unsafe"
- "github.com/corona10/goimagehash"
- "github.com/imgproxy/imgproxy/v3/imagetype"
- "github.com/stretchr/testify/require"
- )
- const (
- // hashPath is a path to hash data in testdata folder
- hashPath = "test-hashes"
- // If TEST_CREATE_MISSING_HASHES is set, matcher would create missing hash files
- createMissingHashesEnv = "TEST_CREATE_MISSING_HASHES"
- // If this is set, the images are saved to this folder before hash is calculated
- saveTmpImagesPathEnv = "TEST_SAVE_TMP_IMAGES_PATH"
- )
- // ImageHashMatcher is a helper struct for image hash comparison in tests
- type ImageHashMatcher struct {
- hashesPath string
- createMissingHashes bool
- saveTmpImagesPath string
- }
- // NewImageHashMatcher creates a new ImageHashMatcher instance
- func NewImageHashMatcher(testDataProvider *TestDataProvider) *ImageHashMatcher {
- hashesPath := testDataProvider.Path(hashPath)
- createMissingHashes := len(os.Getenv(createMissingHashesEnv)) > 0
- saveTmpImagesPath := os.Getenv(saveTmpImagesPathEnv)
- return &ImageHashMatcher{
- hashesPath: hashesPath,
- createMissingHashes: createMissingHashes,
- saveTmpImagesPath: saveTmpImagesPath,
- }
- }
- // ImageHashMatches is a testing helper, which accepts image as reader, calculates
- // difference hash and compares it with a hash saved to testdata/test-hashes
- // folder.
- func (m *ImageHashMatcher) ImageMatches(t *testing.T, img io.Reader, key string, maxDistance int) {
- t.Helper()
- // Read image in memory
- buf, err := io.ReadAll(img)
- require.NoError(t, err)
- // Save tmp image if requested
- m.saveTmpImage(t, key, buf)
- // Convert to RGBA and read into memory using VIPS
- var data unsafe.Pointer
- var size C.size_t
- var width, height C.int
- // no one knows why this triggers linter
- //nolint:gocritic
- readErr := C.vips_image_read_from_to_memory(unsafe.Pointer(unsafe.SliceData(buf)), C.size_t(len(buf)), &data, &size, &width, &height)
- if readErr != 0 {
- t.Fatal("failed to load image using VIPS") // FailNow()
- }
- defer C.vips_memory_buffer_free(data)
- // Convert raw RGBA pixel data to Go image.Image
- goImg, err := createRGBAFromRGBAPixels(int(width), int(height), data, size)
- require.NoError(t, err)
- sourceHash, err := goimagehash.DifferenceHash(goImg)
- require.NoError(t, err)
- // Calculate image hash path (create folder if missing)
- hashPath, err := m.makeTargetPath(t, m.hashesPath, t.Name(), key, "hash")
- require.NoError(t, err)
- // Try to read or create the hash file
- f, err := os.Open(hashPath)
- if os.IsNotExist(err) {
- // If the hash file does not exist, and we are not allowed to create it, fail
- if !m.createMissingHashes {
- require.NoError(t, err, "failed to read target hash from %s, use TEST_CREATE_MISSING_HASHES=true to create it", hashPath)
- }
- // Create missing hash file
- h, hashErr := os.Create(hashPath)
- require.NoError(t, hashErr, "failed to create target hash file %s", hashPath)
- defer h.Close()
- // Dump calculated source hash to this hash file
- hashErr = sourceHash.Dump(h)
- require.NoError(t, hashErr, "failed to write target hash to %s", hashPath)
- t.Logf("Created missing hash in %s", hashPath)
- return
- }
- // Otherwise, if there is no error or error is something else
- require.NoError(t, err)
- // Load image hash from hash file
- targetHash, err := goimagehash.LoadImageHash(f)
- require.NoError(t, err, "failed to load target hash from %s", hashPath)
- // Ensure distance is OK
- distance, err := sourceHash.Distance(targetHash)
- require.NoError(t, err, "failed to calculate hash distance for %s", key)
- require.LessOrEqual(t, distance, maxDistance, "image hashes are too different for %s: distance %d", key, distance)
- }
- // makeTargetPath creates the target directory and returns file path for saving
- // the image or hash.
- func (m *ImageHashMatcher) makeTargetPath(t *testing.T, base, folder, filename, ext string) (string, error) {
- // Create the target directory if it doesn't exist
- targetDir := path.Join(base, folder)
- err := os.MkdirAll(targetDir, 0755)
- require.NoError(t, err, "failed to create %s target directory", targetDir)
- // Replace the extension with the detected one
- filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + "." + ext
- // Create the target file
- targetPath := path.Join(targetDir, filename)
- return targetPath, nil
- }
- // saveTmpImage saves the provided image data to a temporary file
- func (m *ImageHashMatcher) saveTmpImage(t *testing.T, key string, buf []byte) {
- if m.saveTmpImagesPath == "" {
- return
- }
- // Detect the image type to get the correct extension
- ext, err := imagetype.Detect(bytes.NewReader(buf))
- require.NoError(t, err)
- targetPath, err := m.makeTargetPath(t, m.saveTmpImagesPath, t.Name(), key, ext.String())
- require.NoError(t, err, "failed to create TEST_SAVE_TMP_IMAGES target path for %s/%s", t.Name(), key)
- targetFile, err := os.Create(targetPath)
- require.NoError(t, err, "failed to create TEST_SAVE_TMP_IMAGES target file %s", targetPath)
- defer targetFile.Close()
- // Write the image data to the file
- _, err = io.Copy(targetFile, bytes.NewReader(buf))
- require.NoError(t, err, "failed to write to TEST_SAVE_TMP_IMAGES target file %s", targetPath)
- t.Logf("Saved temporary image to %s", targetPath)
- }
- // createRGBAFromRGBAPixels creates a Go image.Image from raw RGBA VIPS pixel data
- func createRGBAFromRGBAPixels(width, height int, data unsafe.Pointer, size C.size_t) (*image.RGBA, error) {
- // RGBA should have 4 bands
- expectedSize := width * height * 4
- if int(size) != expectedSize {
- return nil, fmt.Errorf("size mismatch: expected %d bytes for RGBA, got %d", expectedSize, int(size))
- }
- pixels := unsafe.Slice((*byte)(data), int(size))
- // Create RGBA image
- img := image.NewRGBA(image.Rect(0, 0, width, height))
- // Copy RGBA pixel data directly
- copy(img.Pix, pixels)
- return img, nil
- }
|