Skip to content

Commit 8d55e31

Browse files
committed
Perceptual hashing in test units
1 parent ce8b86b commit 8d55e31

6 files changed

Lines changed: 343 additions & 2 deletions

File tree

3DRadSpace/Engine3DRadSpace_Tests/CMakeLists.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ cmake_minimum_required(VERSION 3.14)
22
project("3DRadSpace.Tests")
33

44
file(GLOB 3DRadSpace_tests_src CONFIGURE_DEPENDS "*.cpp")
5+
#list(REMOVE_ITEM 3DRadSpace_tests_src "${CMAKE_CURRENT_SOURCE_DIR}/Main.cpp")
56
add_executable(3DRadSpace.Tests ${3DRadSpace_tests_src})
67

78
if (CMAKE_VERSION VERSION_GREATER 3.12)
@@ -41,6 +42,14 @@ target_link_libraries(3DRadSpace.Tests PRIVATE GTest::gtest GTest::gtest_main GT
4142

4243
add_dependencies(3DRadSpace.Tests e3drsp_copy_assets)
4344

45+
add_custom_target(e3drsp_copy_testing ALL
46+
COMMAND ${CMAKE_COMMAND} -E copy_directory
47+
${CMAKE_SOURCE_DIR}/Testing
48+
${CMAKE_BINARY_DIR}/Testing
49+
)
50+
51+
add_dependencies(3DRadSpace.Tests e3drsp_copy_testing)
52+
4453
include(GoogleTest)
4554
gtest_discover_tests(3DRadSpace.Tests
4655
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#include "pch.h"
2+
#include <Engine3DRadSpace/Games/Game.hpp>
3+
#include <Engine3DRadSpace/Graphics/IVertexBuffer.hpp>
4+
#include <Engine3DRadSpace/Graphics/IShaderCompiler.hpp>
5+
#include "PerceptualHash.hpp"
6+
7+
using namespace Engine3DRadSpace;
8+
using namespace Engine3DRadSpace::Graphics;
9+
using namespace Engine3DRadSpace::Input;
10+
using namespace Engine3DRadSpace::Math;
11+
using namespace Engine3DRadSpace::Content;
12+
13+
class EmptyGame : public Game
14+
{
15+
int numFrames = 0;
16+
int limNumFrames;
17+
public:
18+
EmptyGame(int numFrames);
19+
20+
void Update() override
21+
{
22+
Game::Update();
23+
24+
++numFrames;
25+
if (numFrames >= limNumFrames)
26+
{
27+
Exit();
28+
}
29+
}
30+
};
31+
32+
EmptyGame::EmptyGame(int numFrames) :
33+
Game("Empty Game", 800, 600),
34+
limNumFrames(numFrames)
35+
{
36+
}
37+
38+
TEST(EngineCoreTests, EmptyGameTest)
39+
{
40+
EmptyGame g(0);
41+
g.Run();
42+
EXPECT_TRUE(true);
43+
}
44+
45+
TEST(EngineCoreTests, EmptyGameTest5sec)
46+
{
47+
EmptyGame g(60 * 5);
48+
g.Run();
49+
EXPECT_TRUE(true);
50+
}

3DRadSpace/Engine3DRadSpace_Tests/HelloWorldTriangleTest.cpp

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include <Engine3DRadSpace/Games/Game.hpp>
33
#include <Engine3DRadSpace/Graphics/IVertexBuffer.hpp>
44
#include <Engine3DRadSpace/Graphics/IShaderCompiler.hpp>
5+
#include "PerceptualHash.hpp"
56

67
using namespace Engine3DRadSpace;
78
using namespace Engine3DRadSpace::Graphics;
@@ -70,12 +71,35 @@ void TriangleTest::Draw3D()
7071
cmd->SetTopology(VertexTopology::TriangleList);
7172
cmd->DrawVertexBuffer(_triangleBuffer.get());
7273
cmd->SaveBackBufferToFile("Triangle.png");
73-
//TODO: Check if the saved image is matching with a expected image
7474
}
7575

7676
TEST(EngineCoreTests, HelloTriangle)
7777
{
7878
TriangleTest t;
7979
t.Run();
80-
EXPECT_TRUE(true);
80+
81+
std::filesystem::path renderedImage = "Triangle.png";
82+
std::filesystem::path expectedImage = "Testing/Triangle.png";
83+
84+
ASSERT_TRUE(std::filesystem::exists(renderedImage))
85+
<< "Rendered image was not saved";
86+
87+
if (std::filesystem::exists(expectedImage))
88+
{
89+
// Compare using perceptual hash with threshold of 5 bits difference
90+
// Threshold can be adjusted: 0 = identical, 5-10 = very similar, >15 = different
91+
bool similar = Testing::PerceptualHash::AreImagesSimilar(
92+
renderedImage,
93+
expectedImage,
94+
5
95+
);
96+
97+
EXPECT_TRUE(similar) << "Rendered image does not match expected image. ";
98+
}
99+
else
100+
{
101+
GTEST_SKIP() << "Reference image not found at: " << expectedImage;
102+
}
103+
104+
std::filesystem::remove(renderedImage);
81105
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#include "pch.h"
2+
#include "PerceptualHash.hpp"
3+
#include <bit>
4+
#include <d3d11.h>
5+
#include <wrl/client.h>
6+
#include <directxtk/WICTextureLoader.h>
7+
#include <wincodec.h>
8+
#include <cmath>
9+
10+
using Microsoft::WRL::ComPtr;
11+
12+
namespace Engine3DRadSpace::Testing
13+
{
14+
PerceptualHash::Hash PerceptualHash::ComputeHash(const std::filesystem::path& imagePath)
15+
{
16+
auto resized = LoadAndResize(imagePath);
17+
return ComputeDHash(resized);
18+
}
19+
20+
unsigned PerceptualHash::HammingDistance(Hash hash1, Hash hash2)
21+
{
22+
return static_cast<unsigned>(std::popcount(hash1 ^ hash2));
23+
}
24+
25+
bool PerceptualHash::AreImagesSimilar(const std::filesystem::path& imagePath1,
26+
const std::filesystem::path& imagePath2,
27+
unsigned threshold)
28+
{
29+
Hash hash1 = ComputeHash(imagePath1);
30+
Hash hash2 = ComputeHash(imagePath2);
31+
return HammingDistance(hash1, hash2) <= threshold;
32+
}
33+
34+
PerceptualHash::ImageData PerceptualHash::LoadAndResize(const std::filesystem::path& imagePath, size_t targetWidth, size_t targetHeight)
35+
{
36+
// Initialize COM for WIC
37+
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
38+
39+
// Create WIC factory
40+
ComPtr<IWICImagingFactory> wicFactory;
41+
HRESULT hr = CoCreateInstance(
42+
CLSID_WICImagingFactory,
43+
nullptr,
44+
CLSCTX_INPROC_SERVER,
45+
IID_PPV_ARGS(&wicFactory)
46+
);
47+
if (FAILED(hr)) throw std::runtime_error("Failed to create WIC factory");
48+
49+
// Load the image
50+
ComPtr<IWICBitmapDecoder> decoder;
51+
hr = wicFactory->CreateDecoderFromFilename(
52+
imagePath.wstring().c_str(),
53+
nullptr,
54+
GENERIC_READ,
55+
WICDecodeMetadataCacheOnDemand,
56+
&decoder
57+
);
58+
if (FAILED(hr)) throw std::runtime_error("Failed to load image: " + imagePath.string());
59+
60+
ComPtr<IWICBitmapFrameDecode> frame;
61+
hr = decoder->GetFrame(0, &frame);
62+
if (FAILED(hr)) throw std::runtime_error("Failed to get image frame");
63+
64+
// Convert to RGBA format
65+
ComPtr<IWICFormatConverter> converter;
66+
hr = wicFactory->CreateFormatConverter(&converter);
67+
if (FAILED(hr)) throw std::runtime_error("Failed to create format converter");
68+
69+
hr = converter->Initialize(
70+
frame.Get(),
71+
GUID_WICPixelFormat32bppRGBA,
72+
WICBitmapDitherTypeNone,
73+
nullptr,
74+
0.0,
75+
WICBitmapPaletteTypeCustom
76+
);
77+
if (FAILED(hr)) throw std::runtime_error("Failed to initialize format converter");
78+
79+
// Get original dimensions
80+
UINT srcWidth, srcHeight;
81+
hr = converter->GetSize(&srcWidth, &srcHeight);
82+
if (FAILED(hr)) throw std::runtime_error("Failed to get image size");
83+
84+
// Read original image data
85+
size_t srcRowPitch = srcWidth * 4; // 4 bytes per pixel (RGBA)
86+
size_t srcImageSize = srcRowPitch * srcHeight;
87+
auto srcPixels = std::make_unique<uint8_t[]>(srcImageSize);
88+
89+
hr = converter->CopyPixels(
90+
nullptr,
91+
static_cast<UINT>(srcRowPitch),
92+
static_cast<UINT>(srcImageSize),
93+
srcPixels.get()
94+
);
95+
if (FAILED(hr)) throw std::runtime_error("Failed to copy pixels");
96+
97+
// Resize to target dimensions
98+
size_t dstRowPitch = targetWidth * 4;
99+
size_t dstImageSize = dstRowPitch * targetHeight;
100+
auto dstPixels = std::make_unique<uint8_t[]>(dstImageSize);
101+
102+
BilinearResize(
103+
srcPixels.get(), srcWidth, srcHeight, srcRowPitch,
104+
dstPixels.get(), targetWidth, targetHeight, dstRowPitch
105+
);
106+
107+
ImageData result;
108+
result.pixels = std::move(dstPixels);
109+
result.width = targetWidth;
110+
result.height = targetHeight;
111+
result.rowPitch = dstRowPitch;
112+
113+
return result;
114+
}
115+
116+
void PerceptualHash::BilinearResize(const uint8_t* src, size_t srcWidth, size_t srcHeight, size_t srcPitch,
117+
uint8_t* dst, size_t dstWidth, size_t dstHeight, size_t dstPitch)
118+
{
119+
float xRatio = static_cast<float>(srcWidth) / static_cast<float>(dstWidth);
120+
float yRatio = static_cast<float>(srcHeight) / static_cast<float>(dstHeight);
121+
122+
for (size_t y = 0; y < dstHeight; ++y)
123+
{
124+
for (size_t x = 0; x < dstWidth; ++x)
125+
{
126+
float srcX = x * xRatio;
127+
float srcY = y * yRatio;
128+
129+
size_t x1 = static_cast<size_t>(srcX);
130+
size_t y1 = static_cast<size_t>(srcY);
131+
size_t x2 = (x1 + 1 < srcWidth) ? (x1 + 1) : (srcWidth - 1);
132+
size_t y2 = (y1 + 1 < srcHeight) ? (y1 + 1) : (srcHeight - 1);
133+
134+
float xWeight = srcX - x1;
135+
float yWeight = srcY - y1;
136+
137+
// Bilinear interpolation for each channel
138+
for (size_t c = 0; c < 4; ++c) // RGBA
139+
{
140+
float p1 = src[y1 * srcPitch + x1 * 4 + c];
141+
float p2 = src[y1 * srcPitch + x2 * 4 + c];
142+
float p3 = src[y2 * srcPitch + x1 * 4 + c];
143+
float p4 = src[y2 * srcPitch + x2 * 4 + c];
144+
145+
float top = p1 * (1.0f - xWeight) + p2 * xWeight;
146+
float bottom = p3 * (1.0f - xWeight) + p4 * xWeight;
147+
float value = top * (1.0f - yWeight) + bottom * yWeight;
148+
149+
dst[y * dstPitch + x * 4 + c] = static_cast<uint8_t>(value);
150+
}
151+
}
152+
}
153+
}
154+
155+
PerceptualHash::Hash PerceptualHash::ComputeDHash(const ImageData& resizedImage)
156+
{
157+
// dHash algorithm: compare adjacent pixels in each row
158+
// Image should be 9x8 pixels (9 wide for 8 comparisons per row)
159+
if (resizedImage.width != 9 || resizedImage.height != 8)
160+
throw std::runtime_error("Image must be 9x8 for dHash computation");
161+
162+
Hash hash = 0;
163+
const uint8_t* pixels = resizedImage.pixels.get();
164+
size_t rowPitch = resizedImage.rowPitch;
165+
166+
// For each row, compare adjacent pixels
167+
for (size_t y = 0; y < 8; ++y)
168+
{
169+
for (size_t x = 0; x < 8; ++x)
170+
{
171+
// Get grayscale values for current and next pixel
172+
// RGBA format: offset = (y * rowPitch) + (x * 4)
173+
size_t offset1 = (y * rowPitch) + (x * 4);
174+
size_t offset2 = (y * rowPitch) + ((x + 1) * 4);
175+
176+
// Convert to grayscale using luminance formula
177+
uint8_t gray1 = static_cast<uint8_t>(
178+
0.299f * pixels[offset1] + // R
179+
0.587f * pixels[offset1 + 1] + // G
180+
0.114f * pixels[offset1 + 2] // B
181+
);
182+
uint8_t gray2 = static_cast<uint8_t>(
183+
0.299f * pixels[offset2] + // R
184+
0.587f * pixels[offset2 + 1] + // G
185+
0.114f * pixels[offset2 + 2] // B
186+
);
187+
188+
// Set bit if left pixel is brighter than right pixel
189+
size_t bitIndex = y * 8 + x;
190+
if (gray1 > gray2)
191+
{
192+
hash |= (1ULL << bitIndex);
193+
}
194+
}
195+
}
196+
197+
return hash;
198+
}
199+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#pragma once
2+
#include <cstdint>
3+
#include <vector>
4+
#include <filesystem>
5+
#include <memory>
6+
7+
namespace Engine3DRadSpace::Testing
8+
{
9+
/// <summary>
10+
/// Perceptual hash using dHash (difference hash) algorithm.
11+
/// Compares structural similarity between images regardless of minor pixel differences.
12+
/// </summary>
13+
class PerceptualHash
14+
{
15+
public:
16+
using Hash = uint64_t;
17+
18+
/// <summary>
19+
/// Computes perceptual hash for an image file.
20+
/// </summary>
21+
/// <param name="imagePath">Path to the image file</param>
22+
/// <returns>64-bit perceptual hash</returns>
23+
static Hash ComputeHash(const std::filesystem::path& imagePath);
24+
25+
/// <summary>
26+
/// Calculates Hamming distance between two hashes (number of differing bits).
27+
/// Lower distance = more similar images.
28+
/// </summary>
29+
/// <param name="hash1">First hash</param>
30+
/// <param name="hash2">Second hash</param>
31+
/// <returns>Hamming distance (0-64)</returns>
32+
static unsigned HammingDistance(Hash hash1, Hash hash2);
33+
34+
/// <summary>
35+
/// Checks if two images are perceptually similar.
36+
/// </summary>
37+
/// <param name="imagePath1">Path to first image</param>
38+
/// <param name="imagePath2">Path to second image</param>
39+
/// <param name="threshold">Maximum allowed Hamming distance (default: 5, typically 0-10 for similar images)</param>
40+
/// <returns>True if images are similar within threshold</returns>
41+
static bool AreImagesSimilar(const std::filesystem::path& imagePath1,
42+
const std::filesystem::path& imagePath2,
43+
unsigned threshold = 5);
44+
45+
private:
46+
struct ImageData
47+
{
48+
std::unique_ptr<uint8_t[]> pixels;
49+
size_t width;
50+
size_t height;
51+
size_t rowPitch;
52+
};
53+
54+
static ImageData LoadAndResize(const std::filesystem::path& imagePath, size_t targetWidth = 9, size_t targetHeight = 8);
55+
static Hash ComputeDHash(const ImageData& resizedImage);
56+
static void BilinearResize(const uint8_t* src, size_t srcWidth, size_t srcHeight, size_t srcPitch,
57+
uint8_t* dst, size_t dstWidth, size_t dstHeight, size_t dstPitch);
58+
};
59+
}

3DRadSpace/Testing/Triangle.png

19.2 KB
Loading

0 commit comments

Comments
 (0)