Explorar o código

ImageHashMatcher

Viktor Sokolov hai 2 semanas
pai
achega
eb46bd1983

+ 9 - 112
integration/load_test.go

@@ -2,17 +2,12 @@ package integration
 
 import (
 	"fmt"
-	"io"
 	"net/http"
 	"os"
 	"path"
 	"path/filepath"
-	"strings"
 	"testing"
-	"unsafe"
 
-	"github.com/corona10/goimagehash"
-	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/testutil"
 	"github.com/imgproxy/imgproxy/v3/vips"
@@ -26,17 +21,15 @@ const (
 type LoadTestSuite struct {
 	Suite
 
-	testImagesPath      string
-	hashesPath          string
-	saveTmpImagesPath   string
-	createMissingHashes bool
+	matcher           *testutil.ImageHashMatcher
+	testImagesPath    string
+	saveTmpImagesPath string
 }
 
 func (s *LoadTestSuite) SetupTest() {
 	s.testImagesPath = s.TestData.Path("test-images")
-	s.hashesPath = s.TestData.Path("test-hashes")
 	s.saveTmpImagesPath = os.Getenv("TEST_SAVE_TMP_IMAGES")
-	s.createMissingHashes = len(os.Getenv("TEST_CREATE_MISSING_HASHES")) > 0
+	s.matcher = testutil.NewImageHashMatcher(s.TestData)
 
 	s.Config().Security.DefaultOptions.MaxAnimationFrames = 999
 	s.Config().Server.DevelopmentErrorsMode = true
@@ -66,52 +59,13 @@ func (s *LoadTestSuite) testLoadFolder(folder string) {
 		sourceUrl := fmt.Sprintf("/insecure/plain/local:///%s/%s@bmp", folder, baseName)
 
 		// Read source image from imgproxy
-		sourceImageData := s.fetchImage(sourceUrl)
-		defer sourceImageData.Close()
+		resp := s.GET(sourceUrl)
+		defer resp.Body.Close()
 
-		// Save the source image if requested
-		s.saveTmpImage(folder, baseName, sourceImageData)
+		s.Require().Equal(http.StatusOK, resp.StatusCode, "expected status code 200 OK, got %d, path: %s", resp.StatusCode, path)
 
-		// Calculate image hash of the image returned by imgproxy
-		var sourceImage vips.Image
-		s.Require().NoError(sourceImage.Load(sourceImageData, 1, 1.0, 1))
-		defer sourceImage.Clear()
-
-		sourceHash, err := testutil.ImageDifferenceHash(unsafe.Pointer(sourceImage.VipsImage))
-		s.Require().NoError(err)
-
-		// Calculate image hash path (create folder if missing)
-		hashPath, err := s.makeTargetPath(s.hashesPath, s.T().Name(), baseName, "hash")
-		s.Require().NoError(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 !s.createMissingHashes {
-				s.Require().NoError(err, "failed to read target hash from %s, use TEST_CREATE_MISSING_HASHES=true to create it", hashPath)
-			}
-
-			h, hashErr := os.Create(hashPath)
-			s.Require().NoError(hashErr, "failed to create target hash file %s", hashPath)
-			defer h.Close()
-
-			hashErr = sourceHash.Dump(h)
-			s.Require().NoError(hashErr, "failed to write target hash to %s", hashPath)
-
-			s.T().Logf("Created missing hash in %s", hashPath)
-		} else {
-			// Otherwise, if there is no error or error is something else
-			s.Require().NoError(err)
-
-			targetHash, err := goimagehash.LoadImageHash(f)
-			s.Require().NoError(err, "failed to load target hash from %s", hashPath)
-
-			distance, err := sourceHash.Distance(targetHash)
-			s.Require().NoError(err, "failed to calculate hash distance for %s", baseName)
-
-			s.Require().LessOrEqual(distance, maxDistance, "image hashes are too different for %s: distance %d", baseName, distance)
-		}
+		// Match image to precalculated hash
+		s.matcher.ImageMatches(s.T(), resp.Body, baseName, maxDistance)
 
 		return nil
 	})
@@ -119,63 +73,6 @@ func (s *LoadTestSuite) testLoadFolder(folder string) {
 	s.Require().NoError(err)
 }
 
-// fetchImage fetches an image from the imgproxy server
-func (s *LoadTestSuite) fetchImage(path string) imagedata.ImageData {
-	resp := s.GET(path)
-	defer resp.Body.Close()
-
-	s.Require().Equal(http.StatusOK, resp.StatusCode, "expected status code 200 OK, got %d, path: %s", resp.StatusCode, path)
-
-	bytes, err := io.ReadAll(resp.Body)
-	s.Require().NoError(err, "failed to read response body from %s", path)
-
-	d, err := s.Imgproxy().ImageDataFactory().NewFromBytes(bytes)
-	s.Require().NoError(err, "failed to load image from bytes for %s", path)
-
-	return d
-}
-
-// makeTargetPath creates the target directory and returns file path for saving
-// the image or hash.
-func (s *LoadTestSuite) makeTargetPath(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)
-	s.Require().NoError(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 (s *LoadTestSuite) saveTmpImage(folder, filename string, imageData imagedata.ImageData) {
-	if s.saveTmpImagesPath == "" {
-		return
-	}
-
-	// Detect the image type to get the correct extension
-	ext, err := imagetype.Detect(imageData.Reader())
-	s.Require().NoError(err)
-
-	targetPath, err := s.makeTargetPath(s.saveTmpImagesPath, folder, filename, ext.String())
-	s.Require().NoError(err, "failed to create TEST_SAVE_TMP_IMAGES target path for %s/%s", folder, filename)
-
-	targetFile, err := os.Create(targetPath)
-	s.Require().NoError(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, imageData.Reader())
-	s.Require().NoError(err, "failed to write to TEST_SAVE_TMP_IMAGES target file %s", targetPath)
-
-	s.T().Logf("Saved temporary image to %s", targetPath)
-}
-
 // TestLoadSaveToPng ensures that our load pipeline works,
 // including standard and custom loaders. For each source image
 // in the folder, it does the passthrough request through imgproxy:

+ 0 - 7
processing/processing_test.go

@@ -6,7 +6,6 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
-	"unsafe"
 
 	"github.com/stretchr/testify/suite"
 
@@ -252,12 +251,6 @@ func (s *ProcessingTestSuite) TestResizeToFitExtendAR() {
 			s.Require().NotNil(result)
 
 			s.checkSize(result, tc.outWidth, tc.outHeight)
-
-			var i vips.Image
-			s.Require().NoError(i.Load(result.OutData, 1, 1.0, 1))
-			h, err := testutil.ImageDifferenceHash(unsafe.Pointer(i.VipsImage))
-			s.Require().NoError(err)
-			fmt.Println(h.ToString())
 		})
 	}
 }

