172 lines
6.3 KiB
C++
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);
|
|
}
|