#include "fgc/ExposurePolicy.h" #include #include namespace fgc { namespace { // Gain is logarithmic; work in linear multiplier space when splitting a brightness // correction between exposure time and gain. inline double dbToLinear(double db) { return std::pow(10.0, db / 20.0); } inline double linearToDb(double x) { return 20.0 * std::log10(std::max(x, 1e-9)); } // Split a desired brightness multiplier across exposure and gain. // // Brightening spends exposure FIRST and reaches for gain only once exposure is // capped: gain buys brightness at the cost of noise. Darkening does the reverse - // surrender gain first, for the same reason. The exposure ceiling is also the // motion-blur budget, so it is never exceeded to chase brightness. CaptureSettings applyRatio(const CaptureSettings& cur, double ratio, const PolicyParams& p) { CaptureSettings out = cur; if (ratio >= 1.0) { const double room = p.exposure_max_us / std::max(cur.exposure_us, 1e-6); const double used = std::min(ratio, room); out.exposure_us = std::clamp(cur.exposure_us * used, p.exposure_min_us, p.exposure_max_us); const double residual = ratio / std::max(used, 1e-9); if (residual > 1.0) out.gain_db = std::min(p.gain_max_db, linearToDb(dbToLinear(cur.gain_db) * residual)); } else { const double gain_lin = dbToLinear(cur.gain_db); const double gain_room = 1.0 / std::max(gain_lin, 1e-9); // how far gain can fall to 0 dB const double used = std::max(ratio, gain_room); out.gain_db = std::clamp(linearToDb(gain_lin * used), 0.0, p.gain_max_db); const double residual = ratio / std::max(used, 1e-9); if (residual < 1.0) out.exposure_us = std::clamp(cur.exposure_us * residual, p.exposure_min_us, p.exposure_max_us); } return out; } // True when a proposed correction would not meaningfully move the camera - i.e. // we are pinned at a limit and further attempts are pointless. bool sameSettings(const CaptureSettings& a, const CaptureSettings& b) { return std::abs(a.exposure_us - b.exposure_us) < 1.0 && std::abs(a.gain_db - b.gain_db) < 0.05; } } // namespace Verdict evaluate(const ImageMetrics& m, const CaptureSettings& current, const PolicyParams& p, double reference_sharpness) { Verdict v; v.next = current; if (!m.valid) { v.accept = false; v.reason = "invalid"; return v; } auto propose = [&](double ratio, const char* reason) { const double damped = 1.0 + p.damping * (ratio - 1.0); CaptureSettings next = applyRatio(current, damped, p); if (sameSettings(next, current)) { // Pinned at a limit: the camera cannot do better here, so accept what // we have rather than burning the remaining attempts on no-op retries. v.accept = true; v.reason = "saturated"; v.next = current; } else { v.accept = false; v.reason = reason; v.next = next; } }; // 1. Blown highlights outrank everything: unrecoverable, and invisible in the mean. if (m.clipped_fraction > p.clip_max_fraction) { // The mean gives no usable step here (it can look fine while the sky burns), // so back off by a fixed factor instead. propose(0.7, "clipped"); return v; } // 2. Mean luma outside the acceptance band. const double ratio = p.target_mean / std::max(m.mean_luma, 1.0); if (m.mean_luma < p.target_mean - p.mean_tolerance) { propose(ratio, "dark"); return v; } if (m.mean_luma > p.target_mean + p.mean_tolerance) { propose(ratio, "bright"); return v; } // 3. Exposure is good. Blur is checked last and never changes the settings - // a shake or an early trigger is fixed by reshooting, not by re-metering. if (p.blur_absolute_floor > 0.0 && m.sharpness < p.blur_absolute_floor) { v.accept = false; v.reason = "blur"; return v; } if (reference_sharpness > 0.0 && m.sharpness < p.blur_relative_floor * reference_sharpness) { v.accept = false; v.reason = "blur"; return v; } v.accept = true; v.reason = "ok"; return v; } double score(const ImageMetrics& m, const PolicyParams& p) { if (!m.valid) return -1.0; const double clip_penalty = m.clipped_fraction / std::max(p.clip_max_fraction, 1e-9); const double mean_penalty = std::abs(m.mean_luma - p.target_mean) / std::max(p.mean_tolerance, 1e-9); // Sharpness is the thing we ultimately want; exposure error only discounts it, // so a sharp well-exposed frame always beats a sharp blown-out one. return m.sharpness / (1.0 + clip_penalty + mean_penalty); } } // namespace fgc