#include #include "fgc/GatedCameraSource.h" #include #include #include using namespace fgc; namespace { // A fake camera whose frames are synthesised from whatever exposure/gain the gate // asks for, so the whole feedback loop can be exercised without hardware. class FakeCamera : public ICameraSource { public: // Sensor model: mean luma is proportional to exposure x gain, saturating at 255. double sensitivity = 0.02; // 5500 us at 0 dB -> ~110 double sharpness_fill = 0; // 0 = checkerboard detail; >0 = flat (blurred) int fail_first_n = 0; // acquisitions that fail outright bool clip_always = false; // Per-attempt sharpness overrides, consumed in order; empty = always sharp. std::vector scripted_blur; int acquisitions = 0; std::vector exposures_seen; bool auto_disabled = false; bool started = false; void open() override {} void close() override {} void start() override { started = true; } void stop() override { started = false; } bool trigger() override { return false; } // never used when gating is on void setFrameCallback(FrameCallback cb) override { cb_ = std::move(cb); } int cameraCount() const override { return 1; } bool setExposure(double us) override { exposure_ = us; exposures_seen.push_back(us); return true; } bool setGain(double db) override { gain_ = db; return true; } bool setAutoExposureGain(bool on) override { auto_disabled = !on; return true; } double currentGain() override { return gain_; } bool acquireFrame(Frame& out, int) override { if (acquisitions++ < fail_first_n) return false; const double lin = exposure_ * std::pow(10.0, gain_ / 20.0); const double mean = std::min(250.0, lin * sensitivity); // Blur is modelled by flattening the image: a flat field has no // second-derivative energy, so its sharpness collapses. double blur = sharpness_fill; if (!scripted_blur.empty()) { blur = scripted_blur.front(); scripted_blur.erase(scripted_blur.begin()); } const uint32_t w = 64, h = 64; out = Frame{}; out.width = w; out.height = h; out.channels = 1; out.timestamp_ms = 1000 + acquisitions; out.data.assign(static_cast(w) * h, 0); for (uint32_t y = 0; y < h; ++y) { for (uint32_t x = 0; x < w; ++x) { double v = mean; if (blur <= 0.0) { // Zero-mean detail on a period of 3. A period-2 checkerboard // would alias against the stride-4 metric sampler (which only // ever hits one phase) and skew the mean by the full amplitude. const int k = static_cast((x + y) % 3); v = mean + (k == 0 ? -40.0 : (k == 1 ? 0.0 : 40.0)); } if (clip_always && x < w / 2) v = 255; out.data[y * w + x] = static_cast(std::clamp(v, 0.0, 255.0)); } } return true; } private: double exposure_ = 1000.0; double gain_ = 0.0; FrameCallback cb_; }; GatedCameraSource::Params params(int max_attempts = 3) { GatedCameraSource::Params p; p.max_attempts = max_attempts; p.min_attempts = 1; p.acquire_timeout_ms = 10; p.settle_delay_ms = 0; p.defaults = {1000.0, 0.0}; p.quality.sharpness_roi_px = 32; p.policy.target_mean = 110.0; p.policy.mean_tolerance = 12.0; p.policy.exposure_max_us = 20000.0; p.policy.gain_max_db = 12.0; return p; } // Build a gate over a fake camera, capturing delivered frames. struct Rig { FakeCamera* cam; std::unique_ptr gate; std::vector delivered; ExposureStore store{"", 1.0, 1800}; explicit Rig(GatedCameraSource::Params p, bool with_store = false) { auto owned = std::make_unique(); cam = owned.get(); gate = std::make_unique( std::move(owned), with_store ? &store : nullptr, p, [] { return std::make_pair(90.0, 0.0); }, [] { return 1'700'000'000'000LL; }, [](int) {}); gate->setFrameCallback([this](const Frame& f) { delivered.push_back(f); }); } }; } // namespace TEST_CASE("a good first frame is kept without extra acquisitions") { Rig r(params()); r.cam->sensitivity = 0.11; // 1000 us default already lands on ~110 CHECK(r.gate->trigger()); CHECK(r.cam->acquisitions == 1); REQUIRE(r.delivered.size() == 1); CHECK_FALSE(r.delivered[0].degraded); CHECK(r.gate->lastReport().attempts == 1); CHECK(r.gate->lastReport().reason == "ok"); CHECK_FALSE(r.gate->lastReport().degraded); } TEST_CASE("an underexposed frame is corrected and re-shot") { Rig r(params()); r.cam->sensitivity = 0.02; // 1000 us -> mean 20: far too dark CHECK(r.gate->trigger()); CHECK(r.cam->acquisitions > 1); REQUIRE(r.delivered.size() == 1); CHECK_FALSE(r.delivered[0].degraded); // Exposure was actually raised on the camera between attempts. REQUIRE(r.cam->exposures_seen.size() >= 2); CHECK(r.cam->exposures_seen[1] > r.cam->exposures_seen[0]); } TEST_CASE("an exhausted budget still delivers the best attempt, flagged degraded") { auto p = params(/*max_attempts=*/2); Rig r(p); r.cam->clip_always = true; // half the frame is blown no matter what we do CHECK(r.gate->trigger()); CHECK(r.cam->acquisitions == 2); REQUIRE(r.delivered.size() == 1); CHECK(r.delivered[0].degraded); // never lose a waypoint, but mark it CHECK(r.gate->lastReport().degraded); CHECK(r.gate->lastReport().reason == "exhausted"); CHECK(r.gate->lastReport().attempts == 2); } TEST_CASE("total acquisition failure delivers nothing and reports it") { auto p = params(/*max_attempts=*/2); Rig r(p); r.cam->fail_first_n = 99; CHECK_FALSE(r.gate->trigger()); CHECK(r.delivered.empty()); CHECK_FALSE(r.gate->lastReport().captured); } TEST_CASE("a transient acquisition failure does not lose the waypoint") { Rig r(params()); r.cam->sensitivity = 0.11; r.cam->fail_first_n = 1; // first attempt drops, second succeeds CHECK(r.gate->trigger()); REQUIRE(r.delivered.size() == 1); CHECK_FALSE(r.delivered[0].degraded); } TEST_CASE("min_attempts always shoots extra and keeps the sharpest") { auto p = params(/*max_attempts=*/3); p.min_attempts = 2; Rig r(p); r.cam->sensitivity = 0.11; // exposure is fine from the start // First frame blurred, second sharp: with min_attempts=2 the gate must take // both and keep the better one, which is the point of the setting. r.cam->scripted_blur = {1.0, 0.0}; CHECK(r.gate->trigger()); CHECK(r.cam->acquisitions == 2); REQUIRE(r.delivered.size() == 1); CHECK(r.gate->lastReport().metrics.sharpness > 0.0); CHECK_FALSE(r.delivered[0].degraded); } TEST_CASE("the gate takes exposure control away from the camera on start") { Rig r(params()); r.gate->start(); CHECK(r.cam->started); // The camera's own continuous auto would fight the gate, and its convergence // cost is exactly what the per-angle store exists to avoid. CHECK(r.cam->auto_disabled); } TEST_CASE("disabling the gate passes straight through to the inner camera") { auto p = params(); p.enabled = false; Rig r(p); CHECK_FALSE(r.gate->trigger()); // FakeCamera::trigger() is a no-op CHECK(r.cam->acquisitions == 0); CHECK(r.delivered.empty()); } TEST_CASE("accepted settings are written back to the store for the next visit") { Rig r(params(), /*with_store=*/true); r.cam->sensitivity = 0.02; CHECK(r.gate->trigger()); auto e = r.store.find(90.0, 0.0); REQUIRE(e.has_value()); CHECK(e->exposure_us > 1000.0); // the corrected value, not the cold default CHECK(e->sharpness > 0.0); CHECK(e->attempts >= 1); } TEST_CASE("a seeded angle converges in one attempt on the next visit") { Rig r(params(), /*with_store=*/true); r.cam->sensitivity = 0.02; CHECK(r.gate->trigger()); const int first_sweep = r.cam->acquisitions; CHECK(first_sweep > 1); // had to search for the right exposure r.cam->acquisitions = 0; CHECK(r.gate->trigger()); // Second visit starts from the stored setting, so no search is needed. This is // the whole point of the per-angle memory. CHECK(r.cam->acquisitions == 1); }