+ 0 - 69
testutil/image_hash.go

@@ -1,69 +0,0 @@
-package testutil
-
-/*
-#cgo pkg-config: vips
-#cgo CFLAGS: -O3
-#cgo LDFLAGS: -lm
-#include <vips/vips.h>
-#include "image_hash.h"
-*/
-import "C"
-import (
-	"fmt"
-	"image"
-	"unsafe"
-
-	"github.com/corona10/goimagehash"
-)
-
-// ImageDifferenceHash calculates a hash of the VipsImage
-func ImageDifferenceHash(vipsImgPtr unsafe.Pointer) (*goimagehash.ImageHash, error) {
-	vipsImg := (*C.VipsImage)(vipsImgPtr)
-
-	// Convert to RGBA and read into memory using VIPS
-	var data unsafe.Pointer
-	var size C.size_t
-
-	// no one knows why this triggers linter
-	//nolint:gocritic
-	loadErr := C.vips_image_read_to_memory(vipsImg, &data, &size)
-	if loadErr != 0 {
-		return nil, fmt.Errorf("failed to convert VipsImage to RGBA memory")
-	}
-	defer C.vips_memory_buffer_free(data)
-
-	// Convert raw RGBA pixel data to Go image.Image
-	goImg, err := createRGBAFromRGBAPixels(vipsImg, data, size)
-	if err != nil {
-		return nil, fmt.Errorf("failed to convert RGBA pixel data to image.Image: %v", err)
-	}
-
-	hash, err := goimagehash.DifferenceHash(goImg)
-	if err != nil {
-		return nil, err
-	}
-
-	return hash, err
-}
-
-// createRGBAFromRGBAPixels creates a Go image.Image from raw RGBA VIPS pixel data
-func createRGBAFromRGBAPixels(vipsImg *C.VipsImage, data unsafe.Pointer, size C.size_t) (*image.RGBA, error) {
-	width := int(vipsImg.Xsize)
-	height := int(vipsImg.Ysize)
-
-	// 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
-}

+ 0 - 15
testutil/image_hash.h

