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

* lazy processing test

* VHS

* ImageHashMatcher
This commit is contained in:
Victor Sokolov
2025-09-22 19:32:34 +02:00
committed by GitHub
parent de7d1b6225
commit 30a5122d08
31 changed files with 364 additions and 94 deletions

View File

@@ -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

View File

@@ -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)
sourceUrl := fmt.Sprintf("/insecure/plain/local:///%s/%s@bmp", folder, baseName)
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)
// Read source image from imgproxy
resp := s.GET(sourceUrl)
defer resp.Body.Close()
referenceFile, err := os.Open(referencePath)
s.Require().NoError(err)
defer referenceFile.Close()
s.Require().Equal(http.StatusOK, resp.StatusCode, "expected status code 200 OK, got %d, path: %s", resp.StatusCode, path)
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)
hash2, err := goimagehash.DifferenceHash(referenceImage)
s.Require().NoError(err)
distance, err := hash1.Distance(hash2)
s.Require().NoError(err)
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:

View File

@@ -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")

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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