fp6-img/aports/temp/libcamera/0016-ipa-simple-awb-Gate-the-gray-world-estimate-on-near-.patch
Jorijn van der Graaf d91963975a
Some checks failed
image / image (push) Failing after 3h4m27s
temp/libcamera: r9 - zone-gated AWB for the simple IPA (patch 0016)
Divide the softISP statistics window into 16x12 zones and, when a white
point locus is tuned, vote only with zones whose implied gains lie within
zoneGateMargin of the locus - a large coloured light source can no longer
drag the white balance. Validated on-phone 2026-08-24: keyboard-scene A/B
shows a ~1700 K estimate shift caused solely by the gate (desk renders
neutral instead of blue); torch-toggle stability equal or better than r8
(settle 1.35-1.79 s, ON-edge repeatability +-0.011, CCM churn 21 vs 30
switches); AF regression clean. Evidence:
journal/camera/captures/2026-08-24-awb-zonegate-validation.md

Assisted-by: Claude:claude-fable-5
2026-08-24 01:05:14 +02:00

365 lines
14 KiB
Diff

From 3e109c99ec651719311580f574d8a152a5a09680 Mon Sep 17 00:00:00 2001
From: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
Date: Sun, 23 Aug 2026 22:32:48 +0200
Subject: [PATCH] ipa: simple: awb: Gate the gray-world estimate on
near-neutral zones
A single large coloured area - typically a coloured light source, e.g.
an RGB keyboard backlight filling the bottom of the frame - defeats
whole-frame gray world by design: the frame average it produces is
statistically identical to that of a neutral scene under some other
illuminant, so the locus-clamped estimate settles confidently at the
wrong end of the locus (measured 2026-08-17: a cyan-backlit keyboard
renders the ambient-lit desk red; stable, uniform and wrong).
Divide the statistics window into 16x12 zones and collect per-zone RGB
sums alongside the existing whole-frame sums, at the same sampling
density. In the Awb algorithm, when a white point locus is tuned, vote
only with the zones whose implied gains lie within zoneGateMargin of
the locus - zones some plausible illuminant could render gray - and
fall back to the whole-frame sums when fewer than four zones qualify.
Zones that are too dark, clipped, or carry too few samples are
excluded from the vote.
The gate runs ahead of the existing locus clamp and damping, which are
unchanged. Without whitePoints tuning the zone statistics are still
collected but the algorithm behaves exactly as before.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
---
.../internal/software_isp/swisp_stats.h | 28 +++++
src/ipa/simple/algorithms/awb.cpp | 119 +++++++++++++++++-
src/ipa/simple/algorithms/awb.h | 4 +
src/libcamera/software_isp/swstats_cpu.cpp | 47 ++++++-
4 files changed, 193 insertions(+), 5 deletions(-)
diff --git a/include/libcamera/internal/software_isp/swisp_stats.h b/include/libcamera/internal/software_isp/swisp_stats.h
index c2ec20e..22cdd0e 100644
--- a/include/libcamera/internal/software_isp/swisp_stats.h
+++ b/include/libcamera/internal/software_isp/swisp_stats.h
@@ -47,6 +47,34 @@ struct SwIspStats {
* \brief Holds the sharpness of an image
*/
uint64_t sharpness;
+ /**
+ * \brief Number of AWB statistics zone columns
+ */
+ static constexpr unsigned int kAwbZoneCols = 16;
+ /**
+ * \brief Number of AWB statistics zone rows
+ */
+ static constexpr unsigned int kAwbZoneRows = 12;
+ /**
+ * \brief Per-zone colour channel sums and sample count
+ *
+ * The sums are in the same scale as sum_. uint32_t is large enough:
+ * a zone of a 12-bit 8192x6144 frame at the stats sampling density
+ * sums to at most ~2^26.
+ */
+ struct AwbZone {
+ uint32_t sumR;
+ uint32_t sumG;
+ uint32_t sumB;
+ uint32_t count;
+ };
+ /**
+ * \brief Colour channel sums of the sampled pixels per image zone
+ *
+ * The statistics window is divided into kAwbZoneCols x kAwbZoneRows
+ * equally sized zones, stored in row-major order.
+ */
+ std::array<AwbZone, kAwbZoneCols * kAwbZoneRows> awbZones;
};
} /* namespace libcamera */
diff --git a/src/ipa/simple/algorithms/awb.cpp b/src/ipa/simple/algorithms/awb.cpp
index 83dcece..e47db76 100644
--- a/src/ipa/simple/algorithms/awb.cpp
+++ b/src/ipa/simple/algorithms/awb.cpp
@@ -43,6 +43,22 @@ constexpr unsigned int kFastConvergeFrames = 3;
/* Start from the white point closest to daylight. */
constexpr double kInitialCT = 5000.0;
+/*
+ * Zone gating thresholds. A zone votes only when it carries enough samples
+ * to trust its means (kZoneMinSamples), no channel mean is close to clipping
+ * (kZoneClipMean, in the 8-bit scale of the statistics), and the green mean
+ * clears the black level by at least kZoneMinSignal. The implied per-zone
+ * gains are capped at kZoneMaxGain to keep the locus distance finite for
+ * almost-black channels. Fewer than kZoneMinIncluded voting zones means the
+ * scene carries no usable spatial signal and the whole-frame estimate is
+ * used instead.
+ */
+constexpr unsigned int kZoneMinSamples = 16;
+constexpr double kZoneClipMean = 250.0;
+constexpr double kZoneMinSignal = 2.0;
+constexpr double kZoneMaxGain = 8.0;
+constexpr unsigned int kZoneMinIncluded = 4;
+
} /* namespace */
namespace libcamera {
@@ -70,6 +86,18 @@ namespace ipa::soft::algorithms {
* 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.
+ *
+ * With whitePoints two further keys enable spatial (zone-gated) AWB:
+ *
+ * zoneGating: true # vote with near-neutral zones only
+ * zoneGateMargin: 0.35 # max locus distance for a zone to vote
+ *
+ * The gray-world sums are then taken from the statistics zones whose
+ * implied gains lie within zoneGateMargin (default clampMargin) of the
+ * locus, i.e. zones some plausible illuminant could render gray, so a
+ * large coloured area - typically a coloured light source - no longer
+ * drags the white balance. When too few zones qualify the whole-frame
+ * sums are used as before.
*/
int Awb::init([[maybe_unused]] IPAContext &context, const ValueNode &tuningData)
{
@@ -120,11 +148,21 @@ int Awb::init([[maybe_unused]] IPAContext &context, const ValueNode &tuningData)
return -EINVAL;
}
+ zoneGating_ = tuningData["zoneGating"].get<bool>(true);
+ zoneGateMargin_ = tuningData["zoneGateMargin"].get<double>(clampMargin_);
+ if (zoneGateMargin_ <= 0.0) {
+ LOG(IPASoftAwb, Error) << "zoneGateMargin must be positive";
+ 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_;
+ << ", damping " << damping_
+ << ", zone gating " << (zoneGating_ ? "on" : "off")
+ << " margin " << zoneGateMargin_;
return 0;
}
@@ -202,6 +240,73 @@ Awb::LocusPosition Awb::closestOnLocus(double rGain, double bGain) const
return best;
}
+/*
+ * Compute the gray-world sums from the near-neutral image zones only.
+ *
+ * A large coloured area - typically a coloured light source such as an RGB
+ * keyboard backlight - defeats whole-frame averaging by design: the frame
+ * average it produces is statistically identical to that of a neutral scene
+ * under some other illuminant, so the estimate is confidently wrong. Per
+ * zone the distinction is visible: a zone votes only when the gains that
+ * would render it gray lie close to the calibrated white point locus, i.e.
+ * when some plausible illuminant could have produced it from a neutral
+ * surface. Zones that are too dark, clipped or strongly coloured are
+ * excluded. Returns false when too few zones vote; the caller then falls
+ * back to the whole-frame estimate.
+ */
+bool Awb::gatedEstimate(const SwIspStats &stats, double blackLevel,
+ double &rTarget, double &bTarget) const
+{
+ double sumR = 0.0, sumG = 0.0, sumB = 0.0;
+ unsigned int eligible = 0, included = 0;
+
+ for (const SwIspStats::AwbZone &zone : stats.awbZones) {
+ if (zone.count < kZoneMinSamples)
+ continue;
+
+ const double meanR = static_cast<double>(zone.sumR) / zone.count;
+ const double meanG = static_cast<double>(zone.sumG) / zone.count;
+ const double meanB = static_cast<double>(zone.sumB) / zone.count;
+
+ /* A clipped channel no longer measures colour. */
+ if (meanR > kZoneClipMean || meanG > kZoneClipMean ||
+ meanB > kZoneClipMean)
+ continue;
+
+ const double r = meanR - blackLevel;
+ const double g = meanG - blackLevel;
+ const double b = meanB - blackLevel;
+ if (g < kZoneMinSignal)
+ continue;
+
+ eligible++;
+
+ const double gR = g / std::max(r, g / kZoneMaxGain);
+ const double gB = g / std::max(b, g / kZoneMaxGain);
+ if (closestOnLocus(gR, gB).distance > zoneGateMargin_)
+ continue;
+
+ included++;
+ sumR += r * zone.count;
+ sumG += g * zone.count;
+ sumB += b * zone.count;
+ }
+
+ if (included < kZoneMinIncluded || sumR <= 0.0 || sumB <= 0.0) {
+ LOG(IPASoftAwb, Debug)
+ << "zone gate: " << included << "/" << eligible
+ << " zones, falling back to whole-frame";
+ return false;
+ }
+
+ LOG(IPASoftAwb, Debug)
+ << "zone gate: " << included << "/" << eligible << " zones vote";
+
+ rTarget = sumG / sumR;
+ bTarget = sumG / sumB;
+ return true;
+}
+
void Awb::process(IPAContext &context,
[[maybe_unused]] const uint32_t frame,
IPAFrameContext &frameContext,
@@ -258,11 +363,17 @@ void Awb::process(IPAContext &context,
}
/*
- * Gray-world estimate. The sums are at least minValid, so the
+ * Gray-world estimate, from the near-neutral zones when enough of
+ * them pass the chromaticity gate, from the whole-frame sums
+ * otherwise. The whole-frame sums are at least minValid, so the
* divisions are safe; the locus clamp below bounds the result.
*/
- double rTarget = static_cast<double>(sum.g()) / sum.r();
- double bTarget = static_cast<double>(sum.g()) / sum.b();
+ double rTarget, bTarget;
+ if (!zoneGating_ ||
+ !gatedEstimate(*stats, blackLevel, rTarget, bTarget)) {
+ rTarget = static_cast<double>(sum.g()) / sum.r();
+ bTarget = static_cast<double>(sum.g()) / sum.b();
+ }
/* Clamp the estimate to within clampMargin_ of the locus. */
const LocusPosition projection = closestOnLocus(rTarget, bTarget);
diff --git a/src/ipa/simple/algorithms/awb.h b/src/ipa/simple/algorithms/awb.h
index ef4e4f3..f86b31f 100644
--- a/src/ipa/simple/algorithms/awb.h
+++ b/src/ipa/simple/algorithms/awb.h
@@ -49,6 +49,8 @@ private:
};
LocusPosition closestOnLocus(double rGain, double bGain) const;
+ bool gatedEstimate(const SwIspStats &stats, double blackLevel,
+ double &rTarget, double &bTarget) const;
/*
* Calibrated white point locus in (R gain, B gain) space, ordered by
@@ -58,6 +60,8 @@ private:
std::vector<WhitePoint> whitePoints_;
double clampMargin_ = 0.0;
double damping_ = 0.0;
+ bool zoneGating_ = false;
+ double zoneGateMargin_ = 0.0;
unsigned int fastFrames_ = 0;
};
diff --git a/src/libcamera/software_isp/swstats_cpu.cpp b/src/libcamera/software_isp/swstats_cpu.cpp
index 2cbdbef..ed32c47 100644
--- a/src/libcamera/software_isp/swstats_cpu.cpp
+++ b/src/libcamera/software_isp/swstats_cpu.cpp
@@ -11,6 +11,8 @@
#include "libcamera/internal/software_isp/swstats_cpu.h"
+#include <algorithm>
+
#include <libcamera/base/log.h>
#include <libcamera/stream.h>
@@ -192,7 +194,19 @@ static constexpr unsigned int kBlueYMul = 29; /* 0.114 * 256 */
const unsigned int sharpXBegin = (lineLength) * 2 / 5; \
const unsigned int sharpXEnd = (lineLength) * 3 / 5; \
const bool sharpRow = y >= window_.height * 2 / 5 && \
- y < window_.height * 3 / 5;
+ y < window_.height * 3 / 5; \
+ uint32_t zSumR = 0; \
+ uint32_t zSumG = 0; \
+ uint32_t zSumB = 0; \
+ uint32_t zCount = 0; \
+ unsigned int zCol = 0; \
+ const unsigned int zLineLength = (lineLength); \
+ unsigned int zEndX = zLineLength / SwIspStats::kAwbZoneCols; \
+ SwIspStats::AwbZone *zRow = &stats.awbZones[ \
+ std::min(y * SwIspStats::kAwbZoneRows / \
+ static_cast<unsigned int>(window_.height), \
+ SwIspStats::kAwbZoneRows - 1) * \
+ SwIspStats::kAwbZoneCols];
/*
* Sharpness is the sum of the squared differences between green samples two
@@ -205,6 +219,20 @@ static constexpr unsigned int kBlueYMul = 29; /* 0.114 * 256 */
sumG += g; \
sumB += b; \
\
+ while (x >= zEndX && zCol + 1 < SwIspStats::kAwbZoneCols) { \
+ zRow[zCol].sumR += zSumR; \
+ zRow[zCol].sumG += zSumG; \
+ zRow[zCol].sumB += zSumB; \
+ zRow[zCol].count += zCount; \
+ zSumR = zSumG = zSumB = zCount = 0; \
+ zCol++; \
+ zEndX = (zCol + 1) * zLineLength / SwIspStats::kAwbZoneCols; \
+ } \
+ zSumR += r; \
+ zSumG += g; \
+ zSumB += b; \
+ zCount++; \
+ \
yVal = r * kRedYMul; \
yVal += g * kGreenYMul; \
yVal += b * kBlueYMul; \
@@ -227,6 +255,10 @@ static constexpr unsigned int kBlueYMul = 29; /* 0.114 * 256 */
stats.sum_.r() += sumR; \
stats.sum_.g() += sumG; \
stats.sum_.b() += sumB; \
+ zRow[zCol].sumR += zSumR; \
+ zRow[zCol].sumG += zSumG; \
+ zRow[zCol].sumB += zSumB; \
+ zRow[zCol].count += zCount; \
if (sharpCount) { \
const uint64_t meanG = sharpSumG / sharpCount; \
if (meanG) \
@@ -432,6 +464,7 @@ void SwStatsCpu::startFrame(uint32_t frame)
s.sum_ = RGB<uint64_t>({ 0, 0, 0 });
s.yHistogram.fill(0);
s.sharpness = 0;
+ s.awbZones.fill({ 0, 0, 0, 0 });
}
}
@@ -450,14 +483,26 @@ void SwStatsCpu::finishFrame(uint32_t frame, uint32_t bufferId)
sharedStats_->sum_ = RGB<uint64_t>({ 0, 0, 0 });
sharedStats_->sharpness = 0;
sharedStats_->yHistogram.fill(0);
+ sharedStats_->awbZones.fill({ 0, 0, 0, 0 });
for (const auto &s : stats_) {
sharedStats_->sharpness += s.sharpness;
sharedStats_->sum_ += s.sum_;
for (unsigned int j = 0; j < SwIspStats::kYHistogramSize; j++)
sharedStats_->yHistogram[j] += s.yHistogram[j];
+ for (unsigned int j = 0; j < sharedStats_->awbZones.size(); j++) {
+ sharedStats_->awbZones[j].sumR += s.awbZones[j].sumR;
+ sharedStats_->awbZones[j].sumG += s.awbZones[j].sumG;
+ sharedStats_->awbZones[j].sumB += s.awbZones[j].sumB;
+ sharedStats_->awbZones[j].count += s.awbZones[j].count;
+ }
}
sharedStats_->sum_ >>= sumShift_;
+ for (SwIspStats::AwbZone &zone : sharedStats_->awbZones) {
+ zone.sumR >>= sumShift_;
+ zone.sumG >>= sumShift_;
+ zone.sumB >>= sumShift_;
+ }
}
sharedStats_->valid = valid;
--
2.55.0