Some checks failed
image / image (push) Failing after 3h4m27s
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
365 lines
14 KiB
Diff
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
|
|
|