#include #include "fgc/ExposurePolicy.h" #include using namespace fgc; namespace { PolicyParams params() { PolicyParams p; p.target_mean = 110.0; p.mean_tolerance = 12.0; p.clip_max_fraction = 0.005; p.exposure_min_us = 50.0; p.exposure_max_us = 20000.0; p.gain_max_db = 12.0; p.damping = 0.8; return p; } ImageMetrics metrics(double mean, double clipped = 0.0, double sharpness = 1000.0) { ImageMetrics m; m.valid = true; m.mean_luma = mean; m.clipped_fraction = clipped; m.sharpness = sharpness; return m; } } // namespace TEST_CASE("a well-exposed frame is accepted unchanged") { auto v = evaluate(metrics(110.0), {5000.0, 0.0}, params()); CHECK(v.accept); CHECK(v.reason == "ok"); // Anywhere inside the tolerance band is good enough - chasing the exact target // would cost extra frames for no visible gain. CHECK(evaluate(metrics(100.0), {5000.0, 0.0}, params()).accept); CHECK(evaluate(metrics(120.0), {5000.0, 0.0}, params()).accept); } TEST_CASE("underexposure raises exposure before it reaches for gain") { auto v = evaluate(metrics(40.0), {5000.0, 0.0}, params()); CHECK_FALSE(v.accept); CHECK(v.reason == "dark"); CHECK(v.next.exposure_us > 5000.0); CHECK(v.next.gain_db == doctest::Approx(0.0)); // gain costs noise; it waits } TEST_CASE("gain is only used once exposure is capped") { PolicyParams p = params(); // Already at the exposure ceiling (which is also the motion-blur budget), so // the only way to brighten further is gain. auto v = evaluate(metrics(40.0), {p.exposure_max_us, 0.0}, p); CHECK_FALSE(v.accept); CHECK(v.next.exposure_us == doctest::Approx(p.exposure_max_us)); CHECK(v.next.gain_db > 0.0); CHECK(v.next.gain_db <= p.gain_max_db); } TEST_CASE("overexposure surrenders gain before it shortens exposure") { auto v = evaluate(metrics(200.0), {5000.0, 6.0}, params()); CHECK_FALSE(v.accept); CHECK(v.reason == "bright"); CHECK(v.next.gain_db < 6.0); // Exposure is untouched while there is still gain to give back. CHECK(v.next.exposure_us == doctest::Approx(5000.0)); } TEST_CASE("clipping outranks the mean") { // The mean sits right on target, but the sky is blown. Blown highlights are // unrecoverable, so this must still be rejected and darkened. auto v = evaluate(metrics(110.0, /*clipped=*/0.05), {5000.0, 0.0}, params()); CHECK_FALSE(v.accept); CHECK(v.reason == "clipped"); CHECK(v.next.exposure_us < 5000.0); } TEST_CASE("corrections stay inside the configured limits") { PolicyParams p = params(); // Extreme darkness cannot push exposure past the blur budget or gain past its cap. CaptureSettings s{p.exposure_max_us, p.gain_max_db}; auto v = evaluate(metrics(1.0), s, p); CHECK(v.next.exposure_us <= p.exposure_max_us); CHECK(v.next.gain_db <= p.gain_max_db); // Extreme brightness cannot drive exposure below the floor or gain negative. CaptureSettings s2{p.exposure_min_us, 0.0}; auto v2 = evaluate(metrics(254.0, 0.9), s2, p); CHECK(v2.next.exposure_us >= p.exposure_min_us); CHECK(v2.next.gain_db >= 0.0); } TEST_CASE("a camera pinned at its limits accepts instead of retrying forever") { PolicyParams p = params(); // Still too dark, but exposure and gain are both maxed: no correction exists, // so burning the remaining attempts would be pointless. auto v = evaluate(metrics(40.0), {p.exposure_max_us, p.gain_max_db}, p); CHECK(v.accept); CHECK(v.reason == "saturated"); } TEST_CASE("repeated correction converges and does not oscillate") { PolicyParams p = params(); CaptureSettings s{1000.0, 0.0}; // Model a linear sensor: mean is proportional to exposure x gain. auto simulate = [](const CaptureSettings& cs) { const double lin = cs.exposure_us * std::pow(10.0, cs.gain_db / 20.0); return std::min(255.0, lin * 0.02); // 5500 us -> 110 }; int iterations = 0; bool converged = false; for (; iterations < 12; ++iterations) { auto v = evaluate(metrics(simulate(s)), s, p); if (v.accept) { converged = true; break; } s = v.next; } CHECK(converged); CHECK(iterations <= 6); // damping trades a little speed for stability CHECK(simulate(s) == doctest::Approx(p.target_mean).epsilon(0.15)); } TEST_CASE("blur is judged against this angle's own history, not an absolute number") { PolicyParams p = params(); // Exposure is fine and there is no reference yet: nothing to compare against, // so a low-detail scene (fog) must NOT be rejected. CHECK(evaluate(metrics(110.0, 0.0, /*sharpness=*/5.0), {5000.0, 0.0}, p).accept); // With a reference from a previous good frame here, a sudden collapse in // sharpness is a shake or an early trigger - reject and reshoot. auto v = evaluate(metrics(110.0, 0.0, 100.0), {5000.0, 0.0}, p, /*reference=*/1000.0); CHECK_FALSE(v.accept); CHECK(v.reason == "blur"); // Blur never re-meters: reshooting is the fix, not a different exposure. CHECK(v.next.exposure_us == doctest::Approx(5000.0)); CHECK(v.next.gain_db == doctest::Approx(0.0)); // A modest drop is normal scene variation and stays acceptable. CHECK(evaluate(metrics(110.0, 0.0, 900.0), {5000.0, 0.0}, p, 1000.0).accept); } TEST_CASE("the absolute blur backstop is off unless configured") { PolicyParams p = params(); CHECK(evaluate(metrics(110.0, 0.0, 0.1), {5000.0, 0.0}, p).accept); p.blur_absolute_floor = 50.0; CHECK_FALSE(evaluate(metrics(110.0, 0.0, 0.1), {5000.0, 0.0}, p).accept); } TEST_CASE("score ranks sharpness but discounts exposure error") { PolicyParams p = params(); // Between equally sharp frames, the better-exposed one wins. CHECK(score(metrics(110.0, 0.0, 1000.0), p) > score(metrics(200.0, 0.0, 1000.0), p)); // Between equally exposed frames, the sharper one wins. CHECK(score(metrics(110.0, 0.0, 2000.0), p) > score(metrics(110.0, 0.0, 1000.0), p)); // A blown frame is heavily penalised even when it is sharp. CHECK(score(metrics(110.0, 0.5, 2000.0), p) < score(metrics(110.0, 0.0, 1000.0), p)); // An unusable frame never wins. CHECK(score(ImageMetrics{}, p) < 0.0); }