fwt_software/tests/test_exposurepolicy.cpp

172 lines
6.3 KiB
C++

#include <doctest/doctest.h>
#include "fgc/ExposurePolicy.h"
#include <cmath>
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);
}