115 lines
4.1 KiB
C++
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));
|
|
}
|