#include #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(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((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)); }