Files
EVEngine/test/image.cpp
T

488 lines
18 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "common/Exception.h"
#include "data/ByteData.h"
#include "filesystem/FileData.h"
#include "image/Image.h"
#include "image/ImageData.h"
#include "medialoader/image/FormatHandler.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <functional>
#include <memory>
#include <string>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
namespace {
eve::image::Image* img() {
return eve::image::Image::create();
}
using Colorf = eve::image::ImageData::Colorf;
bool nearColor(const Colorf& a, const Colorf& b, float eps = 1.0f / 255.0f) {
return std::fabs(a.r - b.r) <= eps && std::fabs(a.g - b.g) <= eps &&
std::fabs(a.b - b.b) <= eps && std::fabs(a.a - b.a) <= eps;
}
bool expectException(const std::function<void()>& fn) {
try {
fn();
} catch (const eve::Exception&) {
return true;
}
return false;
}
} // namespace
TEST_CASE("image.create") {
auto* module = img();
REQUIRE(module != nullptr);
CHECK_EQ(module->getName(), std::string("Image"));
CHECK(!module->getFormatHandlers().empty());
}
TEST_CASE("image.newImageData.sizeAndDefaults") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> data(module->newImageData(4, 3));
REQUIRE(data.get() != nullptr);
CHECK_EQ(data->getWidth(), 4);
CHECK_EQ(data->getHeight(), 3);
CHECK_EQ(data->getFormat(), std::string("RGBA8"));
CHECK_EQ(data->getPixelSize(), 4u);
CHECK_EQ(data->getSize(), 4u * 3u * 4u);
REQUIRE(data->getData() != nullptr);
CHECK(std::memcmp(data->getData(), std::string(4 * 3 * 4, '\0').c_str(), data->getSize()) == 0);
CHECK(!data->isSRGB());
CHECK(data->inside(0, 0));
CHECK(data->inside(3, 2));
CHECK(!data->inside(-1, 0));
CHECK(!data->inside(4, 0));
CHECK(!data->inside(0, 3));
}
TEST_CASE("image.setPixel.getPixel.roundTrip") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> data(module->newImageData(2, 2));
Colorf in{0.25f, 0.5f, 0.75f, 1.0f};
data->setPixel(1, 0, in);
Colorf out = data->getPixel(1, 0);
CHECK(nearColor(in, out));
data->setPixel(0, 1, in);
Colorf viaRef;
data->getPixel(0, 1, viaRef);
CHECK(nearColor(in, viaRef));
}
TEST_CASE("image.setPixel.outOfRange") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> data(module->newImageData(2, 2));
Colorf c{1, 1, 1, 1};
CHECK(expectException([&] { data->setPixel(2, 0, c); }));
CHECK(expectException([&] { data->getPixel(-1, 0); }));
}
TEST_CASE("image.paintCircleUv.mapsVAndWrapsSeams") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> data(module->newImageData(8, 8));
const Colorf red{1.f, 0.f, 0.f, 1.f};
auto top = data->paintCircleUv(0.5f, 1.f, 0.f, red);
REQUIRE(top.ok());
const auto topReceipt = std::move(top).takeValue();
CHECK_EQ(topReceipt.centerY, 0);
CHECK_EQ(topReceipt.changedPixelCount, 1);
CHECK(data->getPixel(topReceipt.centerX, 0).r > 0.99f);
auto seam = data->paintCircleUv(0.f, 0.5f, 1.1f, red, true, false);
REQUIRE(seam.ok());
CHECK(std::move(seam).takeValue().changedPixelCount >= 4);
CHECK(data->getPixel(7, 4).r > 0.99f);
auto invalid = data->paintCircleUv(0.5f, 0.5f, -1.f, red);
CHECK(!invalid.ok());
CHECK_EQ(invalid.error()->code(), eve::DiagnosticCode::InvalidArgument);
}
TEST_CASE("image.invalidFormat") {
auto* module = img();
CHECK(!eve::image::ImageData::validPixelFormat("NOT_A_FORMAT"));
CHECK(expectException([&] { module->newImageData(2, 2, "NOT_A_FORMAT"); }));
}
TEST_CASE("image.invalidSize") {
auto* module = img();
// Negative dimensions overflow allocation size and fail.
CHECK(expectException([&] { module->newImageData(-1, 4); }));
CHECK(expectException([&] { module->newImageData(4, -1); }));
}
TEST_CASE("image.newImageDataFromData.garbage") {
auto* module = img();
const char garbage[] = "this is not encoded image data";
eve::data::ByteData bytes(garbage, sizeof(garbage) - 1);
CHECK(expectException([&] { module->newImageData(&bytes); }));
}
TEST_CASE("image.isCompressed.nonCompressed") {
auto* module = img();
const char plain[] = "plain rgba bytes, not dds/ktx/pvr";
eve::data::ByteData bytes(plain, sizeof(plain) - 1);
CHECK(!module->isCompressed(&bytes));
}
TEST_CASE("image.newImageData.withBuffer") {
auto* module = img();
unsigned char raw[8] = {10, 20, 30, 40, 50, 60, 70, 80};
std::unique_ptr<eve::image::ImageData> copied(
module->newImageData(2, 1, "RGBA8", raw, false));
REQUIRE(copied.get() != nullptr);
CHECK(std::memcmp(copied->getData(), raw, sizeof(raw)) == 0);
raw[0] = 99;
CHECK(static_cast<unsigned char*>(copied->getData())[0] == 10);
unsigned char* owned = new unsigned char[4]{1, 2, 3, 4};
std::unique_ptr<eve::image::ImageData> owning(
module->newImageData(1, 1, "RGBA8", owned, true));
REQUIRE(owning.get() != nullptr);
CHECK(std::memcmp(owning->getData(), owned, 4) == 0);
}
TEST_CASE("image.cloneAndPaste") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> src(module->newImageData(3, 2));
Colorf red{1.0f, 0.0f, 0.0f, 1.0f};
src->setPixel(2, 1, red);
std::unique_ptr<eve::image::ImageData> clone(src->clone());
REQUIRE(clone.get() != nullptr);
CHECK(nearColor(clone->getPixel(2, 1), red));
std::unique_ptr<eve::image::ImageData> dst(module->newImageData(4, 3));
dst->paste(src.get(), 0, 0, 0, 0, src->getWidth(), src->getHeight());
CHECK(nearColor(dst->getPixel(2, 1), red));
CHECK(eve::image::ImageData::canPaste("RGBA8", "RGBA8"));
CHECK(!eve::image::ImageData::canPaste("R8", "RGBA8"));
// Avoid CHECK(fn != nullptr): zeroerr cannot pretty-print function pointers on AppleClang.
const bool hasSetFn = eve::image::ImageData::getPixelSetFunction("RGBA8") != nullptr;
const bool hasGetFn = eve::image::ImageData::getPixelGetFunction("RGBA8") != nullptr;
CHECK(hasSetFn);
CHECK(hasGetFn);
}
TEST_CASE("image.encode.losslessFormatsRoundTrip") {
auto* module = img();
struct FormatCase {
medialoader::FormatHandler::EncodedFormat format;
const char* filename;
Colorf color;
};
for (const FormatCase& input :
{FormatCase{medialoader::FormatHandler::ENCODED_PNG, "ut_roundtrip.png", {1.f, 0.f, 0.f, 1.f}},
FormatCase{medialoader::FormatHandler::ENCODED_TGA, "ut_roundtrip.tga", {0.1f, 0.7f, 0.6f, 1.f}}}) {
std::printf("image round trip: %s\n", input.filename);
std::unique_ptr<eve::image::ImageData> src(module->newImageData(2, 2));
src->setPixel(1, 1, input.color);
std::unique_ptr<eve::filesystem::FileData> encoded(src->encode(input.format, input.filename, false));
REQUIRE(encoded != nullptr);
REQUIRE(encoded->getSize() > 0u);
std::unique_ptr<eve::image::ImageData> decoded(module->newImageData(encoded.get()));
REQUIRE(decoded != nullptr);
REQUIRE_EQ(decoded->getWidth(), 2);
REQUIRE_EQ(decoded->getHeight(), 2);
REQUIRE(nearColor(decoded->getPixel(1, 1), input.color));
}
}
TEST_CASE("image.encode.unsupportedFormatThrows") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> src(module->newImageData(2, 2, "RGBA32F"));
// No encoder currently supports encoding float pixel data into PNG.
CHECK(expectException([&] {
src->encode(medialoader::FormatHandler::ENCODED_PNG, "ut_bad.png", false);
}));
}
TEST_CASE("image.newCubeFaces.crossLayout") {
auto* module = img();
// 3x4 "+" cross layout, one texel per face; see Image::newCubeFaces mapping.
std::unique_ptr<eve::image::ImageData> src(module->newImageData(3, 4));
struct Face { int x, y; Colorf color; };
const Face faces[] = {
{1, 1, {1.0f, 0.0f, 0.0f, 1.0f}}, // +x
{1, 3, {0.0f, 1.0f, 0.0f, 1.0f}}, // -x
{1, 0, {0.0f, 0.0f, 1.0f, 1.0f}}, // +y
{1, 2, {1.0f, 1.0f, 0.0f, 1.0f}}, // -y
{0, 1, {1.0f, 0.0f, 1.0f, 1.0f}}, // +z
{2, 1, {0.0f, 1.0f, 1.0f, 1.0f}}, // -z
};
for (const auto& f : faces) src->setPixel(f.x, f.y, f.color);
auto result = module->newCubeFaces(src.get());
REQUIRE(result.size() == 6u);
for (size_t i = 0; i < 6; ++i) {
CHECK_EQ(result[i]->getWidth(), 1);
CHECK_EQ(result[i]->getHeight(), 1);
CHECK(nearColor(result[i]->getPixel(0, 0), faces[i].color));
}
}
TEST_CASE("image.newCubeFaces.invalidDimensionsThrows") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> src(module->newImageData(5, 7));
CHECK(expectException([&] { module->newCubeFaces(src.get()); }));
}
TEST_CASE("image.rotate.ninetyDegreesNearest") {
auto* module = img();
// 3x2 with a unique corner marker at (2,0).
std::unique_ptr<eve::image::ImageData> src(module->newImageData(3, 2));
Colorf red{1.0f, 0.0f, 0.0f, 1.0f};
Colorf green{0.0f, 1.0f, 0.0f, 1.0f};
src->setPixel(2, 0, red);
src->setPixel(0, 1, green);
// +90° with Math::rotate2* / Y-down → clockwise on screen.
// Corner (2,0) relative to center (1.5,1.0): (+0.5,-1.0) → (+1.0,+0.5)
// → destination pixel center ≈ (dstCx+1, dstCy+0.5).
std::unique_ptr<eve::image::ImageData> rotated(
src->rotate(float(M_PI) * 0.5f, "nearest", true));
REQUIRE(rotated.get() != nullptr);
// Expanded AABB of 3x2 at 90° is 2x3.
CHECK_EQ(rotated->getWidth(), 2);
CHECK_EQ(rotated->getHeight(), 3);
// Find red: should land near the bottom of the tall canvas after clockwise 90°.
int redCount = 0, greenCount = 0;
int redX = -1, redY = -1, greenX = -1, greenY = -1;
for (int y = 0; y < rotated->getHeight(); ++y) {
for (int x = 0; x < rotated->getWidth(); ++x) {
Colorf p = rotated->getPixel(x, y);
if (nearColor(p, red)) {
++redCount;
redX = x;
redY = y;
}
if (nearColor(p, green)) {
++greenCount;
greenX = x;
greenY = y;
}
}
}
CHECK_EQ(redCount, 1);
CHECK_EQ(greenCount, 1);
// Clockwise 90° with pixel-center sampling: (2,0) → (1,2), (0,1) → (0,0).
CHECK_EQ(redX, 1);
CHECK_EQ(redY, 2);
CHECK_EQ(greenX, 0);
CHECK_EQ(greenY, 0);
}
TEST_CASE("image.rotate.identityAndExpandFalse") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> src(module->newImageData(4, 4));
Colorf c{0.2f, 0.4f, 0.6f, 1.0f};
src->setPixel(1, 2, c);
std::unique_ptr<eve::image::ImageData> same(
src->rotate(0.f, "nearest", false));
REQUIRE(same.get() != nullptr);
CHECK_EQ(same->getWidth(), 4);
CHECK_EQ(same->getHeight(), 4);
CHECK(nearColor(same->getPixel(1, 2), c));
// 45° without expand keeps size; marker stays near center area.
std::unique_ptr<eve::image::ImageData> clipped(
src->rotate(float(M_PI) * 0.25f, "nearest", false));
REQUIRE(clipped.get() != nullptr);
CHECK_EQ(clipped->getWidth(), 4);
CHECK_EQ(clipped->getHeight(), 4);
}
TEST_CASE("image.rotate.bilinearAndBadFilter") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> src(module->newImageData(2, 2));
Colorf white{1.0f, 1.0f, 1.0f, 1.0f};
src->setPixel(0, 0, white);
src->setPixel(1, 0, white);
src->setPixel(0, 1, white);
src->setPixel(1, 1, white);
std::unique_ptr<eve::image::ImageData> rotated(
src->rotate(float(M_PI) * 0.25f, "linear", true));
REQUIRE(rotated.get() != nullptr);
CHECK(rotated->getWidth() >= 2);
CHECK(rotated->getHeight() >= 2);
// At least one opaque-ish sample should survive bilinear sampling.
bool anyLit = false;
for (int y = 0; y < rotated->getHeight() && !anyLit; ++y) {
for (int x = 0; x < rotated->getWidth(); ++x) {
if (rotated->getPixel(x, y).a > 0.1f) {
anyLit = true;
break;
}
}
}
CHECK(anyLit);
CHECK(expectException([&] { src->rotate(0.1f, "cubic", true); }));
}
TEST_CASE("image.rotate.rotspriteNinetyAndPalette") {
auto* module = img();
std::unique_ptr<eve::image::ImageData> src(module->newImageData(3, 2));
Colorf red{1.0f, 0.0f, 0.0f, 1.0f};
Colorf green{0.0f, 1.0f, 0.0f, 1.0f};
Colorf blue{0.0f, 0.0f, 1.0f, 1.0f};
src->setPixel(0, 0, blue);
src->setPixel(1, 0, blue);
src->setPixel(2, 0, red);
src->setPixel(0, 1, green);
src->setPixel(1, 1, blue);
src->setPixel(2, 1, blue);
std::unique_ptr<eve::image::ImageData> rotated(
src->rotate(float(M_PI) * 0.5f, "rotsprite", true));
REQUIRE(rotated.get() != nullptr);
CHECK_EQ(rotated->getWidth(), 2);
CHECK_EQ(rotated->getHeight(), 3);
CHECK(nearColor(rotated->getPixel(1, 2), red));
CHECK(nearColor(rotated->getPixel(0, 0), green));
// Only original palette colors (plus transparent empty).
auto isPalette = [&](const Colorf& p) {
if (p.a == 0.f && p.r == 0.f && p.g == 0.f && p.b == 0.f) return true;
return nearColor(p, red) || nearColor(p, green) || nearColor(p, blue);
};
for (int y = 0; y < rotated->getHeight(); ++y)
for (int x = 0; x < rotated->getWidth(); ++x)
CHECK(isPalette(rotated->getPixel(x, y)));
}
TEST_CASE("image.rotate.rotspriteFortyFive") {
auto* module = img();
// 8x8 plus-shaped sprite — RotSprite should keep palette and expand AABB.
std::unique_ptr<eve::image::ImageData> src(module->newImageData(8, 8));
Colorf ink{0.1f, 0.2f, 0.3f, 1.0f};
Colorf bg{0.9f, 0.9f, 0.85f, 1.0f};
for (int y = 0; y < 8; ++y)
for (int x = 0; x < 8; ++x)
src->setPixel(x, y, bg);
for (int i = 1; i < 7; ++i) {
src->setPixel(i, 3, ink);
src->setPixel(i, 4, ink);
src->setPixel(3, i, ink);
src->setPixel(4, i, ink);
}
std::unique_ptr<eve::image::ImageData> rotated(
src->rotate(float(M_PI) * 0.25f, "rotsprite", true));
REQUIRE(rotated.get() != nullptr);
CHECK(rotated->getWidth() >= 8);
CHECK(rotated->getHeight() >= 8);
int inkCount = 0;
int foreign = 0;
for (int y = 0; y < rotated->getHeight(); ++y) {
for (int x = 0; x < rotated->getWidth(); ++x) {
Colorf p = rotated->getPixel(x, y);
if (nearColor(p, ink))
++inkCount;
else if (!(nearColor(p, bg) || (p.a == 0.f && p.r == 0.f && p.g == 0.f && p.b == 0.f)))
++foreign;
}
}
CHECK(inkCount > 0);
// RotSprite must not invent blended colors.
CHECK_EQ(foreign, 0);
}
TEST_CASE("image.formats.pixelRoundTrips") {
auto *module = img();
struct Fmt {
const char *name;
size_t pixelSize;
float eps;
};
const Fmt full[] = {
{"RGBA16", 8u, 0.01f},
{"RGBA16F", 8u, 0.01f},
{"RGBA32F", 16u, 1e-4f},
{"RGBA4", 2u, 0.1f},
{"RGB5A1", 2u, 0.05f},
{"RGB565", 2u, 0.05f},
};
const Colorf probe{0.30f, 0.60f, 0.90f, 1.0f};
for (const auto &f : full) {
CHECK(eve::image::ImageData::validPixelFormat(f.name));
std::unique_ptr<eve::image::ImageData> d(module->newImageData(2, 2, f.name));
REQUIRE(d.get() != nullptr);
CHECK_EQ(d->getFormat(), std::string(f.name));
CHECK_EQ(d->getPixelSize(), f.pixelSize);
const bool hasSetFn = eve::image::ImageData::getPixelSetFunction(f.name) != nullptr;
const bool hasGetFn = eve::image::ImageData::getPixelGetFunction(f.name) != nullptr;
CHECK(hasSetFn);
CHECK(hasGetFn);
d->setPixel(1, 0, probe);
const Colorf got = d->getPixel(1, 0);
CHECK(nearColor(got, probe, f.eps));
}
const Fmt reduced[] = {{"R8", 1u, 0.01f}, {"RG8", 2u, 0.01f}};
for (const auto &f : reduced) {
CHECK(eve::image::ImageData::validPixelFormat(f.name));
std::unique_ptr<eve::image::ImageData> d(module->newImageData(2, 2, f.name));
REQUIRE(d.get() != nullptr);
CHECK_EQ(d->getFormat(), std::string(f.name));
CHECK_EQ(d->getPixelSize(), f.pixelSize);
d->setPixel(1, 0, probe);
const Colorf out = d->getPixel(1, 0);
CHECK(std::fabs(out.r - probe.r) < f.eps);
if (std::string(f.name) == "RG8") CHECK(std::fabs(out.g - probe.g) < f.eps);
CHECK(std::fabs(out.b) < f.eps);
CHECK(std::fabs(out.a - 1.0f) < f.eps);
}
}
TEST_CASE("image.formats.canPasteMatrix") {
CHECK(eve::image::ImageData::canPaste("RGBA8", "RGBA16"));
CHECK(eve::image::ImageData::canPaste("RGBA16", "RGBA8"));
CHECK(eve::image::ImageData::canPaste("RGBA8", "RGBA16F"));
CHECK(eve::image::ImageData::canPaste("RGBA16F", "RGBA32F"));
CHECK(eve::image::ImageData::canPaste("RGBA32F", "RGBA8"));
CHECK(eve::image::ImageData::canPaste("RGBA16F", "RGBA16"));
CHECK(!eve::image::ImageData::canPaste("R8", "RGBA16"));
CHECK(!eve::image::ImageData::canPaste("RGBA16", "R8"));
CHECK(!eve::image::ImageData::canPaste("RGBA4", "RGBA8"));
}
TEST_CASE("image.paste.acrossFormats") {
auto *module = img();
std::unique_ptr<eve::image::ImageData> src(module->newImageData(2, 1, "RGBA8"));
const Colorf px{0.2f, 0.4f, 0.8f, 1.0f};
src->setPixel(1, 0, px);
std::unique_ptr<eve::image::ImageData> dst32f(module->newImageData(2, 1, "RGBA32F"));
dst32f->paste(src.get(), 0, 0, 0, 0, 2, 1);
CHECK(nearColor(dst32f->getPixel(1, 0), px, 0.01f));
std::unique_ptr<eve::image::ImageData> back8(module->newImageData(2, 1, "RGBA8"));
back8->paste(dst32f.get(), 0, 0, 0, 0, 2, 1);
CHECK(nearColor(back8->getPixel(1, 0), px, 0.01f));
std::unique_ptr<eve::image::ImageData> dst(module->newImageData(3, 2));
dst->paste(src.get(), 1, 1, 1, 0, 1, 1); // partial sub-region paste
const Colorf pasted = dst->getPixel(1, 1);
CHECK(nearColor(pasted, px));
CHECK(!nearColor(dst->getPixel(0, 0), px));
}