126 lines
4.8 KiB
C++
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
|