fwt_software/src/core/ExposurePolicy.cpp

126 lines
4.8 KiB
C++

#include "fgc/ExposurePolicy.h"
#include <algorithm>
#include <cmath>
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