@@ -1,15 +0,0 @@
-#include <stdlib.h>
-#include <stdint.h> // uintptr_t
-
-#include <vips/vips.h>
-
-#ifndef TESTUTIL_IMAGE_HASH_H
-#define TESTUTIL_IMAGE_HASH_H
-
-// Function to read VipsImage as RGBA into memory buffer
-int vips_image_read_to_memory(VipsImage *in, void **buf, size_t *size);
-
-// Function to free/discard the memory buffer
-void vips_memory_buffer_free(void *buf);
-
-#endif

+ 23 - 17
testutil/image_hash.c → testutil/image_hash_matcher.c

@@ -1,4 +1,4 @@
-#include "image_hash.h"
+#include "image_hash_matcher.h"
 
 /**
  * vips_image_read_to_memory: converts VipsImage to RGBA format and reads into memory buffer
@@ -12,48 +12,54 @@
  * Returns: 0 on success, -1 on error
  */
 int
-vips_image_read_to_memory(VipsImage *in, void **buf, size_t *size)
+vips_image_read_from_to_memory(void *in, size_t in_size, void **out, size_t *out_size, int *out_width, int *out_height)
 {
-  VipsImage *rgba_image = NULL;
+  if (!in || !out || !out_size || !out_width || !out_height) {
+    vips_error("vips_image_read_from_to_memory", "invalid arguments");
+    return -1;
+  }
 
-  if (!in || !buf || !size) {
-    vips_error("vips_image_read_to_memory", "invalid arguments");
+  VipsImage *base = vips_image_new_from_buffer(in, in_size, "", NULL);
+  if (base == NULL) {
     return -1;
   }
 
-  VipsImage *base = vips_image_new();
   VipsImage **t = (VipsImage **) vips_object_local_array(VIPS_OBJECT(base), 2);
 
   // Initialize output parameters
-  *buf = NULL;
-  *size = 0;
+  *out = NULL;
+  *out_size = 0;
 
   // Convert to sRGB colorspace first if needed
-  if (vips_colourspace(in, &t[0], VIPS_INTERPRETATION_sRGB, NULL) != 0) {
+  if (vips_colourspace(base, &t[0], VIPS_INTERPRETATION_sRGB, NULL) != 0) {
     VIPS_UNREF(base);
-    vips_error("vips_image_read_to_memory", "failed to convert to sRGB");
+    vips_error("vips_image_read_from_to_memory", "failed to convert to sRGB");
     return -1;
   }
 
   in = t[0];
 
   // Add alpha channel if not present (convert to RGBA)
-  if (!vips_image_hasalpha(in)) {
+  if (!vips_image_hasalpha(base)) {
     // Add alpha channel
-    if (vips_addalpha(in, &t[1], NULL) != 0) {
+    if (vips_addalpha(base, &t[1], NULL) != 0) {
       VIPS_UNREF(base);
-      vips_error("vips_image_read_to_memory", "failed to add alpha channel");
+      vips_error("vips_image_read_from_to_memory", "failed to add alpha channel");
       return -1;
     }
     in = t[1];
   }
 
-  // Get raw pixel data
-  *buf = vips_image_write_to_memory(in, size);
+  // Get raw pixel data, width and height
+  *out = vips_image_write_to_memory(in, out_size);
+  *out_width = base->Xsize;
+  *out_height = base->Ysize;
+
+  // Dispose the image regardless of the result
   VIPS_UNREF(base);
 
-  if (*buf == NULL) {
-    vips_error("vips_image_read_to_memory", "failed to write image to memory");
+  if (*out == NULL) {
+    vips_error("vips_image_read_from_to_memory", "failed to write image to memory");
     return -1;
   }
 

+ 189 - 0
testutil/image_hash_matcher.go

@@ -0,0 +1,189 @@
+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
+}

+ 19 - 0
testutil/image_hash_matcher.h

@@ -0,0 +1,19 @@
+#include <stdlib.h>
+#include <stdint.h> // uintptr_t
+
+#include <vips/vips.h>
+
+#ifndef TESTUTIL_IMAGE_HASH_MATCHER_H
+#define TESTUTIL_IMAGE_HASH_MATCHER_H
+
+// Function to read VipsImage as RGBA into memory buffer
+int vips_image_read_from_to_memory(
+    void *in, size_t in_size,       // inner raw buffer and its size
+    void **out, size_t *out_size,   // out raw buffer an its size
+    int *out_width, int *out_height // out image width and height
+);
+
+// Function to free/discard the memory buffer
+void vips_memory_buffer_free(void *buf);
+
+#endif