fwt_software/tests/test_imagequality.cpp

115 lines
4.1 KiB
C++

#include <doctest/doctest.h>
#include "fgc/ImageQuality.h"
using namespace fgc;
namespace {
// A frame filled with one constant value.
Frame flat(uint32_t w, uint32_t h, int channels, uint8_t value) {
Frame f;
f.width = w;
f.height = h;
f.channels = channels;
f.data.assign(static_cast<size_t>(w) * h * channels, value);
return f;
}
// A 1-pixel checkerboard: maximum high-frequency content, so maximum sharpness.
Frame checkerboard(uint32_t w, uint32_t h) {
Frame f = flat(w, h, 1, 0);
for (uint32_t y = 0; y < h; ++y)
for (uint32_t x = 0; x < w; ++x) f.data[y * w + x] = ((x + y) % 2) ? 255 : 0;
return f;
}
// A smooth horizontal ramp: plenty of contrast, but almost no second derivative.
Frame ramp(uint32_t w, uint32_t h) {
Frame f = flat(w, h, 1, 0);
for (uint32_t y = 0; y < h; ++y)
for (uint32_t x = 0; x < w; ++x)
f.data[y * w + x] = static_cast<uint8_t>((x * 255) / (w - 1));
return f;
}
} // namespace
TEST_CASE("analyzeFrame rejects frames it cannot interpret") {
CHECK_FALSE(analyzeFrame(Frame{}).valid);
Frame bad_channels = flat(8, 8, 2, 50);
CHECK_FALSE(analyzeFrame(bad_channels).valid);
// Buffer shorter than width*height*channels must not be read past its end.
Frame truncated = flat(64, 64, 3, 50);
truncated.data.resize(100);
CHECK_FALSE(analyzeFrame(truncated).valid);
}
TEST_CASE("analyzeFrame measures mean luma for mono and RGB") {
ImageMetrics mono = analyzeFrame(flat(64, 64, 1, 128));
REQUIRE(mono.valid);
CHECK(mono.mean_luma == doctest::Approx(128.0));
CHECK(mono.clipped_fraction == doctest::Approx(0.0));
CHECK(mono.dark_fraction == doctest::Approx(0.0));
// Equal R=G=B weights sum to 1.0, so grey RGB gives the same luma.
ImageMetrics rgb = analyzeFrame(flat(64, 64, 3, 128));
REQUIRE(rgb.valid);
CHECK(rgb.mean_luma == doctest::Approx(128.0));
}
TEST_CASE("analyzeFrame detects blown highlights and crushed blacks") {
ImageMetrics blown = analyzeFrame(flat(64, 64, 3, 255));
REQUIRE(blown.valid);
CHECK(blown.clipped_fraction == doctest::Approx(1.0));
CHECK(blown.dark_fraction == doctest::Approx(0.0));
ImageMetrics black = analyzeFrame(flat(64, 64, 3, 0));
REQUIRE(black.valid);
CHECK(black.dark_fraction == doctest::Approx(1.0));
CHECK(black.clipped_fraction == doctest::Approx(0.0));
// A single blown channel is enough to count the pixel as clipped - important
// for RGB, where a red sunset can saturate one channel while the mean is fine.
Frame one_channel = flat(64, 64, 3, 100);
for (size_t i = 0; i < one_channel.data.size(); i += 3) one_channel.data[i] = 255;
ImageMetrics m = analyzeFrame(one_channel);
CHECK(m.clipped_fraction == doctest::Approx(1.0));
}
TEST_CASE("sharpness separates detailed from smooth images") {
QualityParams p;
p.sharpness_roi_px = 64;
const double checker = analyzeFrame(checkerboard(128, 128), p).sharpness;
const double smooth = analyzeFrame(ramp(128, 128), p).sharpness;
const double blank = analyzeFrame(flat(128, 128, 1, 128), p).sharpness;
// A flat field has no second derivative at all.
CHECK(blank == doctest::Approx(0.0));
// A ramp has strong contrast but is locally linear, so it is nearly as flat -
// this is why sharpness must not be inferred from contrast or stddev.
CHECK(smooth < checker / 100.0);
CHECK(checker > 1000.0);
}
TEST_CASE("stride subsampling agrees with a full scan") {
// Half the frame blown, half mid-grey: any correct sampling sees ~50%.
Frame f = flat(128, 128, 1, 128);
for (uint32_t y = 0; y < 64; ++y)
for (uint32_t x = 0; x < 128; ++x) f.data[y * 128 + x] = 255;
QualityParams full;
full.stride = 1;
QualityParams strided;
strided.stride = 4;
ImageMetrics a = analyzeFrame(f, full);
ImageMetrics b = analyzeFrame(f, strided);
CHECK(b.mean_luma == doctest::Approx(a.mean_luma).epsilon(0.02));
CHECK(b.clipped_fraction == doctest::Approx(a.clipped_fraction).epsilon(0.02));
CHECK(b.clipped_fraction == doctest::Approx(0.5).epsilon(0.02));
}