Files
imgproxy/testutil/image_hash_matcher.go
Victor Sokolov 30a5122d08 IMG-59: processing_test.go became lazy, added test image hash calculation (#1532)
* lazy processing test

* VHS

* ImageHashMatcher
2025-09-22 19:32:34 +02:00

190 lines
5.9 KiB
Go

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
}