From 62fc9d05460aa0ad5e1e4c8f81ae966796c066c9 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Mon, 17 Aug 2026 03:23:30 +0200 Subject: [PATCH] ipa: simple: awb: Add optional white point locus clamp and damping The simple IPA's AWB is pure gray world: every stats frame the applied gains become the frame-average G/R and G/B ratios of that frame. Two artifacts follow on real hardware. Panning swings the gains with the scene composition - a warm-dominated frame renders a white wall green, a cool-dominated one renders a dark desk red - and the CCM, which is interpolated over a colour temperature estimated from those same gains, amplifies and quantizes each swing. Add optional per-sensor tuning for the Awb algorithm: - Awb: whitePoints: # ordered by strictly ascending ct - { ct: 2856, gains: [ 1.378, 2.769 ] } # [ R gain, B gain ] - { ct: 5000, gains: [ 2.037, 1.809 ] } clampMargin: 0.35 # max distance from the locus in gain space damping: 0.8 # per-stats-frame EMA weight of the old gains whitePoints defines a piecewise-linear locus of calibrated illuminant white points in (R gain, B gain) space. The gray-world estimate is computed as before, then clamped to within clampMargin of the locus: scene colours can no longer pull the white balance arbitrarily far from colours a real illuminant would produce, while off-locus (greenish, e.g. fluorescent) illuminants within the margin still get corrected. The applied gains follow the clamped estimate through a per-stats-frame exponential moving average so composition changes fade in smoothly; a large difference persisting for several stats frames is a real illuminant change and halves the damping so scene cuts converge quickly instead of crawling. The colour temperature is no longer estimated with the generic estimateCCT() matrix but interpolated from the applied gains' position along the locus, so the Ccm algorithm always picks a matrix consistent with the applied white balance, on the same ct scale the tuning file's ccms table uses. A tuning file without whitePoints keeps the previous behaviour exactly. Signed-off-by: Jorijn van der Graaf --- src/ipa/simple/algorithms/awb.cpp | 240 ++++++++++++++++++++++++++++-- src/ipa/simple/algorithms/awb.h | 30 ++++ 2 files changed, 257 insertions(+), 13 deletions(-) diff --git a/src/ipa/simple/algorithms/awb.cpp b/src/ipa/simple/algorithms/awb.cpp index 05155c8..83dcece 100644 --- a/src/ipa/simple/algorithms/awb.cpp +++ b/src/ipa/simple/algorithms/awb.cpp @@ -1,34 +1,160 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ /* * Copyright (C) 2024-2026 Red Hat Inc. + * Copyright (C) 2026 Jorijn van der Graaf * * Auto white balance */ #include "awb.h" +#include +#include +#include #include +#include #include #include #include +#include "libcamera/internal/value_node.h" +#include "libcamera/internal/vector.h" + #include "libipa/colours.h" #include "simple/ipa_context.h" +namespace { + +constexpr double kDefaultClampMargin = 0.35; +constexpr double kDefaultDamping = 0.8; + +/* + * When the damped gains stay farther than kFastConvergeDistance (in gain + * space) from the clamped gray-world target for more than + * kFastConvergeFrames consecutive stats frames, the difference is a real + * illuminant change rather than scene-composition noise: halve the damping + * so the change converges quickly instead of crawling. + */ +constexpr double kFastConvergeDistance = 0.15; +constexpr unsigned int kFastConvergeFrames = 3; + +/* Start from the white point closest to daylight. */ +constexpr double kInitialCT = 5000.0; + +} /* namespace */ + namespace libcamera { LOG_DEFINE_CATEGORY(IPASoftAwb) namespace ipa::soft::algorithms { +/* + * Optional per-sensor tuning: + * + * - Awb: + * whitePoints: # ordered by strictly ascending ct + * - { ct: 2856, gains: [ 1.378, 2.769 ] } # [ R gain, B gain ] + * - { ct: 5000, gains: [ 2.037, 1.809 ] } + * clampMargin: 0.35 # max distance from the locus in gain space + * damping: 0.8 # per-stats-frame EMA weight of the old gains + * + * whitePoints defines a piecewise-linear locus of calibrated illuminant + * white points in (R gain, B gain) space. The gray-world estimate is + * clamped to within clampMargin of the locus, bounding how far scene + * colours can pull the white balance while still allowing off-locus + * (greenish, e.g. fluorescent) illuminants. The applied gains follow the + * clamped estimate through an exponential moving average, and the colour + * temperature fed to the Ccm algorithm is interpolated from the applied + * gains' position along the locus. Without whitePoints the plain + * gray-world algorithm runs unchanged. + */ +int Awb::init([[maybe_unused]] IPAContext &context, const ValueNode &tuningData) +{ + const ValueNode &wps = tuningData["whitePoints"]; + if (wps.isEmpty()) + return 0; + + if (!wps.isList() || wps.size() < 2) { + LOG(IPASoftAwb, Error) + << "whitePoints must be a list of at least two points"; + return -EINVAL; + } + + for (const ValueNode &node : wps.asList()) { + std::optional ct = node["ct"].get(); + std::optional> gains = + node["gains"].get>(); + if (!ct || !gains || (*gains)[0] <= 0.0 || (*gains)[1] <= 0.0) { + LOG(IPASoftAwb, Error) + << "whitePoints entries need a ct and positive gains [ R, B ]"; + whitePoints_.clear(); + return -EINVAL; + } + if (!whitePoints_.empty()) { + const WhitePoint &prev = whitePoints_.back(); + if (*ct <= prev.ct) { + LOG(IPASoftAwb, Error) + << "whitePoints must be ordered by strictly ascending ct"; + whitePoints_.clear(); + return -EINVAL; + } + if ((*gains)[0] == prev.rGain && (*gains)[1] == prev.bGain) { + LOG(IPASoftAwb, Error) + << "consecutive whitePoints must differ in gains"; + whitePoints_.clear(); + return -EINVAL; + } + } + whitePoints_.push_back({ *ct, (*gains)[0], (*gains)[1] }); + } + + clampMargin_ = tuningData["clampMargin"].get(kDefaultClampMargin); + damping_ = tuningData["damping"].get(kDefaultDamping); + if (clampMargin_ <= 0.0 || damping_ < 0.0 || damping_ >= 1.0) { + LOG(IPASoftAwb, Error) + << "clampMargin must be positive and damping within [0, 1)"; + whitePoints_.clear(); + return -EINVAL; + } + + LOG(IPASoftAwb, Info) + << "White point locus: " << whitePoints_.size() << " points, " + << whitePoints_.front().ct << "-" << whitePoints_.back().ct + << " K, clamp margin " << clampMargin_ + << ", damping " << damping_; + + return 0; +} + int Awb::configure(IPAContext &context, [[maybe_unused]] const IPAConfigInfo &configInfo) { auto &gains = context.activeState.awb.gains; gains = { { 1.0, 1.0, 1.0 } }; + fastFrames_ = 0; + + if (whitePoints_.empty()) + return 0; + + /* + * Start from the calibrated white point closest to daylight rather + * than from unity gains, so the first frames are roughly plausible + * while the loop converges. + */ + const WhitePoint *initial = &whitePoints_.front(); + for (const WhitePoint &wp : whitePoints_) { + if (std::abs(wp.ct - kInitialCT) < std::abs(initial->ct - kInitialCT)) + initial = ℘ + } + gains = { { static_cast(initial->rGain), 1.0, + static_cast(initial->bGain) } }; + context.activeState.awb.temperatureK = + static_cast(std::lround(initial->ct)); + return 0; } @@ -43,6 +169,39 @@ void Awb::prepare(IPAContext &context, params->gains = gains; } +/* + * Find the closest point on the piecewise-linear white point locus, and + * the colour temperature interpolated along the containing segment. + */ +Awb::LocusPosition Awb::closestOnLocus(double rGain, double bGain) const +{ + LocusPosition best{}; + double bestDist2 = std::numeric_limits::max(); + + for (unsigned int i = 0; i + 1 < whitePoints_.size(); i++) { + const WhitePoint &p0 = whitePoints_[i]; + const WhitePoint &p1 = whitePoints_[i + 1]; + const double dr = p1.rGain - p0.rGain; + const double db = p1.bGain - p0.bGain; + const double len2 = dr * dr + db * db; + double t = ((rGain - p0.rGain) * dr + (bGain - p0.bGain) * db) / len2; + t = std::clamp(t, 0.0, 1.0); + const double qr = p0.rGain + t * dr; + const double qb = p0.bGain + t * db; + const double dist2 = (rGain - qr) * (rGain - qr) + + (bGain - qb) * (bGain - qb); + if (dist2 < bestDist2) { + bestDist2 = dist2; + best.rGain = qr; + best.bGain = qb; + best.ct = p0.ct + t * (p1.ct - p0.ct); + } + } + + best.distance = std::sqrt(bestDist2); + return best; +} + void Awb::process(IPAContext &context, [[maybe_unused]] const uint32_t frame, IPAFrameContext &frameContext, @@ -73,24 +232,79 @@ void Awb::process(IPAContext &context, */ const RGB sum = stats->sum_.max(offset + minValid) - offset; + auto &gains = context.activeState.awb.gains; + + if (whitePoints_.empty()) { + /* + * Calculate red and blue gains for AWB. + * Clamp max gain at 4.0, this also avoids 0 division. + */ + gains = { { + sum.r() <= sum.g() / 4 ? 4.0f : static_cast(sum.g()) / sum.r(), + 1.0, + sum.b() <= sum.g() / 4 ? 4.0f : static_cast(sum.g()) / sum.b(), + } }; + + RGB rgbGains{ { 1 / gains.r(), 1 / gains.g(), 1 / gains.b() } }; + context.activeState.awb.temperatureK = estimateCCT(rgbGains); + metadata.set(controls::ColourTemperature, + context.activeState.awb.temperatureK); + + LOG(IPASoftAwb, Debug) + << "gain R/B: " << gains << "; temperature: " + << context.activeState.awb.temperatureK; + + return; + } + /* - * Calculate red and blue gains for AWB. - * Clamp max gain at 4.0, this also avoids 0 division. + * Gray-world estimate. The sums are at least minValid, so the + * divisions are safe; the locus clamp below bounds the result. */ - auto &gains = context.activeState.awb.gains; - gains = { { - sum.r() <= sum.g() / 4 ? 4.0f : static_cast(sum.g()) / sum.r(), - 1.0, - sum.b() <= sum.g() / 4 ? 4.0f : static_cast(sum.g()) / sum.b(), - } }; + double rTarget = static_cast(sum.g()) / sum.r(); + double bTarget = static_cast(sum.g()) / sum.b(); + + /* Clamp the estimate to within clampMargin_ of the locus. */ + const LocusPosition projection = closestOnLocus(rTarget, bTarget); + if (projection.distance > clampMargin_) { + const double f = clampMargin_ / projection.distance; + rTarget = projection.rGain + (rTarget - projection.rGain) * f; + bTarget = projection.bGain + (bTarget - projection.bGain) * f; + } - RGB rgbGains{ { 1 / gains.r(), 1 / gains.g(), 1 / gains.b() } }; - context.activeState.awb.temperatureK = estimateCCT(rgbGains); - metadata.set(controls::ColourTemperature, context.activeState.awb.temperatureK); + /* + * Damp the applied gains toward the clamped target so that changes + * of the frame composition (e.g. panning) do not swing the colours, + * converging faster when the difference is large and persistent. + */ + const double err = std::hypot(rTarget - gains.r(), bTarget - gains.b()); + if (err > kFastConvergeDistance) + fastFrames_++; + else + fastFrames_ = 0; + double damping = damping_; + if (fastFrames_ > kFastConvergeFrames) + damping /= 2.0; + + const double rNew = damping * gains.r() + (1.0 - damping) * rTarget; + const double bNew = damping * gains.b() + (1.0 - damping) * bTarget; + gains = { { static_cast(rNew), 1.0, static_cast(bNew) } }; + + /* + * The colour temperature the Ccm algorithm consumes follows from + * where the applied gains sit along the locus, so the matrix always + * matches the applied white balance. + */ + const LocusPosition applied = closestOnLocus(rNew, bNew); + context.activeState.awb.temperatureK = + static_cast(std::lround(applied.ct)); + metadata.set(controls::ColourTemperature, + context.activeState.awb.temperatureK); LOG(IPASoftAwb, Debug) - << "gain R/B: " << gains << "; temperature: " - << context.activeState.awb.temperatureK; + << "gain R/B: " << gains + << "; locus distance: " << projection.distance + << "; temperature: " << context.activeState.awb.temperatureK; } REGISTER_IPA_ALGORITHM(Awb, "Awb") diff --git a/src/ipa/simple/algorithms/awb.h b/src/ipa/simple/algorithms/awb.h index ad993f3..ef4e4f3 100644 --- a/src/ipa/simple/algorithms/awb.h +++ b/src/ipa/simple/algorithms/awb.h @@ -1,12 +1,15 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ /* * Copyright (C) 2024-2025 Red Hat Inc. + * Copyright (C) 2026 Jorijn van der Graaf * * Auto white balance */ #pragma once +#include + #include "algorithm.h" namespace libcamera { @@ -19,6 +22,7 @@ public: Awb() = default; ~Awb() = default; + int init(IPAContext &context, const ValueNode &tuningData) override; int configure(IPAContext &context, const IPAConfigInfo &configInfo) override; void prepare(IPAContext &context, const uint32_t frame, @@ -29,6 +33,32 @@ public: IPAFrameContext &frameContext, const SwIspStats *stats, ControlList &metadata) override; + +private: + struct WhitePoint { + double ct; + double rGain; + double bGain; + }; + + struct LocusPosition { + double rGain; + double bGain; + double ct; + double distance; + }; + + LocusPosition closestOnLocus(double rGain, double bGain) const; + + /* + * Calibrated white point locus in (R gain, B gain) space, ordered by + * strictly ascending colour temperature. Empty when the tuning file + * provides no white points; the plain gray-world algorithm runs then. + */ + std::vector whitePoints_; + double clampMargin_ = 0.0; + double damping_ = 0.0; + unsigned int fastFrames_ = 0; }; } /* namespace ipa::soft::algorithms */