From c45472a0f01fdae5d6b5982eeecd3fa724c357cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Wed, 11 Jan 2017 17:38:11 +0100 Subject: [PATCH] DebugTools: initial implementation of CompareImage class. Currently just does per-pixel comparison and calculates absolute delta, failing the comparison if max/mean delta threshold is above specified values. Useful enough for the case I have right now, might fail in other case -- but still better than whatever else I was using before :) --- CMakeLists.txt | 5 + Doxyfile | 4 +- doc/snippets/CMakeLists.txt | 40 ++ doc/snippets/configure.h.cmake | 27 + doc/snippets/debugtools-compareimage.cpp | 82 +++ doc/snippets/debugtools-compareimage.png | Bin 0 -> 39294 bytes doc/snippets/image1.tga | Bin 0 -> 4114 bytes doc/snippets/image2.tga | Bin 0 -> 4114 bytes src/Magnum/DebugTools/CMakeLists.txt | 13 + src/Magnum/DebugTools/CompareImage.cpp | 507 ++++++++++++++++++ src/Magnum/DebugTools/CompareImage.h | 173 ++++++ src/Magnum/DebugTools/Test/CMakeLists.txt | 4 + .../DebugTools/Test/CompareImageTest.cpp | 396 ++++++++++++++ 13 files changed, 1250 insertions(+), 1 deletion(-) create mode 100644 doc/snippets/CMakeLists.txt create mode 100644 doc/snippets/configure.h.cmake create mode 100644 doc/snippets/debugtools-compareimage.cpp create mode 100644 doc/snippets/debugtools-compareimage.png create mode 100644 doc/snippets/image1.tga create mode 100644 doc/snippets/image2.tga create mode 100644 src/Magnum/DebugTools/CompareImage.cpp create mode 100644 src/Magnum/DebugTools/CompareImage.h create mode 100644 src/Magnum/DebugTools/Test/CompareImageTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 52cbebd87..c7873928a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -335,3 +335,8 @@ set(MAGNUM_PLUGINS_AUDIOIMPORTER_RELEASE_DIR ${MAGNUM_PLUGINS_RELEASE_DIR}/audio add_subdirectory(modules) add_subdirectory(src) + +# Build snippets as part of testing +if(BUILD_TESTS) + add_subdirectory(doc/snippets) +endif() diff --git a/Doxyfile b/Doxyfile index 3b466f962..f8c6aada1 100644 --- a/Doxyfile +++ b/Doxyfile @@ -892,7 +892,8 @@ EXCLUDE_SYMBOLS = Magnum::*Implementation \ # that contain example code fragments that are included (see the \include # command). -EXAMPLE_PATH = ../magnum-examples/src/ +EXAMPLE_PATH = doc/snippets/ \ + ../magnum-examples/src/ # If the value of the EXAMPLE_PATH tag contains directories, you can use the # EXAMPLE_PATTERNS tag to specify one or more wildcard pattern (like *.cpp and @@ -913,6 +914,7 @@ EXAMPLE_RECURSIVE = NO # \image command). IMAGE_PATH = doc/ \ + doc/snippets/ \ ../magnum-examples/src/ # The INPUT_FILTER tag can be used to specify a program that doxygen should diff --git a/doc/snippets/CMakeLists.txt b/doc/snippets/CMakeLists.txt new file mode 100644 index 000000000..81e8fcbe3 --- /dev/null +++ b/doc/snippets/CMakeLists.txt @@ -0,0 +1,40 @@ +# +# This file is part of Magnum. +# +# Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016 +# Vladimír Vondruš +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# + +find_package(Corrade COMPONENTS TestSuite) + +if(WITH_DEBUGTOOLS AND Corrade_TestSuite_FOUND) + set(SNIPPETS_DIR ${CMAKE_CURRENT_SOURCE_DIR}) + configure_file(${CMAKE_CURRENT_SOURCE_DIR}/configure.h.cmake + ${CMAKE_CURRENT_BINARY_DIR}/configure.h) + + # CompareImage documentation snippet. I need it executable so I can + # copy&paste the output to the documentation. Also mot using + # corrade_add_test() because it shouldn't be run as part of CTest as it + # purposedly fails. + add_executable(debugtools-compareimage debugtools-compareimage.cpp) + target_link_libraries(debugtools-compareimage PRIVATE MagnumDebugTools) + target_include_directories(debugtools-compareimage PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) +endif() diff --git a/doc/snippets/configure.h.cmake b/doc/snippets/configure.h.cmake new file mode 100644 index 000000000..4ecdcf137 --- /dev/null +++ b/doc/snippets/configure.h.cmake @@ -0,0 +1,27 @@ +/* + This file is part of Magnum. + + Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016 + Vladimír Vondruš + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +*/ + +#define MAGNUM_PLUGINS_IMPORTER_DIR "${MAGNUM_PLUGINS_IMPORTER_DIR}" +#define SNIPPETS_DIR "${SNIPPETS_DIR}" diff --git a/doc/snippets/debugtools-compareimage.cpp b/doc/snippets/debugtools-compareimage.cpp new file mode 100644 index 000000000..70fdc58c7 --- /dev/null +++ b/doc/snippets/debugtools-compareimage.cpp @@ -0,0 +1,82 @@ +/* + This file is part of Magnum. + + Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016 + Vladimír Vondruš + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +*/ + +#include +#include +#include + +#include "Magnum/Image.h" +#include "Magnum/Trade/ImageData.h" +#include "Magnum/Trade/AbstractImporter.h" +#include "Magnum/DebugTools/CompareImage.h" + +#include "configure.h" + +using namespace Magnum; + +namespace { + +Image2D doProcessing() { + PluginManager::Manager manager{MAGNUM_PLUGINS_IMPORTER_DIR}; + std::unique_ptr importer = manager.loadAndInstantiate("TgaImporter"); + importer->openFile(Utility::Directory::join(SNIPPETS_DIR, "image2.tga")); + auto image = importer->image2D(0); + CORRADE_INTERNAL_ASSERT(image); + return Image2D{image->storage(), image->format(), image->type(), image->size(), image->release()}; +} + +Image2D loadExpectedImage() { + PluginManager::Manager manager{MAGNUM_PLUGINS_IMPORTER_DIR}; + std::unique_ptr importer = manager.loadAndInstantiate("TgaImporter"); + importer->openFile(Utility::Directory::join(SNIPPETS_DIR, "image1.tga")); + auto image = importer->image2D(0); + CORRADE_INTERNAL_ASSERT(image); + return Image2D{image->storage(), image->format(), image->type(), image->size(), image->release()}; +} + +} + +struct ProcessingTest: TestSuite::Tester { + explicit ProcessingTest(); + + void process(); +}; + +ProcessingTest::ProcessingTest() { + addTests({&ProcessingTest::process}); +} + +/** [0] */ +void ProcessingTest::process() { + Image2D actual = doProcessing(); + Image2D expected = loadExpectedImage(); + + CORRADE_COMPARE_WITH(actual, expected, + (DebugTools::CompareImage{170.0f, 96.0f})); +} +/** [0] */ + +CORRADE_TEST_MAIN(ProcessingTest) + diff --git a/doc/snippets/debugtools-compareimage.png b/doc/snippets/debugtools-compareimage.png new file mode 100644 index 0000000000000000000000000000000000000000..b2a418d8056280fdb8002f4551e8ec10db7fd30a GIT binary patch literal 39294 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 z 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 literal 0 HcmV?d00001 diff --git a/src/Magnum/DebugTools/CMakeLists.txt b/src/Magnum/DebugTools/CMakeLists.txt index 9394ab0fb..4393ecd8c 100644 --- a/src/Magnum/DebugTools/CMakeLists.txt +++ b/src/Magnum/DebugTools/CMakeLists.txt @@ -43,6 +43,16 @@ if(MAGNUM_TARGET_GLES AND NOT MAGNUM_TARGET_GLES2) list(APPEND MagnumDebugTools_SRCS ${MagnumDebugTools_RESOURCES}) endif() +# Build the TestSuite-related functionality only if it is present +find_package(Corrade COMPONENTS TestSuite) +if(Corrade_TestSuite_FOUND) + list(APPEND MagnumDebugTools_SRCS + CompareImage.cpp) + + list(APPEND MagnumDebugTools_HEADERS + CompareImage.h) +endif() + if(NOT MAGNUM_TARGET_WEBGL) list(APPEND MagnumDebugTools_SRCS BufferData.cpp) @@ -107,6 +117,9 @@ if(BUILD_STATIC_PIC) endif() target_link_libraries(MagnumDebugTools Magnum) +if(Corrade_TestSuite_FOUND) + target_link_libraries(MagnumDebugTools Corrade::TestSuite) +endif() if(WITH_SCENEGRAPH) target_link_libraries(MagnumDebugTools MagnumSceneGraph) endif() diff --git a/src/Magnum/DebugTools/CompareImage.cpp b/src/Magnum/DebugTools/CompareImage.cpp new file mode 100644 index 000000000..f11b85ca8 --- /dev/null +++ b/src/Magnum/DebugTools/CompareImage.cpp @@ -0,0 +1,507 @@ +/* + This file is part of Magnum. + + Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016 + Vladimír Vondruš + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +*/ + +#include "CompareImage.h" + +#include +#include +#include + +#include "Magnum/ImageView.h" +#include "Magnum/PixelFormat.h" +#include "Magnum/Math/Functions.h" +#include "Magnum/Math/Color.h" +#include "Magnum/Math/Algorithms/KahanSum.h" + +namespace Magnum { namespace DebugTools { namespace Implementation { + +namespace { + +template Math::Vector pixelAt(const char* const pixels, const std::size_t stride, const Vector2i& pos) { + return reinterpret_cast*>(pixels + stride*pos.y())[pos.x()]; +} + +template Float calculateImageDelta(const ImageView2D& actual, const ImageView2D& expected, std::vector& output) { + CORRADE_INTERNAL_ASSERT(output.size() == std::size_t(expected.size().product())); + + /* Precalculate parameters for pixel access */ + Math::Vector2 dataOffset, dataSize; + + std::tie(dataOffset, dataSize, std::ignore) = actual.dataProperties(); + const char* const actualPixels = actual.data() + dataOffset.sum(); + const std::size_t actualStride = dataSize.x(); + + std::tie(dataOffset, dataSize, std::ignore) = expected.dataProperties(); + const char* const expectedPixels = expected.data() + dataOffset.sum(); + const std::size_t expectedStride = dataSize.x(); + + /* Calculate deltas and maximal value of them */ + Float max{}; + for(std::int_fast32_t y = 0; y != expected.size().y(); ++y) { + for(std::int_fast32_t x = 0; x != expected.size().x(); ++x) { + Math::Vector actualPixel{pixelAt(actualPixels, actualStride, {Int(x), Int(y)})}; + Math::Vector expectedPixel{pixelAt(expectedPixels, expectedStride, {Int(x), Int(y)})}; + + const Float value = (Math::abs(actualPixel - expectedPixel)).sum()/size; + output[y*expected.size().x() + x] = value; + max = Math::max(max, value); + } + } + + return max; +} + +template Float calculateIntegerImageDelta(const ImageView2D& actual, const ImageView2D& expected, std::vector& output) { + if( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + expected.format() == PixelFormat::Red + #endif + #ifndef MAGNUM_TARGET_GLES2 + || expected.format() == PixelFormat::RedInteger + #else + #ifndef MAGNUM_TARGET_WEBGL + || + #endif + expected.format() == PixelFormat::Luminance + #endif + ) + return calculateImageDelta<1, T>(actual, expected, output); + else if( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + expected.format() == PixelFormat::RG + #endif + #ifndef MAGNUM_TARGET_GLES2 + || expected.format() == PixelFormat::RGInteger + #else + #ifndef MAGNUM_TARGET_WEBGL + || + #endif + expected.format() == PixelFormat::LuminanceAlpha + #endif + ) + return calculateImageDelta<2, T>(actual, expected, output); + else if(expected.format() == PixelFormat::RGB + #ifndef MAGNUM_TARGET_GLES2 + || expected.format() == PixelFormat::RGBInteger + #endif + ) + return calculateImageDelta<3, T>(actual, expected, output); + else if(expected.format() == PixelFormat::RGBA + #ifndef MAGNUM_TARGET_GLES2 + || expected.format() == PixelFormat::RGBAInteger + #endif + ) + return calculateImageDelta<4, T>(actual, expected, output); + + CORRADE_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */ +} + +template Float calculateImageDelta(const ImageView2D& actual, const ImageView2D& expected, std::vector& output) { + if( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + expected.format() == PixelFormat::Red + #endif + #ifdef MAGNUM_TARGET_GLES2 + #ifndef MAGNUM_TARGET_WEBGL + || + #endif + expected.format() == PixelFormat::Luminance + #endif + ) + return calculateImageDelta<1, T>(actual, expected, output); + else if( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + expected.format() == PixelFormat::RG + #endif + #ifdef MAGNUM_TARGET_GLES2 + #ifndef MAGNUM_TARGET_WEBGL + || + #endif + expected.format() == PixelFormat::LuminanceAlpha + #endif + ) + return calculateImageDelta<2, T>(actual, expected, output); + else if(expected.format() == PixelFormat::RGB) + return calculateImageDelta<3, T>(actual, expected, output); + else if(expected.format() == PixelFormat::RGBA) + return calculateImageDelta<4, T>(actual, expected, output); + + CORRADE_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */ +} + +} + +std::tuple, Float, Float> calculateImageDelta(const ImageView2D& actual, const ImageView2D& expected) { + /* Calculate a delta image */ + std::vector delta(expected.size().product()); + + Float max; + if(expected.type() == PixelType::UnsignedByte) + max = calculateIntegerImageDelta(actual, expected, delta); + else if(expected.type() == PixelType::UnsignedShort) + max = calculateIntegerImageDelta(actual, expected, delta); + else if(expected.type() == PixelType::UnsignedInt) + max = calculateIntegerImageDelta(actual, expected, delta); + #ifndef MAGNUM_TARGET_GLES2 + else if(expected.type() == PixelType::Byte) + max = calculateIntegerImageDelta(actual, expected, delta); + else if(expected.type() == PixelType::Short) + max = calculateIntegerImageDelta(actual, expected, delta); + else if(expected.type() == PixelType::Int) + max = calculateIntegerImageDelta(actual, expected, delta); + #endif + else if(expected.type() == PixelType::Float) + max = calculateImageDelta(actual, expected, delta); + else CORRADE_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */ + + /* Calculate mean delta. Do it the special way so we don't lose + precision -- that would result in having false negatives! */ + const Float mean = Math::Algorithms::kahanSum(delta.begin(), delta.end())/delta.size(); + + return std::make_tuple(delta, max, mean); +} + +namespace { + /* Done by printing an white to black gradient using one of the online + ASCII converters. Yes, I'm lazy. Another one could be " .,:;ox%#@". */ + const char Characters[] = " .,:~=+?7IZ$08DNM"; +} + +void printDeltaImage(Debug& out, const std::vector& deltas, const Vector2i& size, const Float max, const Float maxThreshold, const Float meanThreshold) { + CORRADE_INTERNAL_ASSERT(meanThreshold <= maxThreshold); + + /* At most 64 characters per line. The console fonts height is usually 2x + the width, so there is twice the pixels per block */ + const Vector2i pixelsPerBlock{(size.x() + 63)/64, 2*((size.x() + 63)/64)}; + const Vector2i blockCount = (size + pixelsPerBlock - Vector2i{1})/pixelsPerBlock; + + for(std::int_fast32_t y = 0; y != blockCount.y(); ++y) { + out << " |"; + + for(std::int_fast32_t x = 0; x != blockCount.x(); ++x) { + /* Going bottom-up so we don't flip the image upside down when printing */ + const Vector2i offset = Vector2i{Int(x), blockCount.y() - Int(y) - 1}*pixelsPerBlock; + const Vector2i blockSize = Math::min(size - offset, Vector2i{pixelsPerBlock}); + + Float blockMax{}; + for(std::int_fast32_t yb = 0; yb != blockSize.y(); ++yb) + for(std::int_fast32_t xb = 0; xb != blockSize.x(); ++xb) + blockMax = Math::max(blockMax, deltas[(offset.y() + yb)*size.x() + offset.x() + xb]); + + const char c = Characters[Int(Math::round(Math::min(blockMax/max, 1.0f)*(sizeof(Characters) - 2)))]; + + if(blockMax > maxThreshold) + out << Debug::boldColor(Debug::Color::Red) << Debug::nospace << std::string{c} << Debug::resetColor; + else if(blockMax > meanThreshold) + out << Debug::boldColor(Debug::Color::Yellow) << Debug::nospace << std::string{c} << Debug::resetColor; + else out << Debug::nospace << std::string{c}; + } + + out << Debug::nospace << "|" << Debug::newline; + } +} + +namespace { + +template void printIntegerPixelAt(Debug& out, const char* const pixels, const std::size_t stride, const Vector2i& pos, const PixelFormat format) { + if( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + format == PixelFormat::Red + #endif + #ifndef MAGNUM_TARGET_GLES2 + || format == PixelFormat::RedInteger + #else + #ifndef MAGNUM_TARGET_WEBGL + || + #endif + format == PixelFormat::Luminance + #endif + ) + out << pixelAt<1, T>(pixels, stride, pos); + else if( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + format == PixelFormat::RG + #endif + #ifndef MAGNUM_TARGET_GLES2 + || format == PixelFormat::RGInteger + #else + #ifndef MAGNUM_TARGET_WEBGL + || + #endif + format == PixelFormat::LuminanceAlpha + #endif + ) + out << pixelAt<2, T>(pixels, stride, pos); + /* Take the opportunity and print 8-bit colors in hex */ + else if(format == PixelFormat::RGB + #ifndef MAGNUM_TARGET_GLES2 + || format == PixelFormat::RGBInteger + #endif + ) + out << Math::Color3{pixelAt<3, T>(pixels, stride, pos)}; + else if(format == PixelFormat::RGBA + #ifndef MAGNUM_TARGET_GLES2 + || format == PixelFormat::RGBAInteger + #endif + ) + out << Math::Color4{pixelAt<4, T>(pixels, stride, pos)}; + else CORRADE_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */ +} + +template void printPixelAt(Debug& out, const char* const pixels, const std::size_t stride, const Vector2i& pos, const PixelFormat format) { + if( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + format == PixelFormat::Red + #endif + #ifdef MAGNUM_TARGET_GLES2 + #ifndef MAGNUM_TARGET_WEBGL + || + #endif + format == PixelFormat::Luminance + #endif + ) + out << pixelAt<1, T>(pixels, stride, pos); + else if( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + format == PixelFormat::RG + #endif + #ifdef MAGNUM_TARGET_GLES2 + #ifndef MAGNUM_TARGET_WEBGL + || + #endif + format == PixelFormat::LuminanceAlpha + #endif + ) + out << pixelAt<2, T>(pixels, stride, pos); + else if(format == PixelFormat::RGB) + out << pixelAt<3, T>(pixels, stride, pos); + else if(format == PixelFormat::RGBA) + out << pixelAt<4, T>(pixels, stride, pos); + else CORRADE_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */ +} + +void printPixelAt(Debug& out, const char* const pixels, const std::size_t stride, const Vector2i& pos, const PixelFormat format, const PixelType type) { + if(type == PixelType::UnsignedByte) + printIntegerPixelAt(out, pixels, stride, pos, format); + else if(type == PixelType::UnsignedShort) + printIntegerPixelAt(out, pixels, stride, pos, format); + else if(type == PixelType::UnsignedInt) + printIntegerPixelAt(out, pixels, stride, pos, format); + #ifndef MAGNUM_TARGET_GLES2 + else if(type == PixelType::Byte) + printIntegerPixelAt(out, pixels, stride, pos, format); + else if(type == PixelType::Short) + printIntegerPixelAt(out, pixels, stride, pos, format); + else if(type == PixelType::Int) + printIntegerPixelAt(out, pixels, stride, pos, format); + #endif + else if(type == PixelType::Float) + printPixelAt(out, pixels, stride, pos, format); + else CORRADE_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */ +} + +} + +void printPixelDeltas(Debug& out, const std::vector& delta, const ImageView2D& actual, const ImageView2D& expected, const Float maxThreshold, const Float meanThreshold, std::size_t maxCount) { + /* Precalculate parameters for pixel access */ + Math::Vector2 offset, size; + + std::tie(offset, size, std::ignore) = actual.dataProperties(); + const char* const actualPixels = actual.data() + offset.sum(); + const std::size_t actualStride = size.x(); + + std::tie(offset, size, std::ignore) = expected.dataProperties(); + const char* const expectedPixels = expected.data() + offset.sum(); + const std::size_t expectedStride = size.x(); + + /* Find first maxCount values above mean threshold and put them into a + sorted map */ + std::multimap large; + for(std::size_t i = 0; i != delta.size(); ++i) { + /* GCC 4.7 std::multimap doesn't have emplace() */ + if(delta[i] > meanThreshold) large.insert({delta[i], i}); + } + + CORRADE_INTERNAL_ASSERT(!large.empty()); + + if(large.size() > maxCount) + out << " Top" << maxCount << "out of" << large.size() << "pixels above max/mean threshold:"; + else + out << " Pixels above max/mean threshold:"; + + /* Print the values from largest to smallest. Branching on the done in the + inner loop but that doesn't matter as we always print just ~10 values. */ + std::size_t count = 0; + for(auto it = large.crbegin(); it != large.crend(); ++it) { + if(++count > maxCount) break; + + Vector2i pos; + std::tie(pos.y(), pos.x()) = Math::div(Int(it->second), expected.size().x()); + out << Debug::newline << " [" << Debug::nospace << pos.x() + << Debug::nospace << "," << Debug::nospace << pos.y() + << Debug::nospace << "]"; + + printPixelAt(out, actualPixels, actualStride, pos, expected.format(), expected.type()); + + out << Debug::nospace << ", expected"; + + printPixelAt(out, expectedPixels, expectedStride, pos, expected.format(), expected.type()); + + out << "(Δ =" << Debug::boldColor(delta[it->second] > maxThreshold ? + Debug::Color::Red : Debug::Color::Yellow) << delta[it->second] + << Debug::nospace << Debug::resetColor << ")"; + } +} + +}}} + +#ifndef DOXYGEN_GENERATING_OUTPUT +/* If Doxygen sees this, all @ref Corrade::TestSuite links break (prolly + because the namespace is undocumented in this project) */ +namespace Corrade { namespace TestSuite { + +using namespace Magnum; + +Comparator::Comparator(Float maxThreshold, Float meanThreshold): _maxThreshold{maxThreshold}, _meanThreshold{meanThreshold} { + CORRADE_ASSERT(meanThreshold <= maxThreshold, + "DebugTools::CompareImage: maxThreshold can't be smaller than meanThreshold", ); +} + +bool Comparator::operator()(const ImageView2D& actual, const ImageView2D& expected) { + _actualImage = &actual; + _expectedImage = &expected; + + /* Verify that the images are the same */ + if(actual.size() != expected.size()) { + _state = State::DifferentSize; + return false; + } + if(actual.format() != expected.format() || actual.type() != expected.type()) { + _state = State::DifferentFormat; + return false; + } + + /* Assert on unsupported format/storage */ + #ifndef CORRADE_NO_DEBUG + const bool formatSupported = ( + ( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + expected.format() == PixelFormat::Red || + expected.format() == PixelFormat::RG || + #endif + #ifndef MAGNUM_TARGET_GLES2 + expected.format() == PixelFormat::RedInteger || + expected.format() == PixelFormat::RGInteger || + expected.format() == PixelFormat::RGBInteger || + expected.format() == PixelFormat::RGBAInteger || + #else + expected.format() == PixelFormat::Luminance || + expected.format() == PixelFormat::LuminanceAlpha || + #endif + expected.format() == PixelFormat::RGB || + expected.format() == PixelFormat::RGBA + ) && ( + #ifndef MAGNUM_TARGET_GLES2 + expected.type() == PixelType::Byte || + expected.type() == PixelType::Short || + expected.type() == PixelType::Int || + #endif + expected.type() == PixelType::UnsignedByte || + expected.type() == PixelType::UnsignedShort || + expected.type() == PixelType::UnsignedInt + )) || (( + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + expected.format() == PixelFormat::Red || + expected.format() == PixelFormat::RG || + #endif + #ifdef MAGNUM_TARGET_GLES2 + expected.format() == PixelFormat::Luminance || + expected.format() == PixelFormat::LuminanceAlpha || + #endif + expected.format() == PixelFormat::RGB || + expected.format() == PixelFormat::RGBA + ) && expected.type() == PixelType::Float); + CORRADE_ASSERT( + formatSupported, + "DebugTools::CompareImage: format" << expected.format() << Debug::nospace << "/" << expected.type() << "is not supported", {}); + #endif + #ifndef MAGNUM_TARGET_GLES + CORRADE_ASSERT(!actual.storage().swapBytes() && !expected.storage().swapBytes(), + "DebugTools::CompareImage: pixel storage with byte swap is not supported", {}); + #endif + + std::vector delta; + std::tie(delta, _max, _mean) = DebugTools::Implementation::calculateImageDelta(actual, expected); + + /* If both values are not above threshold, success */ + if(_max > _maxThreshold && _mean > _meanThreshold) + _state = State::AboveThresholds; + else if(_max > _maxThreshold) + _state = State::AboveMaxThreshold; + else if(_mean > _meanThreshold) + _state = State::AboveMeanThreshold; + else return true; + + /* Otherwise save the deltas and fail */ + _delta = std::move(delta); + return false; +} + +void Comparator::printErrorMessage(Debug& out, const std::string& actual, const std::string& expected) const { + out << "Images" << actual << "and" << expected << "have"; + if(_state == State::DifferentSize) + out << "different size, actual" << _actualImage->size() << "but" + << _expectedImage->size() << "expected."; + else if(_state == State::DifferentFormat) + out << "different format, actual" << _actualImage->format() + << Debug::nospace << "/" << Debug::nospace << _actualImage->type() + << "but" << _expectedImage->format() << Debug::nospace << "/" + << Debug::nospace << _expectedImage->type() << "expected."; + else { + if(_state == State::AboveThresholds) + out << "both max and mean delta above threshold, actual" + << _max << Debug::nospace << "/" << Debug::nospace << _mean + << "but at most" << _maxThreshold << Debug::nospace << "/" + << Debug::nospace << _meanThreshold << "expected."; + else if(_state == State::AboveMaxThreshold) + out << "max delta above threshold, actual" << _max + << "but at most" << _maxThreshold + << "expected. Mean delta" << _mean << "is below threshold" + << _meanThreshold << Debug::nospace << "."; + else if(_state == State::AboveMeanThreshold) + out << "mean delta above threshold, actual" << _mean + << "but at most" << _meanThreshold + << "expected. Max delta" << _max << "is below threshold" + << _maxThreshold << Debug::nospace << "."; + else CORRADE_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */ + + out << "Delta image:" << Debug::newline; + DebugTools::Implementation::printDeltaImage(out, _delta, _expectedImage->size(), _max, _maxThreshold, _meanThreshold); + DebugTools::Implementation::printPixelDeltas(out, _delta, *_actualImage, *_expectedImage, _maxThreshold, _meanThreshold, 10); + } +} + +}} +#endif diff --git a/src/Magnum/DebugTools/CompareImage.h b/src/Magnum/DebugTools/CompareImage.h new file mode 100644 index 000000000..1da459136 --- /dev/null +++ b/src/Magnum/DebugTools/CompareImage.h @@ -0,0 +1,173 @@ +#ifndef Magnum_DebugTools_CompareImage_h +#define Magnum_DebugTools_CompareImage_h +/* + This file is part of Magnum. + + Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016 + Vladimír Vondruš + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +*/ + +/** @file + * @brief Class @ref Magnum::DebugTools::CompareImage + */ + +#include +#include + +#include "Magnum/Magnum.h" +#include "Magnum/Math/Vector2.h" +#include "Magnum/DebugTools/visibility.h" + +namespace Magnum { namespace DebugTools { + +namespace Implementation { + MAGNUM_DEBUGTOOLS_EXPORT std::tuple, Float, Float> calculateImageDelta(const ImageView2D& actual, const ImageView2D& expected); + + MAGNUM_DEBUGTOOLS_EXPORT void printDeltaImage(Debug& out, const std::vector& delta, const Vector2i& size, Float max, Float maxThreshold, Float meanThreshold); + + MAGNUM_DEBUGTOOLS_EXPORT void printPixelDeltas(Debug& out, const std::vector& delta, const ImageView2D& actual, const ImageView2D& expected, Float maxThreshold, Float meanThreshold, std::size_t maxCount); +} + +class CompareImage; + +}} + +#ifndef DOXYGEN_GENERATING_OUTPUT +/* If Doxygen sees this, all @ref Corrade::TestSuite links break (prolly + because the namespace is undocumented in this project) */ +namespace Corrade { namespace TestSuite { + +template<> class MAGNUM_DEBUGTOOLS_EXPORT Comparator { + public: + explicit Comparator(Magnum::Float maxThreshold, Magnum::Float meanThreshold); + + /*implicit*/ Comparator(): Comparator{0.0f, 0.0f} {} + + bool operator()(const Magnum::ImageView2D& actual, const Magnum::ImageView2D& expected); + + void printErrorMessage(Utility::Debug& out, const std::string& actual, const std::string& expected) const; + + private: + enum class State { + DifferentSize = 1, + DifferentFormat, + AboveThresholds, + AboveMeanThreshold, + AboveMaxThreshold + }; + + Magnum::Float _maxThreshold, _meanThreshold; + + State _state{}; + const Magnum::ImageView2D *_actualImage, *_expectedImage; + Magnum::Float _max, _mean; + std::vector _delta; +}; + +}} +#endif + +namespace Magnum { namespace DebugTools { + +/** +@brief Image comparator + +To be used with @ref Corrade::TestSuite. Basic use is really simple: + +@snippet debugtools-compareimage.cpp 0 + +Based on actual images used, in case of commparison failure the comparator can +give for example the following result: + +@image html debugtools-compareimage.png + +Supports the following formats: + +- @ref PixelFormat::Red, @ref PixelFormat::RedInteger, @ref PixelFormat::RG, + @ref PixelFormat::RGInteger, @ref PixelFormat::RGB, @ref PixelFormat::RGBInteger, + @ref PixelFormat::RGBA and @ref PixelFormat::RGBAInteger with + @ref PixelType::UnsignedByte, @ref PixelType::Byte, @ref PixelType::UnsignedShort, + @ref PixelType::Short, @ref PixelType::UnsignedInt and @ref PixelType::Int +- @ref PixelFormat::Red, @ref PixelFormat::RG, @ref PixelFormat::RGB and + @ref PixelFormat::RGBA with @ref PixelType::Float + +In OpenGL ES 2.0 and WebGL 1.0, @ref PixelFormat::Luminance and +@ref PixelFormat::LuminanceAlpha are also accepted in place of +@ref PixelFormat::Red and @ref PixelFormat::RG. + +Supports all @ref PixelStorage parameters *except* non-default +@ref PixelStorage::swapBytes() values. The images don't need to have the same +pixel storage parameters, meaning you are able to compare different subimages +of a larger image as long as they have the same size. + +The comparator first compares both images to have the same pixel format/type +combination and size. Each pixel is then first converted to @ref Magnum::Float "Float" +vector of corresponding channel count and then the per-pixel delta is +calculated as simple sum of per-channel deltas (where @f$ \boldsymbol{a} @f$ is +the actual pixel value, @f$ \boldsymbol{e} @f$ expected pixel value and @f$ c @f$ +is channel count), with max and mean delta being taken over the whole picture. @f[ + + \Delta_{\boldsymbol{p}} = \sum\limits_{i=1}^c \dfrac{a_i - e_i}{c} + +@f] + +The two parameters passed to the @ref CompareImage(Float, Float) "CompareImage(Float, Float)" +constructor are max and mean delta threshold. If the calculated values are +above these threshold, the comparison fails. In case of comparison failure the +diagnostic output contains calculated max/meanvalues, delta image visualization +and a list of top deltas. The delta image is an ASCII-art representation of the +image difference with each block being a maximum of pixel deltas in some area, +printed as characters of different perceived brightness. Blocks with delta over +the max threshold are colored red, blocks with delta over the mean threshold +are colored yellow. The delta list contains X,Y pixel position (with origin at +bottom left), actual and expected pixel value and calculated delta. +*/ +class CompareImage { + public: + /** + * @brief Constructor + * @param maxThreshold Max threshold. If any pixel has delta above + * this value, this comparison fails + * @param meanThreshold Mean threshold. If mean delta over all pixels + * is above this value, the comparison fails + */ + explicit CompareImage(Float maxThreshold, Float meanThreshold): _c{maxThreshold, meanThreshold} {} + + /** + * @brief Implicit constructor + * + * Equivalent to calling the above with zero values. + */ + explicit CompareImage(): CompareImage{0.0f, 0.0f} {} + + #ifndef DOXYGEN_GENERATING_OUTPUT + Corrade::TestSuite::Comparator& comparator() { + return _c; + } + #endif + + private: + Corrade::TestSuite::Comparator _c; +}; + +}} + +#endif diff --git a/src/Magnum/DebugTools/Test/CMakeLists.txt b/src/Magnum/DebugTools/Test/CMakeLists.txt index 1610b5a50..b200d2b95 100644 --- a/src/Magnum/DebugTools/Test/CMakeLists.txt +++ b/src/Magnum/DebugTools/Test/CMakeLists.txt @@ -33,6 +33,10 @@ if(WITH_SCENEGRAPH) corrade_add_test(DebugToolsForceRendererTest ForceRendererTest.cpp LIBRARIES MagnumMathTestLib) endif() +if(Corrade_TestSuite_FOUND) + corrade_add_test(DebugToolsCompareImageTest CompareImageTest.cpp LIBRARIES MagnumDebugTools) +endif() + if(BUILD_GL_TESTS) corrade_add_test(DebugToolsBufferDataGLTest BufferDataGLTest.cpp LIBRARIES MagnumDebugTools ${GL_TEST_LIBRARIES}) corrade_add_test(DebugToolsTextureImageGLTest TextureImageGLTest.cpp LIBRARIES MagnumDebugTools ${GL_TEST_LIBRARIES}) diff --git a/src/Magnum/DebugTools/Test/CompareImageTest.cpp b/src/Magnum/DebugTools/Test/CompareImageTest.cpp new file mode 100644 index 000000000..f6997d904 --- /dev/null +++ b/src/Magnum/DebugTools/Test/CompareImageTest.cpp @@ -0,0 +1,396 @@ +/* + This file is part of Magnum. + + Copyright © 2010, 2011, 2012, 2013, 2014, 2015, 2016 + Vladimír Vondruš + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +*/ + +#include +#include +#include +#include + +#include "Magnum/ImageView.h" +#include "Magnum/PixelFormat.h" +#include "Magnum/DebugTools/CompareImage.h" +#include "Magnum/Math/Functions.h" +#include "Magnum/Math/Color.h" + +namespace Magnum { namespace DebugTools { namespace Test { + +struct CompareImageTest: TestSuite::Tester { + explicit CompareImageTest(); + + void calculateDelta(); + void calculateDeltaStorage(); + + void deltaImage(); + void deltaImageScaling(); + void deltaImageColors(); + + void pixelDelta(); + void pixelDeltaOverflow(); + + void compareDifferentSize(); + void compareDifferentFormat(); + void compareDifferentType(); + void compareSameZeroThreshold(); + void compareAboveThresholds(); + void compareAboveMaxThreshold(); + void compareAboveMeanThreshold(); +}; + +CompareImageTest::CompareImageTest() { + addTests({&CompareImageTest::calculateDelta, + &CompareImageTest::calculateDeltaStorage, + + &CompareImageTest::deltaImage, + &CompareImageTest::deltaImageScaling, + &CompareImageTest::deltaImageColors, + + &CompareImageTest::pixelDelta, + &CompareImageTest::pixelDeltaOverflow, + + &CompareImageTest::compareDifferentSize, + &CompareImageTest::compareDifferentFormat, + &CompareImageTest::compareDifferentType, + &CompareImageTest::compareSameZeroThreshold, + &CompareImageTest::compareAboveThresholds, + &CompareImageTest::compareAboveMaxThreshold, + &CompareImageTest::compareAboveMeanThreshold}); +} + +namespace { + const Float ActualRedData[] = { + 0.3f, 1.0f, 0.9f, + 0.9f, 0.6f, 0.2f, + -0.1f, 1.0f, 0.0f + }; + + const Float ExpectedRedData[] = { + 0.65f, 1.0f, 0.6f, + 0.91f, 0.6f, 0.1f, + 0.02f, 0.0f, 0.0f + }; + + const std::vector DeltaRed{ + 0.35f, 0.0f, 0.3f, + 0.01f, 0.0f, 0.1f, + 0.12f, 1.0f, 0.0f}; + + const ImageView2D ActualRed{ + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + PixelFormat::Red + #else + PixelFormat::Luminance + #endif + , PixelType::Float, {3, 3}, ActualRedData}; + const ImageView2D ExpectedRed{ + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + PixelFormat::Red + #else + PixelFormat::Luminance + #endif + , PixelType::Float, {3, 3}, ExpectedRedData}; +} + +void CompareImageTest::calculateDelta() { + std::vector delta; + Float max, mean; + std::tie(delta, max, mean) = Implementation::calculateImageDelta(ActualRed, ExpectedRed); + + CORRADE_COMPARE_AS(delta, DeltaRed, TestSuite::Compare::Container); + CORRADE_COMPARE(max, 1.0f); + CORRADE_COMPARE(mean, 0.208889f); +} + +namespace { + /* Different storage for each */ + const UnsignedByte ActualRgbData[] = { + 0, 0, 0, 0, 0, 0, 0, 0, + 0x56, 0xf8, 0x3a, 0x56, 0x47, 0xec, 0, 0, + 0x23, 0x57, 0x10, 0xab, 0xcd, 0x85, 0, 0 + }; + + const UnsignedByte ExpectedRgbData[] = { + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + 0, 0, 0, 0x55, 0xf8, 0x3a, 0x56, 0x10, 0xed, 0, 0, 0, + 0, 0, 0, 0x23, 0x27, 0x10, 0xab, 0xcd, 0xfa, 0, 0, 0 + #else + 0x55, 0xf8, 0x3a, 0x56, 0x10, 0xed, 0, 0, + 0x23, 0x27, 0x10, 0xab, 0xcd, 0xfa, 0, 0, + #endif + }; + + const ImageView2D ActualRgb{PixelStorage{}.setSkip({0, 1, 0}), + PixelFormat::RGB, PixelType::UnsignedByte, {2, 2}, ActualRgbData}; + const ImageView2D ExpectedRgb{ + #if !(defined(MAGNUM_TARGET_WEBGL) && defined(MAGNUM_TARGET_GLES2)) + PixelStorage{}.setSkip({1, 0, 0}).setRowLength(3), + #endif + PixelFormat::RGB, PixelType::UnsignedByte, {2, 2}, ExpectedRgbData}; +} + +void CompareImageTest::calculateDeltaStorage() { + std::vector delta; + Float max, mean; + std::tie(delta, max, mean) = Implementation::calculateImageDelta(ActualRgb, ExpectedRgb); + + CORRADE_COMPARE_AS(delta, (std::vector{ + 1.0f/3.0f, (55.0f + 1.0f)/3.0f, + 48.0f/3.0f, 117.0f/3.0f + }), TestSuite::Compare::Container); + CORRADE_COMPARE(max, 117.0f/3.0f); + CORRADE_COMPARE(mean, 18.5f); +} + +void CompareImageTest::deltaImage() { + std::ostringstream out; + Debug d{&out, Debug::Flag::DisableColors}; + + std::vector delta(32*32); + + for(std::int_fast32_t x = 0; x != 32; ++x) + for(std::int_fast32_t y = 0; y != 32; ++y) + delta[y*32 + x] = Vector2{Float(x), Float(y)}.length()/Vector2{32.0f}.length(); + + Implementation::printDeltaImage(d, delta, {32, 32}, 1.0f, 0.0f, 0.0f); + CORRADE_COMPARE(out.str(), + " |$$$$$$$$$$0000000888888DDDDNNNNM|\n" + " |ZZZZZZZ$$$$$$$$0000008888DDDDNNN|\n" + " |ZZZZZZZZZZZZZ$$$$$$00008888DDDDN|\n" + " |IIIIIIIIIIZZZZZZZ$$$$00008888DDD|\n" + " |7777777IIIIIIIZZZZZ$$$$00008888D|\n" + " |???777777777IIIIIZZZZ$$$$0000888|\n" + " |??????????77777IIIIZZZZ$$$$00088|\n" + " |+++++++??????7777IIIIZZZZ$$$0008|\n" + " |=====++++++????7777IIIIZZZ$$$000|\n" + " |=========++++????7777IIIZZZ$$$00|\n" + " |~~~~~~~====++++????777IIIZZZ$$$0|\n" + " |:::::~~~~====++++???777IIIZZZ$$$|\n" + " |,::::::~~~~===+++????77IIIZZZ$$$|\n" + " |,,,,,::::~~~===+++???777IIIZZZ$$|\n" + " |...,,,,:::~~~===+++??777IIIZZZ$$|\n" + " | ....,,:::~~~===+++???777IIZZZ$$|\n"); +} + +void CompareImageTest::deltaImageScaling() { + std::ostringstream out; + Debug d{&out, Debug::Flag::DisableColors}; + + std::vector delta(65*40); + for(std::int_fast32_t x = 0; x != 65; ++x) + for(std::int_fast32_t y = 0; y != 40; ++y) + delta[y*65 + x] = Vector2{Float(x), Float(y)}.length()/Vector2{65.0f, 40.0f}.length(); + + Implementation::printDeltaImage(d, delta, {65, 40}, 1.0f, 0.0f, 0.0f); + CORRADE_COMPARE(out.str(), + " |777777IIIIIIZZZZ$$$0000888DDDNNMM|\n" + " |????777777IIIIZZZZ$$$000888DDDNNN|\n" + " |?????????7777IIIIZZZ$$$00888DDDNN|\n" + " |++++++++????777IIIZZZ$$$00088DDDN|\n" + " |======++++????777IIIZZ$$$00088DDD|\n" + " |~~~~~====+++???777IIIZZ$$$00888DD|\n" + " |::::~~~~===+++??777IIZZZ$$00088DD|\n" + " |,,::::~~~===++???777IIZZ$$$00888D|\n" + " |.,,,,:::~~===++???77IIZZZ$$000888|\n" + " |...,,,::~~~==++???77IIIZZ$$000888|\n"); +} + +void CompareImageTest::deltaImageColors() { + /* Print for visual color verification */ + { + Debug() << "Visual verification -- some letters should be yellow, some red, some white:"; + Debug d{Debug::Flag::NoNewlineAtTheEnd}; + Implementation::printDeltaImage(d, DeltaRed, {3, 3}, 2.0f, 0.5f, 0.2f); + } + + std::ostringstream out; + Debug dc{&out, Debug::Flag::DisableColors}; + Implementation::printDeltaImage(dc, DeltaRed, {3, 3}, 2.0f, 0.5f, 0.2f); + /* Yes, there is half of the rows (2 instead of 3) in order to roughly + preserve image ratio */ + CORRADE_COMPARE(out.str(), + " |.7 |\n" + " |: ,|\n"); +} + +void CompareImageTest::pixelDelta() { + { + Debug() << "Visual verification -- some lines should be yellow, some red:"; + Debug d; + Implementation::printPixelDeltas(d, DeltaRed, ActualRed, ExpectedRed, 0.5f, 0.1f, 10); + } + + std::ostringstream out; + Debug d{&out, Debug::Flag::DisableColors}; + Implementation::printPixelDeltas(d, DeltaRed, ActualRed, ExpectedRed, 0.5f, 0.1f, 10); + + CORRADE_COMPARE(out.str(), R"( Pixels above max/mean threshold: + [1,2] Vector(1), expected Vector(0) (Δ = 1) + [0,0] Vector(0.3), expected Vector(0.65) (Δ = 0.35) + [2,0] Vector(0.9), expected Vector(0.6) (Δ = 0.3) + [0,2] Vector(-0.1), expected Vector(0.02) (Δ = 0.12))"); +} + +void CompareImageTest::pixelDeltaOverflow() { + std::ostringstream out; + Debug d{&out, Debug::Flag::DisableColors}; + Implementation::printPixelDeltas(d, DeltaRed, ActualRed, ExpectedRed, 0.5f, 0.1f, 3); + + CORRADE_COMPARE(out.str(), R"( Top 3 out of 4 pixels above max/mean threshold: + [1,2] Vector(1), expected Vector(0) (Δ = 1) + [0,0] Vector(0.3), expected Vector(0.65) (Δ = 0.35) + [2,0] Vector(0.9), expected Vector(0.6) (Δ = 0.3))"); +} + +void CompareImageTest::compareDifferentSize() { + std::stringstream out; + + ImageView2D a{ + #ifndef MAGNUM_TARGET_GLES2 + PixelFormat::RGInteger, + #else + PixelFormat::LuminanceAlpha, + #endif + PixelType::UnsignedByte, {3, 4}, nullptr}; + ImageView2D b{ + #ifndef MAGNUM_TARGET_GLES2 + PixelFormat::RGInteger, + #else + PixelFormat::LuminanceAlpha, + #endif + PixelType::UnsignedByte, {3, 5}, nullptr}; + + { + Error e(&out); + TestSuite::Comparator compare; + CORRADE_VERIFY(!compare(a, b)); + compare.printErrorMessage(e, "a", "b"); + } + + CORRADE_COMPARE(out.str(), "Images a and b have different size, actual Vector(3, 4) but Vector(3, 5) expected.\n"); +} + +void CompareImageTest::compareDifferentFormat() { + std::stringstream out; + + ImageView2D a{PixelFormat::RGBA, PixelType::Float, {3, 4}, nullptr}; + ImageView2D b{PixelFormat::RGB, PixelType::Float, {3, 4}, nullptr}; + + { + Error e(&out); + TestSuite::Comparator compare; + CORRADE_VERIFY(!compare(a, b)); + compare.printErrorMessage(e, "a", "b"); + } + + CORRADE_COMPARE(out.str(), "Images a and b have different format, actual PixelFormat::RGBA/PixelType::Float but PixelFormat::RGB/PixelType::Float expected.\n"); +} + +void CompareImageTest::compareDifferentType() { + std::stringstream out; + + ImageView2D a{PixelFormat::RGB, PixelType::UnsignedByte, {3, 4}, nullptr}; + ImageView2D b{PixelFormat::RGB, PixelType::UnsignedShort, {3, 4}, nullptr}; + + { + Error e(&out); + TestSuite::Comparator compare; + CORRADE_VERIFY(!compare(a, b)); + compare.printErrorMessage(e, "a", "b"); + } + + CORRADE_COMPARE(out.str(), "Images a and b have different format, actual PixelFormat::RGB/PixelType::UnsignedByte but PixelFormat::RGB/PixelType::UnsignedShort expected.\n"); +} + +void CompareImageTest::compareSameZeroThreshold() { + using namespace Math::Literals; + + const Color3 data[] = { + 0xcafeba_rgbf, 0xdeadbe_rgbf, + 0xbadc0d_rgbf, 0xbeefe0_rgbf + }; + + const ImageView2D image{PixelFormat::RGB, PixelType::Float, {2, 2}, data}; + CORRADE_VERIFY((TestSuite::Comparator{0.0f, 0.0f}(image, image))); +} + +void CompareImageTest::compareAboveThresholds() { + std::stringstream out; + + { + TestSuite::Comparator compare{20.0f, 10.0f}; + CORRADE_VERIFY(!compare(ActualRgb, ExpectedRgb)); + Debug d{&out, Debug::Flag::DisableColors}; + compare.printErrorMessage(d, "a", "b"); + } + + CORRADE_COMPARE(out.str(), +R"(Images a and b have both max and mean delta above threshold, actual 39/18.5 but at most 20/10 expected. Delta image: + |?M| + Pixels above max/mean threshold: + [1,1] #abcd85, expected #abcdfa (Δ = 39) + [1,0] #5647ec, expected #5610ed (Δ = 18.6667) + [0,1] #235710, expected #232710 (Δ = 16) +)"); +} + +void CompareImageTest::compareAboveMaxThreshold() { + std::stringstream out; + + { + TestSuite::Comparator compare{30.0f, 20.0f}; + CORRADE_VERIFY(!compare(ActualRgb, ExpectedRgb)); + Debug d{&out, Debug::Flag::DisableColors}; + compare.printErrorMessage(d, "a", "b"); + } + + CORRADE_COMPARE(out.str(), +R"(Images a and b have max delta above threshold, actual 39 but at most 30 expected. Mean delta 18.5 is below threshold 20. Delta image: + |?M| + Pixels above max/mean threshold: + [1,1] #abcd85, expected #abcdfa (Δ = 39) +)"); +} + +void CompareImageTest::compareAboveMeanThreshold() { + std::stringstream out; + + { + TestSuite::Comparator compare{50.0f, 18.0f}; + CORRADE_VERIFY(!compare(ActualRgb, ExpectedRgb)); + Debug d{&out, Debug::Flag::DisableColors}; + compare.printErrorMessage(d, "a", "b"); + } + + CORRADE_COMPARE(out.str(), +R"(Images a and b have mean delta above threshold, actual 18.5 but at most 18 expected. Max delta 39 is below threshold 50. Delta image: + |?M| + Pixels above max/mean threshold: + [1,1] #abcd85, expected #abcdfa (Δ = 39) + [1,0] #5647ec, expected #5610ed (Δ = 18.6667) +)"); +} + +}}} + +CORRADE_TEST_MAIN(Magnum::DebugTools::Test::CompareImageTest)