فهرست منبع

IMG-59: processing_test.go became lazy, added test image hash calculation (#1532)

* lazy processing test

* VHS

* ImageHashMatcher
Victor Sokolov 2 هفته پیش
والد
کامیت
30a5122d08
31فایلهای تغییر یافته به همراه364 افزوده شده و 94 حذف شده
  1. 3 0
      .devcontainer/Dockerfile
  2. 17 53
      integration/load_test.go
  3. 51 38
      processing/processing_test.go
  4. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/1-bpp.hash
  5. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/16-bpp.hash
  6. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp-no-alpha-mask.hash
  7. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp.hash
  8. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha-self-gen.hash
  9. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha.hash
  10. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/4-bpp.hash
  11. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-move-to-x.hash
  12. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-single-color.hash
  13. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-small.hash
  14. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle.hash
  15. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp.hash
  16. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/gif.hash
  17. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/heif.hash
  18. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jpg.hash
  19. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jxl.hash
  20. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-bmp.hash
  21. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-png.hash
  22. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/png-256x256.hash
  23. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/single-bmp.hash
  24. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/svg.hash
  25. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/tiff.hash
  26. BIN
      testdata/test-hashes/TestIntegration/TestLoadSaveToPng/webp.hash
  27. 82 0
      testutil/image_hash_matcher.c
  28. 189 0
      testutil/image_hash_matcher.go
  29. 19 0
      testutil/image_hash_matcher.h
  30. 1 1
      vips/bmpsave.c
  31. 2 2
      vips/source.h

+ 3 - 0
.devcontainer/Dockerfile

@@ -39,6 +39,9 @@ RUN go install github.com/air-verse/air@latest
 # Install lefthook
 RUN go install github.com/evilmartians/lefthook@latest
 
+# Install gotestsum
+RUN go install gotest.tools/gotestsum@latest
+
 # Install golangci-lint
 RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.1.6
 

+ 17 - 53
integration/load_test.go

@@ -1,40 +1,38 @@
 package integration
 
 import (
-	"bytes"
 	"fmt"
-	"image/png"
-	"io"
 	"net/http"
 	"os"
 	"path"
 	"path/filepath"
-	"strings"
 	"testing"
 
-	"github.com/corona10/goimagehash"
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
+	"github.com/imgproxy/imgproxy/v3/testutil"
 	"github.com/imgproxy/imgproxy/v3/vips"
 	"github.com/stretchr/testify/suite"
 )
 
 const (
-	similarityThreshold = 5 // Distance between images to be considered similar
+	maxDistance = 0 // maximum image distance
 )
 
 type LoadTestSuite struct {
 	Suite
 
-	testImagesPath string
+	matcher           *testutil.ImageHashMatcher
+	testImagesPath    string
+	saveTmpImagesPath string
 }
 
 func (s *LoadTestSuite) SetupTest() {
 	s.testImagesPath = s.TestData.Path("test-images")
+	s.saveTmpImagesPath = os.Getenv("TEST_SAVE_TMP_IMAGES")
+	s.matcher = testutil.NewImageHashMatcher(s.TestData)
 
-	config.MaxAnimationFrames = 999
-	config.DevelopmentErrorsMode = true
-
+	s.Config().Security.DefaultOptions.MaxAnimationFrames = 999
+	s.Config().Server.DevelopmentErrorsMode = true
 	s.Config().Fetcher.Transport.Local.Root = s.testImagesPath
 }
 
@@ -55,40 +53,19 @@ func (s *LoadTestSuite) testLoadFolder(folder string) {
 		}
 
 		// get the base name of the file (8-bpp.png)
-		basePath := filepath.Base(path)
-
-		// Replace the extension with .png
-		referencePath := strings.TrimSuffix(basePath, filepath.Ext(basePath)) + ".png"
-
-		// Construct the full path to the reference image (integration/ folder)
-		referencePath = filepath.Join(s.testImagesPath, "integration", folder, referencePath)
+		baseName := filepath.Base(path)
 
 		// Construct the source URL for imgproxy (no processing)
-		sourceUrl := fmt.Sprintf("/insecure/plain/local:///%s/%s@png", folder, basePath)
-
-		imgproxyImageBytes := s.fetchImage(sourceUrl)
-		imgproxyImage, err := png.Decode(bytes.NewReader(imgproxyImageBytes))
-		s.Require().NoError(err, "Failed to decode PNG image from imgproxy for %s", basePath)
-
-		referenceFile, err := os.Open(referencePath)
-		s.Require().NoError(err)
-		defer referenceFile.Close()
-
-		referenceImage, err := png.Decode(referenceFile)
-		s.Require().NoError(err, "Failed to decode PNG reference image for %s", referencePath)
-
-		hash1, err := goimagehash.DifferenceHash(imgproxyImage)
-		s.Require().NoError(err)
+		sourceUrl := fmt.Sprintf("/insecure/plain/local:///%s/%s@bmp", folder, baseName)
 
-		hash2, err := goimagehash.DifferenceHash(referenceImage)
-		s.Require().NoError(err)
+		// Read source image from imgproxy
+		resp := s.GET(sourceUrl)
+		defer resp.Body.Close()
 
-		distance, err := hash1.Distance(hash2)
-		s.Require().NoError(err)
+		s.Require().Equal(http.StatusOK, resp.StatusCode, "expected status code 200 OK, got %d, path: %s", resp.StatusCode, path)
 
-		s.Require().LessOrEqual(distance, similarityThreshold,
-			"Image %s differs from reference image %s by %d, which is greater than the allowed threshold of %d",
-			basePath, referencePath, distance, similarityThreshold)
+		// Match image to precalculated hash
+		s.matcher.ImageMatches(s.T(), resp.Body, baseName, maxDistance)
 
 		return nil
 	})
@@ -96,19 +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) []byte {
-	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)
-
-	return bytes
-}
-
 // 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:

+ 51 - 38
processing/processing_test.go

@@ -9,51 +9,64 @@ import (
 
 	"github.com/stretchr/testify/suite"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/fetcher"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/logger"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/security"
+	"github.com/imgproxy/imgproxy/v3/testutil"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
 type ProcessingTestSuite struct {
-	suite.Suite
-	idf *imagedata.Factory
-	pof *options.Factory
+	testutil.LazySuite
+
+	imageDataFactory testutil.LazyObj[*imagedata.Factory]
+	securityConfig   testutil.LazyObj[*security.Config]
+	security         testutil.LazyObj[*security.Checker]
+	poConfig         testutil.LazyObj[*options.Config]
+	po               testutil.LazyObj[*options.Factory]
 }
 
 func (s *ProcessingTestSuite) SetupSuite() {
-	config.Reset()
-
-	config.MaxSrcResolution = 10 * 1024 * 1024
-	config.MaxSrcFileSize = 10 * 1024 * 1024
-	config.MaxAnimationFrames = 100
-	config.MaxAnimationFrameResolution = 10 * 1024 * 1024
-
 	s.Require().NoError(vips.Init())
 
 	logger.Mute()
 
-	fc := fetcher.NewDefaultConfig()
-	f, err := fetcher.New(&fc)
-	s.Require().NoError(err)
+	s.imageDataFactory, _ = testutil.NewLazySuiteObj(s, func() (*imagedata.Factory, error) {
+		c := fetcher.NewDefaultConfig()
+		f, err := fetcher.New(&c)
+		if err != nil {
+			return nil, err
+		}
 
-	s.idf = imagedata.NewFactory(f)
+		return imagedata.NewFactory(f), nil
+	})
 
-	scfg, err := security.LoadConfigFromEnv(nil)
-	s.Require().NoError(err)
+	s.securityConfig, _ = testutil.NewLazySuiteObj(s, func() (*security.Config, error) {
+		c := security.NewDefaultConfig()
 
-	security, err := security.New(scfg)
-	s.Require().NoError(err)
+		c.DefaultOptions.MaxSrcResolution = 10 * 1024 * 1024
+		c.DefaultOptions.MaxSrcFileSize = 10 * 1024 * 1024
+		c.DefaultOptions.MaxAnimationFrames = 100
+		c.DefaultOptions.MaxAnimationFrameResolution = 10 * 1024 * 1024
 
-	cfg, err := options.LoadConfigFromEnv(nil)
-	s.Require().NoError(err)
+		return &c, nil
+	})
 
-	s.pof, err = options.NewFactory(cfg, security)
-	s.Require().NoError(err)
+	s.security, _ = testutil.NewLazySuiteObj(s, func() (*security.Checker, error) {
+		return security.New(s.securityConfig())
+	})
+
+	s.poConfig, _ = testutil.NewLazySuiteObj(s, func() (*options.Config, error) {
+		c := options.NewDefaultConfig()
+		return &c, nil
+	})
+
+	s.po, _ = testutil.NewLazySuiteObj(s, func() (*options.Factory, error) {
+		return options.NewFactory(s.poConfig(), s.security())
+	})
 }
 
 func (s *ProcessingTestSuite) TearDownSuite() {
@@ -65,7 +78,7 @@ func (s *ProcessingTestSuite) openFile(name string) imagedata.ImageData {
 	s.Require().NoError(err)
 	path := filepath.Join(wd, "..", "testdata", name)
 
-	imagedata, err := s.idf.NewFromPath(path)
+	imagedata, err := s.imageDataFactory().NewFromPath(path)
 	s.Require().NoError(err)
 
 	return imagedata
@@ -80,7 +93,7 @@ func (s *ProcessingTestSuite) checkSize(r *Result, width, height int) {
 func (s *ProcessingTestSuite) TestResizeToFit() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFit
 
 	testCases := []struct {
@@ -118,7 +131,7 @@ func (s *ProcessingTestSuite) TestResizeToFit() {
 func (s *ProcessingTestSuite) TestResizeToFitEnlarge() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFit
 	po.Enlarge = true
 
@@ -157,7 +170,7 @@ func (s *ProcessingTestSuite) TestResizeToFitEnlarge() {
 func (s *ProcessingTestSuite) TestResizeToFitExtend() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFit
 	po.Extend = options.ExtendOptions{
 		Enabled: true,
@@ -201,7 +214,7 @@ func (s *ProcessingTestSuite) TestResizeToFitExtend() {
 func (s *ProcessingTestSuite) TestResizeToFitExtendAR() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFit
 	po.ExtendAspectRatio = options.ExtendOptions{
 		Enabled: true,
@@ -245,7 +258,7 @@ func (s *ProcessingTestSuite) TestResizeToFitExtendAR() {
 func (s *ProcessingTestSuite) TestResizeToFill() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFill
 
 	testCases := []struct {
@@ -283,7 +296,7 @@ func (s *ProcessingTestSuite) TestResizeToFill() {
 func (s *ProcessingTestSuite) TestResizeToFillEnlarge() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFill
 	po.Enlarge = true
 
@@ -322,7 +335,7 @@ func (s *ProcessingTestSuite) TestResizeToFillEnlarge() {
 func (s *ProcessingTestSuite) TestResizeToFillExtend() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFill
 	po.Extend = options.ExtendOptions{
 		Enabled: true,
@@ -368,7 +381,7 @@ func (s *ProcessingTestSuite) TestResizeToFillExtend() {
 func (s *ProcessingTestSuite) TestResizeToFillExtendAR() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFill
 	po.ExtendAspectRatio = options.ExtendOptions{
 		Enabled: true,
@@ -414,7 +427,7 @@ func (s *ProcessingTestSuite) TestResizeToFillExtendAR() {
 func (s *ProcessingTestSuite) TestResizeToFillDown() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFillDown
 
 	testCases := []struct {
@@ -452,7 +465,7 @@ func (s *ProcessingTestSuite) TestResizeToFillDown() {
 func (s *ProcessingTestSuite) TestResizeToFillDownEnlarge() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFillDown
 	po.Enlarge = true
 
@@ -491,7 +504,7 @@ func (s *ProcessingTestSuite) TestResizeToFillDownEnlarge() {
 func (s *ProcessingTestSuite) TestResizeToFillDownExtend() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFillDown
 	po.Extend = options.ExtendOptions{
 		Enabled: true,
@@ -537,7 +550,7 @@ func (s *ProcessingTestSuite) TestResizeToFillDownExtend() {
 func (s *ProcessingTestSuite) TestResizeToFillDownExtendAR() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.ResizingType = options.ResizeFillDown
 	po.ExtendAspectRatio = options.ExtendOptions{
 		Enabled: true,
@@ -581,7 +594,7 @@ func (s *ProcessingTestSuite) TestResizeToFillDownExtendAR() {
 func (s *ProcessingTestSuite) TestResultSizeLimit() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 
 	testCases := []struct {
 		limit        int
@@ -1009,7 +1022,7 @@ func (s *ProcessingTestSuite) TestResultSizeLimit() {
 }
 
 func (s *ProcessingTestSuite) TestImageResolutionTooLarge() {
-	po := s.pof.NewProcessingOptions()
+	po := s.po().NewProcessingOptions()
 	po.SecurityOptions.MaxSrcResolution = 1
 
 	imgdata := s.openFile("test2.jpg")

BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/1-bpp.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/16-bpp.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp-no-alpha-mask.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha-self-gen.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/4-bpp.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-move-to-x.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-single-color.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-small.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/gif.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/heif.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jpg.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jxl.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-bmp.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-png.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/png-256x256.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/single-bmp.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/svg.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/tiff.hash


BIN
testdata/test-hashes/TestIntegration/TestLoadSaveToPng/webp.hash


+ 82 - 0
testutil/image_hash_matcher.c

@@ -0,0 +1,82 @@
+#include "image_hash_matcher.h"
+
+/**
+ * vips_image_read_to_memory: converts VipsImage to RGBA format and reads into memory buffer
+ * @in: VipsImage to convert and read
+ * @buf: pointer to buffer pointer (will be allocated)
+ * @size: pointer to size_t to store the buffer size
+ *
+ * Converts the VipsImage to RGBA format using VIPS operations and reads the raw pixel data.
+ * The caller is responsible for freeing the buffer using vips_memory_buffer_free().
+ *
+ * Returns: 0 on success, -1 on error
+ */
+int
+vips_image_read_from_to_memory(void *in, size_t in_size, void **out, size_t *out_size, int *out_width, int *out_height)
+{
+  if (!in || !out || !out_size || !out_width || !out_height) {
+    vips_error("vips_image_read_from_to_memory", "invalid arguments");
+    return -1;
+  }
+
+  VipsImage *base = vips_image_new_from_buffer(in, in_size, "", NULL);
+  if (base == NULL) {
+    return -1;
+  }
+
+  VipsImage **t = (VipsImage **) vips_object_local_array(VIPS_OBJECT(base), 2);
+
+  // Initialize output parameters
+  *out = NULL;
+  *out_size = 0;
+
+  // Convert to sRGB colorspace first if needed
+  if (vips_colourspace(base, &t[0], VIPS_INTERPRETATION_sRGB, NULL) != 0) {
+    VIPS_UNREF(base);
+    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(base)) {
+    // Add alpha channel
+    if (vips_addalpha(base, &t[1], NULL) != 0) {
+      VIPS_UNREF(base);
+      vips_error("vips_image_read_from_to_memory", "failed to add alpha channel");
+      return -1;
+    }
+    in = t[1];
+  }
+
+  // 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 (*out == NULL) {
+    vips_error("vips_image_read_from_to_memory", "failed to write image to memory");
+    return -1;
+  }
+
+  return 0;
+}
+
+/**
+ * vips_memory_buffer_free: frees memory buffer allocated by vips_image_write_to_memory
+ * @buf: buffer pointer to free
+ *
+ * Frees the memory buffer allocated by vips_image_write_to_memory.
+ * Safe to call with NULL pointer.
+ */
+void
+vips_memory_buffer_free(void *buf)
+{
+  if (buf) {
+    g_free(buf);
+  }
+}

+ 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

+ 1 - 1
vips/bmpsave.c

@@ -124,7 +124,7 @@ vips_foreign_save_bmp_build(VipsObject *object)
   // bands (3 or 4) * 8 bits
   int bands = vips_image_get_bands(in);
 
-  if ((bands > 3) || (bands > 4)) {
+  if ((bands < 3) || (bands > 4)) {
     vips_error("vips_foreign_save_bmp_build", "BMP source file must have 3 or 4 bands (RGB or RGBA)");
     return -1;
   }

+ 2 - 2
vips/source.h

@@ -21,7 +21,7 @@ typedef struct _VipsImgproxySourceClass {
 // creates new vips async source from a reader handle
 VipsImgproxySource *vips_new_imgproxy_source(uintptr_t readerHandle);
 
-#endif
-
 // unreferences the source, which leads to reader close
 void unref_imgproxy_source(VipsImgproxySource *source);
+
+#endif