From 9c874e1e1202c7646d8fa3cabc66467f0982f5e3 Mon Sep 17 00:00:00 2001 From: Stuart J Mackintosh Date: Fri, 27 Mar 2026 16:37:39 +0100 Subject: [PATCH 01/16] ipa: libipa: camera_sensor_helper: Add OV01A10 Add a CameraSensorHelper for the OmniVision OV01A10 image sensor, used in Dell XPS 13 and other laptops with the Intel IPU6 camera subsystem. The analogue gain register (0x3508) uses a Q6.8 fixed-point format, with the minimum value OV01A10_ANAL_GAIN_MIN = 0x100 representing unity gain. This gives the linear model: gain = code / 256 Hans de Goede confirmed linear behaviour by monitoring the 18% grey patch of a Macbeth chart under controlled lighting while stepping the gain control. The black level of 0x40 at 10 bits (4096 scaled to 16 bits) was confirmed by dark frame measurement with the lens covered. Without this helper, libcamera's AGC algorithm cannot convert between gain codes and real gain values, causing auto-exposure oscillation and the following warning: IPASoft: Failed to create camera sensor helper for ov01a10 Signed-off-by: Stuart J Mackintosh --- src/ipa/libipa/camera_sensor_helper.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ipa/libipa/camera_sensor_helper.cpp b/src/ipa/libipa/camera_sensor_helper.cpp index e3e3e535..72466867 100644 --- a/src/ipa/libipa/camera_sensor_helper.cpp +++ b/src/ipa/libipa/camera_sensor_helper.cpp @@ -653,6 +653,18 @@ class CameraSensorHelperImx708 : public CameraSensorHelper }; REGISTER_CAMERA_SENSOR_HELPER("imx708", CameraSensorHelperImx708) +class CameraSensorHelperOv01a10 : public CameraSensorHelper +{ +public: + CameraSensorHelperOv01a10() + { + /* From dark frame measurement: 0x40 at 10bits. */ + blackLevel_ = 4096; + gain_ = AnalogueGainLinear{ 1, 0, 0, 256 }; + } +}; +REGISTER_CAMERA_SENSOR_HELPER("ov01a10", CameraSensorHelperOv01a10) + class CameraSensorHelperOv2685 : public CameraSensorHelper { public: From 680feeedc1763170225d6a0564a5332a7853e2d1 Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Fri, 1 May 2026 20:59:04 +0200 Subject: [PATCH 02/16] ipa: simple: awb: Add temporal smoothing and per-channel gain limits Add configurable YAML parameters maxGainR, maxGainB, and speed to AWB. Replace the single hardcoded max gain (4.0x) with per-channel limits, and apply exponential moving average smoothing to reduce colour temperature oscillation between frames. Signed-off-by: d3vv3 --- src/ipa/simple/algorithms/awb.cpp | 34 ++++++++++++++++++++++++++----- src/ipa/simple/algorithms/awb.h | 6 ++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/ipa/simple/algorithms/awb.cpp b/src/ipa/simple/algorithms/awb.cpp index f5c88ea6..937aabc8 100644 --- a/src/ipa/simple/algorithms/awb.cpp +++ b/src/ipa/simple/algorithms/awb.cpp @@ -14,6 +14,8 @@ #include +#include "libcamera/internal/yaml_parser.h" + #include "libipa/colours.h" #include "simple/ipa_context.h" @@ -23,6 +25,21 @@ LOG_DEFINE_CATEGORY(IPASoftAwb) namespace ipa::soft::algorithms { +int Awb::init([[maybe_unused]] IPAContext &context, + const ValueNode &tuningData) +{ + maxGainR_ = tuningData["maxGainR"].get().value_or(4.0f); + maxGainB_ = tuningData["maxGainB"].get().value_or(4.0f); + speed_ = tuningData["speed"].get().value_or(1.0f); + + LOG(IPASoftAwb, Debug) + << "AWB: maxGainR " << maxGainR_ + << ", maxGainB " << maxGainB_ + << ", speed " << speed_; + + return 0; +} + int Awb::configure(IPAContext &context, [[maybe_unused]] const IPAConfigInfo &configInfo) { @@ -84,14 +101,21 @@ void Awb::process(IPAContext &context, const RGB sum = stats->sum_.max(offset + minValid) - offset; /* - * Calculate red and blue gains for AWB. - * Clamp max gain at 4.0, this also avoids 0 division. + * Calculate red and blue gains for AWB. Clamp max gain to avoid + * division by zero and extreme color casts. */ auto &gains = context.activeState.awb.gains; + float rawRGain = sum.r() <= sum.g() / maxGainR_ ? maxGainR_ : + static_cast(sum.g()) / sum.r(); + float rawBGain = sum.b() <= sum.g() / maxGainB_ ? maxGainB_ : + static_cast(sum.g()) / sum.b(); + + /* Apply temporal smoothing to avoid rapid white balance changes. */ + float alpha = std::clamp(speed_, 0.01f, 1.0f); 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(), + gains.r() * (1.0f - alpha) + rawRGain * alpha, + 1.0f, + gains.b() * (1.0f - alpha) + rawBGain * alpha, } }; RGB rgbGains{ { 1 / gains.r(), 1 / gains.g(), 1 / gains.b() } }; diff --git a/src/ipa/simple/algorithms/awb.h b/src/ipa/simple/algorithms/awb.h index ad993f39..0aedc1d1 100644 --- a/src/ipa/simple/algorithms/awb.h +++ b/src/ipa/simple/algorithms/awb.h @@ -19,6 +19,7 @@ class Awb : public Algorithm 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 +30,11 @@ class Awb : public Algorithm IPAFrameContext &frameContext, const SwIspStats *stats, ControlList &metadata) override; + +private: + float maxGainR_; + float maxGainB_; + float speed_; }; } /* namespace ipa::soft::algorithms */ From 75f883e4a501e30c0dc00c2091dd5b25c6934ad2 Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Fri, 1 May 2026 20:59:02 +0200 Subject: [PATCH 03/16] ipa: simple: adjust: Support gamma, contrast, saturation defaults from YAML Read default values for gamma, contrast, and saturation from the tuning file so sensors can specify different image processing defaults without code changes. Falls back to prior defaults (gamma 2.2, contrast 1.0, saturation 1.0) when not specified in YAML. Signed-off-by: d3vv3 --- src/ipa/simple/algorithms/adjust.cpp | 48 +++++++++++++--------------- src/ipa/simple/algorithms/adjust.h | 4 +++ src/ipa/simple/ipa_context.h | 8 ++--- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/ipa/simple/algorithms/adjust.cpp b/src/ipa/simple/algorithms/adjust.cpp index 8bf39c4c..a03a6f1f 100644 --- a/src/ipa/simple/algorithms/adjust.cpp +++ b/src/ipa/simple/algorithms/adjust.cpp @@ -14,34 +14,37 @@ #include #include "libcamera/internal/matrix.h" +#include "libcamera/internal/yaml_parser.h" namespace libcamera { namespace ipa::soft::algorithms { -constexpr float kDefaultContrast = 1.0f; -constexpr float kDefaultSaturation = 1.0f; - LOG_DEFINE_CATEGORY(IPASoftAdjust) -int Adjust::init(IPAContext &context, [[maybe_unused]] const ValueNode &tuningData) +int Adjust::init(IPAContext &context, const ValueNode &tuningData) { + defaultGamma_ = tuningData["gamma"].get().value_or(kDefaultGamma); + defaultContrast_ = tuningData["contrast"].get().value_or(1.0f); + defaultSaturation_ = tuningData["saturation"].get().value_or(1.0f); + context.ctrlMap[&controls::Gamma] = - ControlInfo(0.1f, 10.0f, kDefaultGamma); + ControlInfo(0.1f, 10.0f, defaultGamma_); context.ctrlMap[&controls::Contrast] = - ControlInfo(0.0f, 2.0f, kDefaultContrast); + ControlInfo(0.0f, 2.0f, defaultContrast_); if (context.ccmEnabled) context.ctrlMap[&controls::Saturation] = - ControlInfo(0.0f, 2.0f, kDefaultSaturation); + ControlInfo(0.0f, 2.0f, defaultSaturation_); + return 0; } int Adjust::configure(IPAContext &context, [[maybe_unused]] const IPAConfigInfo &configInfo) { - context.activeState.knobs.gamma = kDefaultGamma; - context.activeState.knobs.contrast = std::optional(); - context.activeState.knobs.saturation = std::optional(); + context.activeState.knobs.gamma = defaultGamma_; + context.activeState.knobs.contrast = defaultContrast_; + context.activeState.knobs.saturation = defaultSaturation_; return 0; } @@ -59,13 +62,13 @@ void Adjust::queueRequest(typename Module::Context &context, const auto &contrast = controls.get(controls::Contrast); if (contrast.has_value()) { - context.activeState.knobs.contrast = contrast; + context.activeState.knobs.contrast = contrast.value(); LOG(IPASoftAdjust, Debug) << "Setting contrast to " << contrast.value(); } const auto &saturation = controls.get(controls::Saturation); if (saturation.has_value()) { - context.activeState.knobs.saturation = saturation; + context.activeState.knobs.saturation = saturation.value(); LOG(IPASoftAdjust, Debug) << "Setting saturation to " << saturation.value(); } } @@ -100,15 +103,15 @@ void Adjust::prepare(IPAContext &context, frameContext.gamma = context.activeState.knobs.gamma; frameContext.contrast = context.activeState.knobs.contrast; - auto &saturation = context.activeState.knobs.saturation; - if (context.ccmEnabled && saturation) { - applySaturation(context.activeState.combinedMatrix, saturation.value()); + const float saturation = context.activeState.knobs.saturation; + if (context.ccmEnabled) { + applySaturation(context.activeState.combinedMatrix, saturation); frameContext.saturation = saturation; } params->gamma = 1.0 / context.activeState.knobs.gamma; - const float contrast = context.activeState.knobs.contrast.value_or(kDefaultContrast); - params->contrastExp = tan(std::clamp(contrast * M_PI_4, 0.0, M_PI_2 - 0.00001)); + params->contrastExp = tan(std::clamp(context.activeState.knobs.contrast * M_PI_4, + 0.0, M_PI_2 - 0.00001)); } void Adjust::process([[maybe_unused]] IPAContext &context, @@ -117,14 +120,9 @@ void Adjust::process([[maybe_unused]] IPAContext &context, [[maybe_unused]] const SwIspStats *stats, ControlList &metadata) { - const auto &gamma = frameContext.gamma; - metadata.set(controls::Gamma, gamma); - - const auto &contrast = frameContext.contrast; - metadata.set(controls::Contrast, contrast.value_or(kDefaultContrast)); - - const auto &saturation = frameContext.saturation; - metadata.set(controls::Saturation, saturation.value_or(kDefaultSaturation)); + metadata.set(controls::Gamma, frameContext.gamma); + metadata.set(controls::Contrast, frameContext.contrast); + metadata.set(controls::Saturation, frameContext.saturation); } REGISTER_IPA_ALGORITHM(Adjust, "Adjust") diff --git a/src/ipa/simple/algorithms/adjust.h b/src/ipa/simple/algorithms/adjust.h index 49c1f26c..a836b51b 100644 --- a/src/ipa/simple/algorithms/adjust.h +++ b/src/ipa/simple/algorithms/adjust.h @@ -43,6 +43,10 @@ class Adjust : public Algorithm private: void applySaturation(Matrix &ccm, float saturation); + + float defaultGamma_; + float defaultContrast_; + float defaultSaturation_; }; } /* namespace ipa::soft::algorithms */ diff --git a/src/ipa/simple/ipa_context.h b/src/ipa/simple/ipa_context.h index 34f7403a..cd9a8eda 100644 --- a/src/ipa/simple/ipa_context.h +++ b/src/ipa/simple/ipa_context.h @@ -58,8 +58,8 @@ struct IPAActiveState { struct { float gamma; /* 0..2 range, 1.0 = normal */ - std::optional contrast; - std::optional saturation; + float contrast; + float saturation; } knobs; }; @@ -77,8 +77,8 @@ struct IPAFrameContext : public FrameContext { } gains; float gamma; - std::optional contrast; - std::optional saturation; + float contrast; + float saturation; }; struct IPAContext { From 858491cc7cf90af4ae2e953a5e60fe97005a323e Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Fri, 1 May 2026 20:59:01 +0200 Subject: [PATCH 04/16] ipa: simple: data: Add OV01A10 tuning file Add tuning data for the OmniVision OV01A10 sensor, calibrated from the factory-supplied Intel IPU6 AIQB binary (ov01a_1BG101N3_MTL.aiqb) shipped with the Dell XPS 9320 (Meteor Lake). The AIQB binary was parsed with some custom scripts to extract: - 8 colour correction matrices spanning 2856K-7500K (Incandescent to D75), all with rows summing to 1.0 (luminance-preserving) - AWB neutral locus: 9 achromatic R/G, B/G points defining the sensor's white balance response across illuminants - Sensor properties: 1280x800, 10-bit, GBRG colour order, base ISO 43 - Noise model: read noise variance 7.5e-5, shot noise slope 0.187 The AWB gain limits (maxGainR: 2.5, maxGainB: 3.2) are derived from the maximum gains required to neutralise the warmest and coolest illuminants in the locus, with 10% headroom. Signed-off-by: d3vv3 --- src/ipa/simple/data/meson.build | 1 + src/ipa/simple/data/ov01a10.yaml | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/ipa/simple/data/ov01a10.yaml diff --git a/src/ipa/simple/data/meson.build b/src/ipa/simple/data/meson.build index 92795ee4..e6110320 100644 --- a/src/ipa/simple/data/meson.build +++ b/src/ipa/simple/data/meson.build @@ -1,6 +1,7 @@ # SPDX-License-Identifier: CC0-1.0 conf_files = files([ + 'ov01a10.yaml', 'uncalibrated.yaml', ]) diff --git a/src/ipa/simple/data/ov01a10.yaml b/src/ipa/simple/data/ov01a10.yaml new file mode 100644 index 00000000..d1f613c5 --- /dev/null +++ b/src/ipa/simple/data/ov01a10.yaml @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: CC0-1.0 +%YAML 1.1 +--- +version: 1 +algorithms: + # Black level is not specified in the AIQB calibration binary; auto-detected + # from the histogram dark end at runtime. + - BlackLevel: + - Awb: + maxGainR: 2.5 + maxGainB: 3.2 + speed: 0.25 + - Ccm: + ccms: + - ct: 2856 + ccm: [ 1.1248, 0.2210, -0.3458, + -0.4616, 1.7736, -0.3120, + -0.4342, -0.9348, 2.3690 ] + - ct: 3000 + ccm: [ 1.5839, -0.4188, -0.1650, + -0.3670, 1.6565, -0.2895, + -0.1213, -1.0442, 2.1655 ] + - ct: 3450 + ccm: [ 1.6411, -0.5127, -0.1284, + -0.3680, 1.6337, -0.2657, + -0.1384, -1.0869, 2.2253 ] + - ct: 4000 + ccm: [ 1.5414, -0.4024, -0.1390, + -0.3304, 1.6352, -0.3048, + -0.1237, -0.6699, 1.7936 ] + - ct: 4150 + ccm: [ 1.7334, -0.6629, -0.0706, + -0.3121, 1.6267, -0.3146, + -0.0920, -0.9183, 2.0103 ] + - ct: 5000 + ccm: [ 1.5015, -0.3165, -0.1850, + -0.2277, 1.6190, -0.3913, + -0.0699, -0.7285, 1.7984 ] + - ct: 6500 + ccm: [ 1.8163, -0.7062, -0.1100, + -0.1640, 1.5736, -0.4096, + -0.0084, -0.8294, 1.8378 ] + - ct: 7500 + ccm: [ 1.8953, -0.7980, -0.0973, + -0.1539, 1.6001, -0.4462, + -0.0101, -0.7800, 1.7902 ] + - Adjust: + gamma: 2.2 + contrast: 1.0 + saturation: 1.0 + - Agc: + exposureTarget: 2.5 + hysteresis: 0.2 + stepDenominator: 20 +... From 18fa741a4223152ed9458ccf47558799e67ebe3f Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 6 Mar 2026 12:46:40 -0600 Subject: [PATCH 05/16] ipa: simple: agc: Replace bang-bang controller with proportional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AGC's updateExposure() uses a fixed ~10% step per frame regardless of how far the current exposure is from optimal. With a hysteresis dead band of only +/-4%, the controller overshoots when the correct value falls within one step, causing visible brightness oscillation (flicker). Replace the fixed-step bang-bang controller with a proportional one where the correction factor scales linearly with the MSV error: factor = 1.0 + error * 0.04 At maximum error (~2.5), this gives the same ~10% step as before. Near the target, steps shrink to <1%, eliminating overshoot. The existing hysteresis (kExposureSatisfactory) still prevents hunting on noise. Tested on OV2740 behind Intel IPU6 ISYS (ThinkPad X1 Carbon Gen 10) where the old controller produced continuous brightness flicker. The proportional controller converges in ~3 seconds from cold start with no visible oscillation. Signed-off-by: Javier Tia Reviewed-by: Milan Zamazal Tested-by: Barnabás Pőcze Signed-off-by: d3vv3 --- src/ipa/simple/algorithms/agc.cpp | 65 +++++++++++++++++++------------ 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/src/ipa/simple/algorithms/agc.cpp b/src/ipa/simple/algorithms/agc.cpp index 2f7e040c..ac977d5f 100644 --- a/src/ipa/simple/algorithms/agc.cpp +++ b/src/ipa/simple/algorithms/agc.cpp @@ -7,6 +7,8 @@ #include "agc.h" +#include +#include #include #include @@ -37,52 +39,66 @@ static constexpr float kExposureOptimal = kExposureBinsCount / 2.0; */ static constexpr float kExposureSatisfactory = 0.2; +/* + * Proportional gain for exposure/gain adjustment. Maps the MSV error to a + * multiplicative correction factor: + * + * factor = 1.0 + kExpProportionalGain * error + * + * With kExpProportionalGain = 0.04: + * - max error ~2.5 -> factor 1.10 (~10% step, same as before) + * - error 1.0 -> factor 1.04 (~4% step) + * - error 0.3 -> factor 1.012 (~1.2% step) + * + * This replaces the fixed 10% bang-bang step with a proportional correction + * that converges smoothly and avoids overshooting near the target. + */ +static constexpr float kExpProportionalGain = 0.04; + Agc::Agc() { } void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, double exposureMSV) { - /* - * kExpDenominator of 10 gives ~10% increment/decrement; - * kExpDenominator of 5 - about ~20% - */ - static constexpr uint8_t kExpDenominator = 10; - static constexpr uint8_t kExpNumeratorUp = kExpDenominator + 1; - static constexpr uint8_t kExpNumeratorDown = kExpDenominator - 1; - int32_t &exposure = frameContext.sensor.exposure; double &again = frameContext.sensor.gain; - if (exposureMSV < kExposureOptimal - kExposureSatisfactory) { + double error = kExposureOptimal - exposureMSV; + + if (std::abs(error) <= kExposureSatisfactory) + return; + + /* + * Compute a proportional correction factor. The sign of the error + * determines the direction: positive error means too dark (increase), + * negative means too bright (decrease). + */ + float factor = 1.0f + static_cast(error) * kExpProportionalGain; + + if (factor > 1.0f) { + /* Scene too dark: increase exposure first, then gain. */ if (exposure < context.configuration.agc.exposureMax) { - int32_t next = exposure * kExpNumeratorUp / kExpDenominator; - if (next - exposure < 1) - exposure += 1; - else - exposure = next; + int32_t next = static_cast(exposure * factor); + exposure = std::max(next, exposure + 1); } else { - double next = again * kExpNumeratorUp / kExpDenominator; + double next = again * factor; if (next - again < context.configuration.agc.againMinStep) again += context.configuration.agc.againMinStep; else again = next; } - } - - if (exposureMSV > kExposureOptimal + kExposureSatisfactory) { + } else { + /* Scene too bright: decrease gain first, then exposure. */ if (again > context.configuration.agc.again10) { - double next = again * kExpNumeratorDown / kExpDenominator; + double next = again * factor; if (again - next < context.configuration.agc.againMinStep) again -= context.configuration.agc.againMinStep; else again = next; } else { - int32_t next = exposure * kExpNumeratorDown / kExpDenominator; - if (exposure - next < 1) - exposure -= 1; - else - exposure = next; + int32_t next = static_cast(exposure * factor); + exposure = std::min(next, exposure - 1); } } @@ -96,6 +112,7 @@ void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, dou LOG(IPASoftExposure, Debug) << "exposureMSV " << exposureMSV + << " error " << error << " factor " << factor << " exp " << exposure << " again " << again; } From b93b9bbfc8734f71becaabfac330924855ddb5fb Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 1 May 2026 18:26:50 +0200 Subject: [PATCH 06/16] libcamera: software_isp: Normalize statistics sums to 8-bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SWSTATS_ACCUMULATE_LINE_STATS() macro divides the luminance value for the histogram to normalize it to 8-bit range, but does not apply the same normalization to the RGB sums. For 10-bit and 12-bit unpacked Bayer formats this means the sums are accumulated at native bit depth (0-1023 or 0-4095 per pixel) while the AWB algorithm subtracts an 8-bit black level from them, under-correcting by 4x or 16x respectively. Fix this by right-shifting the RGB sums in finishFrame() to normalize them to 8-bit scale, matching the histogram and the 8-bit black level used by AWB. A per-format sumShift_ value is set in configure(): 0 for 8-bit and CSI-2 packed formats (already 8-bit), 2 for 10-bit, and 4 for 12-bit unpacked formats. Signed-off-by: Javier Tia Reviewed-by: Milan Zamazal Tested-by: Milan Zamazal Reviewed-by: Barnabás Pőcze Tested-by: Barnabás Pőcze Signed-off-by: d3vv3 --- include/libcamera/internal/software_isp/swstats_cpu.h | 1 + src/libcamera/software_isp/swstats_cpu.cpp | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/include/libcamera/internal/software_isp/swstats_cpu.h b/include/libcamera/internal/software_isp/swstats_cpu.h index 802370bd..2dac6945 100644 --- a/include/libcamera/internal/software_isp/swstats_cpu.h +++ b/include/libcamera/internal/software_isp/swstats_cpu.h @@ -116,6 +116,7 @@ class SwStatsCpu unsigned int xShift_; unsigned int stride_; + unsigned int sumShift_; std::vector stats_; SharedMemObject sharedStats_; diff --git a/src/libcamera/software_isp/swstats_cpu.cpp b/src/libcamera/software_isp/swstats_cpu.cpp index 5366e019..2ed906e1 100644 --- a/src/libcamera/software_isp/swstats_cpu.cpp +++ b/src/libcamera/software_isp/swstats_cpu.cpp @@ -362,6 +362,11 @@ void SwStatsCpu::finishFrame(uint32_t frame, uint32_t bufferId) for (unsigned int j = 0; j < SwIspStats::kYHistogramSize; j++) sharedStats_->yHistogram[j] += s.yHistogram[j]; } + if (sumShift_) { + sharedStats_->sum_.r() >>= sumShift_; + sharedStats_->sum_.g() >>= sumShift_; + sharedStats_->sum_.b() >>= sumShift_; + } } sharedStats_->valid = valid; @@ -422,6 +427,7 @@ int SwStatsCpu::configure(const StreamConfiguration &inputCfg, unsigned int stat if (bayerFormat.packing == BayerFormat::Packing::None && setupStandardBayerOrder(bayerFormat.order) == 0) { processFrame_ = &SwStatsCpu::processBayerFrame2; + sumShift_ = bayerFormat.bitDepth - 8; switch (bayerFormat.bitDepth) { case 8: stats0_ = &SwStatsCpu::statsBGGR8Line0; @@ -442,6 +448,7 @@ int SwStatsCpu::configure(const StreamConfiguration &inputCfg, unsigned int stat /* Skip every 3th and 4th line, sample every other 2x2 block */ ySkipMask_ = 0x02; xShift_ = 0; + sumShift_ = 0; processFrame_ = &SwStatsCpu::processBayerFrame2; switch (bayerFormat.order) { From bf34d3d6e93bb4b2dcb23017adedb1114c10fb13 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 6 Mar 2026 12:46:42 -0600 Subject: [PATCH 07/16] ipa: libipa: camera_sensor_helper: Add OV2740 black level Set blackLevel_ = 4096 (0x40 at 10-bit) in CameraSensorHelperOv2740. The OV2740 kernel driver programs BLC target register 0x4003 with 0x40 for the 180 MHz link frequency mode. This matches the same pattern used by OV5675 and other OmniVision sensors with a 10-bit black level of 64. Without this, the Simple pipeline falls back to auto-guessing the black level, which happens to arrive at the same value but isn't documented. More importantly, the CameraSensorHelper is the canonical location for sensor calibration data and is used across all pipeline handlers, not just Simple. Suggested-by: Robert Mader Signed-off-by: Javier Tia Reviewed-by: Milan Zamazal Reviewed-by: Kieran Bingham Signed-off-by: d3vv3 --- src/ipa/libipa/camera_sensor_helper.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ipa/libipa/camera_sensor_helper.cpp b/src/ipa/libipa/camera_sensor_helper.cpp index 72466867..f3e8d7c8 100644 --- a/src/ipa/libipa/camera_sensor_helper.cpp +++ b/src/ipa/libipa/camera_sensor_helper.cpp @@ -684,6 +684,8 @@ class CameraSensorHelperOv2740 : public CameraSensorHelper public: CameraSensorHelperOv2740() { + /* From Linux kernel driver: 0x40 at 10bits. */ + blackLevel_ = 4096; gain_ = AnalogueGainLinear{ 1, 0, 0, 128 }; } }; From 9dd489514caea8c42b6c051a742d58d681caea13 Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Fri, 1 May 2026 20:58:53 +0200 Subject: [PATCH 08/16] ipa: simple: agc: Read exposure target and gain from YAML Replace the hardcoded kExposureOptimal, kExposureSatisfactory, and kExpProportionalGain constants with member variables read from the tuning file as exposureTarget, hysteresis, and proportionalGain. Defaults match the previous values (2.5, 0.2, 0.04). The constants are renamed to match their YAML keys and to use standard control-theory terminology: - kExposureOptimal -> exposureTarget: "optimal" implies a single universally correct value; "target" is the conventional ISP/AGC term for the setpoint the controller drives towards and is sensor-dependent. - kExposureSatisfactory -> hysteresis: the old name described the effect (exposure is satisfactory within this band) rather than the mechanism. "hysteresis" is the standard term for a deadband that prevents oscillation around a setpoint. - kExpProportionalGain -> proportionalGain: drops the redundant kExp prefix and matches the YAML key name directly. Signed-off-by: d3vv3 --- src/ipa/simple/algorithms/agc.cpp | 33 ++++++++++++++++++++----------- src/ipa/simple/algorithms/agc.h | 8 ++++++++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/ipa/simple/algorithms/agc.cpp b/src/ipa/simple/algorithms/agc.cpp index ac977d5f..66618d0f 100644 --- a/src/ipa/simple/algorithms/agc.cpp +++ b/src/ipa/simple/algorithms/agc.cpp @@ -28,45 +28,56 @@ static constexpr unsigned int kExposureBinsCount = 5; /* * The exposure is optimal when the mean sample value of the histogram is - * in the middle of the range. + * in the middle of the range. Overridable via YAML exposureTarget. */ -static constexpr float kExposureOptimal = kExposureBinsCount / 2.0; +static constexpr float kExposureTargetDefault = kExposureBinsCount / 2.0; /* * This implements the hysteresis for the exposure adjustment. * It is small enough to have the exposure close to the optimal, and is big * enough to prevent the exposure from wobbling around the optimal value. */ -static constexpr float kExposureSatisfactory = 0.2; +static constexpr float kHysteresisDefault = 0.2; /* * Proportional gain for exposure/gain adjustment. Maps the MSV error to a * multiplicative correction factor: * - * factor = 1.0 + kExpProportionalGain * error + * factor = 1.0 + proportionalGain_ * error * - * With kExpProportionalGain = 0.04: + * With proportionalGain_ = 0.04: * - max error ~2.5 -> factor 1.10 (~10% step, same as before) * - error 1.0 -> factor 1.04 (~4% step) * - error 0.3 -> factor 1.012 (~1.2% step) * - * This replaces the fixed 10% bang-bang step with a proportional correction - * that converges smoothly and avoids overshooting near the target. + * Overridable via YAML proportionalGain. */ -static constexpr float kExpProportionalGain = 0.04; +static constexpr float kProportionalGainDefault = 0.04; Agc::Agc() { } +int Agc::init([[maybe_unused]] IPAContext &context, const ValueNode &tuningData) +{ + exposureTarget_ = tuningData["exposureTarget"].get() + .value_or(kExposureTargetDefault); + hysteresis_ = tuningData["hysteresis"].get() + .value_or(kHysteresisDefault); + proportionalGain_ = tuningData["proportionalGain"].get() + .value_or(kProportionalGainDefault); + + return 0; +} + void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, double exposureMSV) { int32_t &exposure = frameContext.sensor.exposure; double &again = frameContext.sensor.gain; - double error = kExposureOptimal - exposureMSV; + double error = exposureTarget_ - exposureMSV; - if (std::abs(error) <= kExposureSatisfactory) + if (std::abs(error) <= hysteresis_) return; /* @@ -74,7 +85,7 @@ void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, dou * determines the direction: positive error means too dark (increase), * negative means too bright (decrease). */ - float factor = 1.0f + static_cast(error) * kExpProportionalGain; + float factor = 1.0f + static_cast(error) * proportionalGain_; if (factor > 1.0f) { /* Scene too dark: increase exposure first, then gain. */ diff --git a/src/ipa/simple/algorithms/agc.h b/src/ipa/simple/algorithms/agc.h index 112d9f5a..b8ed542b 100644 --- a/src/ipa/simple/algorithms/agc.h +++ b/src/ipa/simple/algorithms/agc.h @@ -7,6 +7,8 @@ #pragma once +#include + #include "algorithm.h" namespace libcamera { @@ -19,6 +21,8 @@ class Agc : public Algorithm Agc(); ~Agc() = default; + int init(IPAContext &context, const ValueNode &tuningData) override; + void process(IPAContext &context, const uint32_t frame, IPAFrameContext &frameContext, const SwIspStats *stats, @@ -26,6 +30,10 @@ class Agc : public Algorithm private: void updateExposure(IPAContext &context, IPAFrameContext &frameContext, double exposureMSV); + + float exposureTarget_; + float hysteresis_; + float proportionalGain_; }; } /* namespace ipa::soft::algorithms */ From 28c0547a012369568dc2b4b2c73bae9fdd3f62f2 Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Fri, 1 May 2026 19:06:18 +0200 Subject: [PATCH 09/16] ipa: simple: data: Document tuning parameters in uncalibrated.yaml Add comments documenting all algorithm YAML keys (BlackLevel, Awb, Ccm, Adjust, Agc) to the uncalibrated template so sensor calibration authors can discover parameters without reading source code. Update ov01a10.yaml replacing stepDenominator (removed) with proportionalGain for the new proportional AGC controller. Signed-off-by: d3vv3 --- src/ipa/simple/data/ov01a10.yaml | 2 +- src/ipa/simple/data/uncalibrated.yaml | 40 +++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/ipa/simple/data/ov01a10.yaml b/src/ipa/simple/data/ov01a10.yaml index d1f613c5..9663c6da 100644 --- a/src/ipa/simple/data/ov01a10.yaml +++ b/src/ipa/simple/data/ov01a10.yaml @@ -51,5 +51,5 @@ algorithms: - Agc: exposureTarget: 2.5 hysteresis: 0.2 - stepDenominator: 20 + proportionalGain: 0.04 ... diff --git a/src/ipa/simple/data/uncalibrated.yaml b/src/ipa/simple/data/uncalibrated.yaml index fc90ca52..7391c36e 100644 --- a/src/ipa/simple/data/uncalibrated.yaml +++ b/src/ipa/simple/data/uncalibrated.yaml @@ -3,17 +3,45 @@ --- version: 1 algorithms: + # --- Black Level --- + # blackLevel: 16-bit black level pedestal (optional). + # If omitted, auto-detected from histogram dark end. - BlackLevel: + + # --- Auto White Balance --- + # maxGainR: Maximum red channel gain (default 4.0). + # maxGainB: Maximum blue channel gain (default 4.0). + # speed: Temporal smoothing factor 0-1 (default 1.0 = instant). + # 0.25 = slow smooth, 0.5 = moderate, 1.0 = no smoothing. - Awb: - # Color correction matrices can be defined here. The CCM algorithm - # has a significant performance impact, and should only be enabled - # if tuned. + + # --- Color Correction Matrix --- + # Has a significant performance impact on the CPU ISP, and should + # only be enabled if tuned. Provide ccms as a list of color temperature + # entries with a 3x3 matrix: # - Ccm: # ccms: # - ct: 6500 - # ccm: [ 1, 0, 0, - # 0, 1, 0, - # 0, 0, 1] + # ccm: [ 1.0, 0.0, 0.0, + # 0.0, 1.0, 0.0, + # 0.0, 0.0, 1.0 ] + # - Ccm: + + # --- Image Adjustments --- + # gamma: Gamma encoding value (default 2.2, range 0.1-10.0). + # contrast: Contrast scaling (default 1.0, range 0.0-2.0). + # saturation: Saturation multiplier (default 1.0, range 0.0-2.0). + # Only active when CCM is enabled. - Adjust: + + # --- Auto Gain/Exposure Control (proportional) --- + # exposureTarget: Target MSV (mean sample value) in histogram bins. + # Default: 2.5 (middle of 5-bin range). + # Lower values target a darker exposure. + # hysteresis: Deadband around target where no adjustment occurs. + # Default 0.2. Larger values reduce sensitivity near target. + # proportionalGain: Step scaling factor (default 0.04). + # At max error (~2.5): factor 1.10 (~10% step). + # Near target: factor ~1.01 (~1% step). - Agc: ... From d92765f170388306d0787c8b88065fb60dd75eef Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Wed, 6 May 2026 23:45:21 +0200 Subject: [PATCH 10/16] test: ipa: libipa: Add CCM row-sum validation test Verify that each row of a colour correction matrix sums to 1.0 (luminance preservation property). Tests cover: - identity and known-bad inline matrices - a real OV01A10 D65 calibrated CCM - parsing a multi-entry CCM YAML table via Interpolator - detection of a bad entry in YAML Tolerance is 5e-4 to accommodate 4-decimal-place rounding in tuning files. Signed-off-by: d3vv3 --- test/ipa/libipa/ccm.cpp | 158 ++++++++++++++++++++++++++++++++++++ test/ipa/libipa/meson.build | 1 + 2 files changed, 159 insertions(+) create mode 100644 test/ipa/libipa/ccm.cpp diff --git a/test/ipa/libipa/ccm.cpp b/test/ipa/libipa/ccm.cpp new file mode 100644 index 00000000..efc0035a --- /dev/null +++ b/test/ipa/libipa/ccm.cpp @@ -0,0 +1,158 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +/* + * Copyright (C) 2024-2026, Red Hat Inc. + * + * CCM matrix row-sum validation tests + * + * Each row of a colour correction matrix must sum to 1.0 (luminance + * preservation). This test verifies that property for inline matrix data + * and for matrices parsed from a YAML CCM table. + */ + +#include "../../../src/ipa/libipa/interpolator.h" + +#include +#include +#include +#include + +#include "libcamera/base/file.h" +#include "libcamera/internal/matrix.h" +#include "libcamera/internal/yaml_parser.h" + +#include "test.h" + +using namespace std; +using namespace libcamera; +using namespace ipa; + +/* Tolerance for floating-point row-sum comparison. + * CCM values in tuning files are typically given to 4 decimal places, + * which can introduce up to ~0.5e-3 rounding error per row. */ +static constexpr float kRowSumTolerance = 5e-4f; + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + cerr << "FAIL: " #cond "\n"; \ + return TestFail; \ + } \ + } while (0) + +static bool allRowsSumToOne(const Matrix &m) +{ + for (unsigned int row = 0; row < 3; row++) { + float sum = 0.0f; + for (unsigned int col = 0; col < 3; col++) + sum += m[row][col]; + if (std::abs(sum - 1.0f) > kRowSumTolerance) + return false; + } + return true; +} + +class CcmRowSumTest : public Test +{ +protected: + bool writeTempYaml(const std::string &content, std::string &filename) + { + filename = "/tmp/libcamera.ccm.test.XXXXXX"; + int fd = mkstemp(&filename.front()); + if (fd == -1) + return false; + ssize_t ret = write(fd, content.c_str(), content.size()); + close(fd); + return ret == static_cast(content.size()); + } + + std::unique_ptr parseYaml(const std::string &content) + { + std::string filename; + if (!writeTempYaml(content, filename)) + return nullptr; + + File file{ filename }; + if (!file.open(File::OpenModeFlag::ReadOnly)) + return nullptr; + + auto root = YamlParser::parse(file); + unlink(filename.c_str()); + return root; + } + + int run() + { + /* --- 1. Known-good identity matrix --- */ + Matrix identity{ { 1, 0, 0, + 0, 1, 0, + 0, 0, 1 } }; + ASSERT_TRUE(allRowsSumToOne(identity)); + + /* --- 2. Known-bad matrix (rows do not sum to 1) --- */ + Matrix bad{ { 2, 0, 0, + 0, 1, 0, + 0, 0, 1 } }; + ASSERT_TRUE(!allRowsSumToOne(bad)); + + /* --- 3. Typical calibrated CCM (D65, OV01A10) --- */ + Matrix d65{ { 1.8163f, -0.7062f, -0.1100f, + -0.1640f, 1.5736f, -0.4096f, + -0.0084f, -0.8294f, 1.8378f } }; + ASSERT_TRUE(allRowsSumToOne(d65)); + + /* --- 4. Parse a valid CCM table from YAML and validate --- */ + const std::string validYaml = + "- ct: 2856\n" + " ccm: [ 1.1248, 0.2210, -0.3458,\n" + " -0.4616, 1.7736, -0.3120,\n" + " -0.4342, -0.9348, 2.3690 ]\n" + "- ct: 6500\n" + " ccm: [ 1.8163, -0.7062, -0.1100,\n" + " -0.1640, 1.5736, -0.4096,\n" + " -0.0084, -0.8294, 1.8378 ]\n" + "- ct: 7500\n" + " ccm: [ 1.8953, -0.7980, -0.0973,\n" + " -0.1539, 1.6001, -0.4462,\n" + " -0.0101, -0.7800, 1.7902 ]\n"; + + auto root = parseYaml(validYaml); + ASSERT_TRUE(root); + + Interpolator> interp; + ASSERT_TRUE(interp.readYaml(*root, "ct", "ccm") == 0); + ASSERT_TRUE(interp.data().size() == 3); + + for (const auto &[ct, m] : interp.data()) { + if (!allRowsSumToOne(m)) { + cerr << "CCM at ct=" << ct + << " has a row that does not sum to 1.0\n"; + return TestFail; + } + } + + /* --- 5. Detect a bad entry in YAML --- */ + const std::string badYaml = + "- ct: 5000\n" + " ccm: [ 2.0000, -0.7062, -0.1100,\n" + " -0.1640, 1.5736, -0.4096,\n" + " -0.0084, -0.8294, 1.8378 ]\n"; + + auto badRoot = parseYaml(badYaml); + ASSERT_TRUE(badRoot); + + Interpolator> badInterp; + ASSERT_TRUE(badInterp.readYaml(*badRoot, "ct", "ccm") == 0); + + for (const auto &[ct, m] : badInterp.data()) { + if (allRowsSumToOne(m)) { + cerr << "Expected bad CCM at ct=" << ct + << " to fail row-sum check, but it passed\n"; + return TestFail; + } + } + + return TestPass; + } +}; + +TEST_REGISTER(CcmRowSumTest) diff --git a/test/ipa/libipa/meson.build b/test/ipa/libipa/meson.build index c3e25587..8c36800c 100644 --- a/test/ipa/libipa/meson.build +++ b/test/ipa/libipa/meson.build @@ -1,6 +1,7 @@ # SPDX-License-Identifier: CC0-1.0 libipa_test = [ + {'name': 'ccm', 'sources': ['ccm.cpp']}, {'name': 'fixedpoint', 'sources': ['fixedpoint.cpp']}, {'name': 'histogram', 'sources': ['histogram.cpp']}, {'name': 'interpolator', 'sources': ['interpolator.cpp']}, From a8b383145ee2b99c350a1f26e21d8ad3e4038155 Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Fri, 8 May 2026 17:51:38 +0200 Subject: [PATCH 11/16] ipa: simple: awb: Don't bake AWB gains into combinedMatrix The CCM rows sum to 1.0, meaning it expects a white-balanced signal in [0,1]. Previously AWB gains were multiplied into combinedMatrix alongside the CCM, producing a combined matrix with row sums far from 1.0 (e.g. [1.62, 0.63, 2.23] at 4500K). A neutral white pixel then produced R=1.62, G=0.63, B=2.23 -- magenta -- before any clamping. Instead, store AWB gains in DebayerParams::gains only, leaving combinedMatrix to hold just the CCM (and saturation). The shader applies AWB gains separately with a clamp to [0,1] before the CCM multiply. --- .../internal/software_isp/debayer_params.h | 4 ++-- src/ipa/simple/algorithms/awb.cpp | 13 ++++++------- src/ipa/simple/data/ov01a10.yaml | 17 ++++++++++++++--- src/libcamera/shaders/bayer_unpacked.frag | 13 +++++++++++++ src/libcamera/software_isp/debayer_egl.cpp | 7 +++++++ src/libcamera/software_isp/debayer_egl.h | 1 + 6 files changed, 43 insertions(+), 12 deletions(-) diff --git a/include/libcamera/internal/software_isp/debayer_params.h b/include/libcamera/internal/software_isp/debayer_params.h index 6772b43b..4d95c135 100644 --- a/include/libcamera/internal/software_isp/debayer_params.h +++ b/include/libcamera/internal/software_isp/debayer_params.h @@ -19,8 +19,8 @@ namespace libcamera { struct DebayerParams { Matrix combinedMatrix = { { 1.0, 0.0, 0.0, - 0.0, 1.0, 0.0, - 0.0, 0.0, 1.0 } }; + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0 } }; RGB blackLevel = RGB({ 0.0, 0.0, 0.0 }); float gamma = 1.0; float contrastExp = 1.0; diff --git a/src/ipa/simple/algorithms/awb.cpp b/src/ipa/simple/algorithms/awb.cpp index 937aabc8..8ccd152d 100644 --- a/src/ipa/simple/algorithms/awb.cpp +++ b/src/ipa/simple/algorithms/awb.cpp @@ -55,16 +55,15 @@ void Awb::prepare(IPAContext &context, DebayerParams *params) { auto &gains = context.activeState.awb.gains; - Matrix gainMatrix = { { gains.r(), 0, 0, - 0, gains.g(), 0, - 0, 0, gains.b() } }; - context.activeState.combinedMatrix = - gainMatrix * context.activeState.combinedMatrix; + /* + * Store AWB gains in params for the shader to apply separately. + * AWB gains are NOT baked into combinedMatrix so that the CCM always + * receives a clamped [0,1] white-balanced signal (see shader). + */ + params->gains = gains; frameContext.gains.red = gains.r(); frameContext.gains.blue = gains.b(); - - params->gains = gains; } void Awb::process(IPAContext &context, diff --git a/src/ipa/simple/data/ov01a10.yaml b/src/ipa/simple/data/ov01a10.yaml index 9663c6da..4b208879 100644 --- a/src/ipa/simple/data/ov01a10.yaml +++ b/src/ipa/simple/data/ov01a10.yaml @@ -11,6 +11,17 @@ algorithms: maxGainB: 3.2 speed: 0.25 - Ccm: + # CCMs extracted verbatim from the OV01A10 AIQB calibration binary. + # The Intel IPU6 hardware pipeline applies a GLIM (Global Inverse Tone + # Mapping) stage before the ACM/CCM block to compress highlights, so + # the CCMs were calibrated assuming bright pixels are already compressed. + # Without GLIM, the large diagonal gains clip the R channel on bright + # pixels (magenta highlights). highlightCompression implements the same + # Reinhard-style luma gain: g = 1 / (1 + luma * k) + # + # The pipeline order is AWB first (with clamp to [0,1]), then CCM. + # The CCM rows sum to 1.0 and always receives a clamped white-balanced + # signal, so highlights map to white rather than magenta. ccms: - ct: 2856 ccm: [ 1.1248, 0.2210, -0.3458, @@ -49,7 +60,7 @@ algorithms: contrast: 1.0 saturation: 1.0 - Agc: - exposureTarget: 2.5 - hysteresis: 0.2 - proportionalGain: 0.04 + exposureTarget: 2.0 + hysteresis: 0.3 + proportionalGain: 0.12 ... diff --git a/src/libcamera/shaders/bayer_unpacked.frag b/src/libcamera/shaders/bayer_unpacked.frag index 1b85196a..1883f80a 100644 --- a/src/libcamera/shaders/bayer_unpacked.frag +++ b/src/libcamera/shaders/bayer_unpacked.frag @@ -25,6 +25,7 @@ varying vec4 center; varying vec4 yCoord; varying vec4 xCoord; uniform mat3 ccm; +uniform vec3 awbGains; uniform vec3 blacklevel; uniform float gamma; uniform float contrastExp; @@ -170,6 +171,18 @@ void main(void) { gin = rgb.g; bin = rgb.b; + /* + * Apply AWB gains and clamp to [0,1] before CCM. + * + * The CCM rows sum to 1.0, meaning it is designed to receive a + * white-balanced signal in [0,1]. Clamping after AWB ensures the CCM + * never sees values > 1.0, which would cause differential clipping across + * channels (magenta/cyan highlights). + */ + rin = clamp(rin * awbGains.r, 0.0, 1.0); + gin = clamp(gin * awbGains.g, 0.0, 1.0); + bin = clamp(bin * awbGains.b, 0.0, 1.0); + rgb.r = (rin * ccm[0][0]) + (gin * ccm[0][1]) + (bin * ccm[0][2]); rgb.g = (rin * ccm[1][0]) + (gin * ccm[1][1]) + (bin * ccm[1][2]); rgb.b = (rin * ccm[2][0]) + (gin * ccm[2][1]) + (bin * ccm[2][2]); diff --git a/src/libcamera/software_isp/debayer_egl.cpp b/src/libcamera/software_isp/debayer_egl.cpp index 8f0c229f..4b953dfd 100644 --- a/src/libcamera/software_isp/debayer_egl.cpp +++ b/src/libcamera/software_isp/debayer_egl.cpp @@ -100,6 +100,7 @@ int DebayerEGL::getShaderVariableLocations(void) textureUniformBayerDataIn_ = glGetUniformLocation(programId_, "tex_y"); ccmUniformDataIn_ = glGetUniformLocation(programId_, "ccm"); + awbGainsUniformDataIn_ = glGetUniformLocation(programId_, "awbGains"); blackLevelUniformDataIn_ = glGetUniformLocation(programId_, "blacklevel"); gammaUniformDataIn_ = glGetUniformLocation(programId_, "gamma"); contrastExpUniformDataIn_ = glGetUniformLocation(programId_, "contrastExp"); @@ -474,6 +475,12 @@ void DebayerEGL::setShaderVariableValues(const DebayerParams ¶ms) glUniformMatrix3fv(ccmUniformDataIn_, 1, GL_FALSE, ccm); LOG(Debayer, Debug) << " ccmUniformDataIn_ " << ccmUniformDataIn_ << " data " << params.combinedMatrix; + /* + * AWB gains (applied in shader before CCM, with clamp to [0,1]) + */ + glUniform3f(awbGainsUniformDataIn_, params.gains.r(), params.gains.g(), params.gains.b()); + LOG(Debayer, Debug) << " awbGainsUniformDataIn_ " << awbGainsUniformDataIn_ << " data " << params.gains; + /* * 0 = Red, 1 = Green, 2 = Blue */ diff --git a/src/libcamera/software_isp/debayer_egl.h b/src/libcamera/software_isp/debayer_egl.h index fcd281f4..07528666 100644 --- a/src/libcamera/software_isp/debayer_egl.h +++ b/src/libcamera/software_isp/debayer_egl.h @@ -101,6 +101,7 @@ class DebayerEGL : public Debayer /* Contrast */ GLint contrastExpUniformDataIn_; + GLint awbGainsUniformDataIn_; Rectangle window_; std::unique_ptr stats_; From 7a8ff0333acfa18f0a216dbd083edddda67c4c4e Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Fri, 8 May 2026 17:58:39 +0200 Subject: [PATCH 12/16] test: ipa: libipa: Add AWB+CCM pipeline highlight clipping tests Verify that applying AWB gains with a clamp to [0,1] before the CCM prevents magenta highlights, while the old approach of baking AWB gains into the combined matrix produces magenta on bright neutral pixels. Five test cases: 1. Old baked-matrix approach produces magenta on a bright neutral pixel. 2. New clamp-then-CCM approach produces neutral white on the same pixel. 3. Mid-tones are identical between both approaches (no unintended shift). 4. Fix works across multiple CCM colour temperatures (4000K and 6500K). 5. Black pixels are unaffected. --- test/ipa/libipa/awb_ccm_pipeline.cpp | 239 +++++++++++++++++++++++++++ test/ipa/libipa/meson.build | 1 + 2 files changed, 240 insertions(+) create mode 100644 test/ipa/libipa/awb_ccm_pipeline.cpp diff --git a/test/ipa/libipa/awb_ccm_pipeline.cpp b/test/ipa/libipa/awb_ccm_pipeline.cpp new file mode 100644 index 00000000..d140a604 --- /dev/null +++ b/test/ipa/libipa/awb_ccm_pipeline.cpp @@ -0,0 +1,239 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +/* + * Copyright (C) 2026, Red Hat Inc. + * + * AWB + CCM pipeline highlight clipping tests + * + * The CCM rows sum to 1.0, meaning it is designed to receive a + * white-balanced signal in [0,1]. AWB gains must be applied and clamped + * to [0,1] BEFORE the CCM multiply; otherwise the combined matrix has row + * sums far from 1.0 and bright neutral pixels produce magenta/cyan output. + * + * These tests verify: + * 1. The old (broken) approach: baking AWB into the matrix causes magenta. + * 2. The new (correct) approach: separate AWB+clamp then CCM gives neutral. + * 3. Mid-tones are unaffected by the clamp (no colour shift for normal pixels). + * 4. The fix works across multiple CCM colour temperatures. + */ + +#include +#include + +#include "libcamera/internal/matrix.h" +#include "libcamera/internal/vector.h" + +#include "test.h" + +using namespace std; +using namespace libcamera; + +/* Tolerance for output channel comparison. */ +static constexpr float kTol = 1e-3f; + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + cerr << "FAIL: " #cond " (line " \ + << __LINE__ << ")\n"; \ + return TestFail; \ + } \ + } while (0) + +#define ASSERT_NEAR(a, b, tol) \ + do { \ + if (std::abs((a) - (b)) > (tol)) { \ + cerr << "FAIL: |" << (a) << " - " \ + << (b) << "| > " << (tol) \ + << " (line " << __LINE__ << ")\n"; \ + return TestFail; \ + } \ + } while (0) + +/* + * Apply AWB gains by baking them into the CCM (old broken approach). + * Returns the output RGB for a given raw input pixel. + */ +static RGB applyBakedMatrix(const Matrix &ccm, + const RGB &awbGains, + const RGB &rawPixel) +{ + /* Combined = CCM * diag(awbGains) */ + Matrix awbDiag = { { awbGains.r(), 0, 0, + 0, awbGains.g(), 0, + 0, 0, awbGains.b() } }; + Matrix combined = ccm * awbDiag; + + float r = combined[0][0] * rawPixel.r() + combined[0][1] * rawPixel.g() + combined[0][2] * rawPixel.b(); + float g = combined[1][0] * rawPixel.r() + combined[1][1] * rawPixel.g() + combined[1][2] * rawPixel.b(); + float b = combined[2][0] * rawPixel.r() + combined[2][1] * rawPixel.g() + combined[2][2] * rawPixel.b(); + + return RGB({ r, g, b }); +} + +/* + * Apply AWB gains with clamp to [0,1], then CCM (new correct approach). + * Returns the output RGB for a given raw input pixel. + */ +static RGB applyClampedAwbThenCcm(const Matrix &ccm, + const RGB &awbGains, + const RGB &rawPixel) +{ + /* Step 1: AWB gains + clamp */ + float rin = std::clamp(rawPixel.r() * awbGains.r(), 0.0f, 1.0f); + float gin = std::clamp(rawPixel.g() * awbGains.g(), 0.0f, 1.0f); + float bin = std::clamp(rawPixel.b() * awbGains.b(), 0.0f, 1.0f); + + /* Step 2: CCM multiply */ + float r = ccm[0][0] * rin + ccm[0][1] * gin + ccm[0][2] * bin; + float g = ccm[1][0] * rin + ccm[1][1] * gin + ccm[1][2] * bin; + float b = ccm[2][0] * rin + ccm[2][1] * gin + ccm[2][2] * bin; + + return RGB({ r, g, b }); +} + +/* + * Returns true if the pixel is "magenta" -- R and B significantly higher + * than G. + */ +static bool isMagenta(const RGB &p) +{ + return (p.r() - p.g() > 0.2f) && (p.b() - p.g() > 0.2f); +} + +/* + * Returns true if the pixel is approximately neutral (R ~= G ~= B). + */ +static bool isNeutral(const RGB &p, float tol = 0.05f) +{ + return std::abs(p.r() - p.g()) < tol && + std::abs(p.b() - p.g()) < tol; +} + +class AwbCcmPipelineTest : public Test +{ +protected: + int run() + { + /* + * OV01A10 CCM at 4000K (from AIQB calibration binary). + * Row sums are exactly 1.0. + */ + const Matrix ccm4000{ { + 1.5414f, -0.4024f, -0.1390f, + -0.3304f, 1.6352f, -0.3048f, + -0.1237f, -0.6699f, 1.7936f, + } }; + + /* + * Typical AWB gains at ~4500K for OV01A10. + * R and B gains > 1 because the sensor is more sensitive to G. + */ + const RGB awbGains({ 1.47f, 1.0f, 1.72f }); + + /* --- Test 1: old approach produces magenta on bright pixels --- */ + { + /* + * A fully saturated neutral raw pixel [1,1,1]. + * With the old baked-matrix approach the combined row + * sums are [1.62, 0.63, 2.23], so R and B clip hard + * while G stays low -> magenta. + */ + RGB brightNeutral({ 1.0f, 1.0f, 1.0f }); + RGB out = applyBakedMatrix(ccm4000, awbGains, brightNeutral); + + /* + * We expect R > 1, G < 1, B > 1 (magenta before clamp). + * After hard clamp to [0,1]: R=1, G<1, B=1 -> magenta. + */ + ASSERT_TRUE(out.r() > 1.0f); + ASSERT_TRUE(out.b() > 1.0f); + ASSERT_TRUE(out.g() < 1.0f); + + RGB clamped({ std::clamp(out.r(), 0.0f, 1.0f), + std::clamp(out.g(), 0.0f, 1.0f), + std::clamp(out.b(), 0.0f, 1.0f) }); + ASSERT_TRUE(isMagenta(clamped)); + } + + /* --- Test 2: new approach produces neutral on bright pixels --- */ + { + RGB brightNeutral({ 1.0f, 1.0f, 1.0f }); + RGB out = applyClampedAwbThenCcm(ccm4000, awbGains, brightNeutral); + + /* + * After clamping AWB output to [0,1], the CCM receives + * [1,1,1] (all channels hit the ceiling equally for a + * neutral pixel). CCM row sums = 1.0, so output = [1,1,1]. + */ + ASSERT_NEAR(out.r(), 1.0f, kTol); + ASSERT_NEAR(out.g(), 1.0f, kTol); + ASSERT_NEAR(out.b(), 1.0f, kTol); + ASSERT_TRUE(isNeutral(out)); + } + + /* --- Test 3: mid-tones are unaffected (no colour shift) --- */ + { + /* + * A neutral grey at a level where AWB gains don't cause + * clipping: raw value scaled so that after AWB gains the + * result is [0.5, 0.5, 0.5]. With awbGains=[1.47,1.0,1.72] + * the raw pixel must be [0.34, 0.5, 0.29]. + * Both approaches should give identical results here. + */ + float level = 0.5f; + RGB midGrey({ level / awbGains.r(), + level / awbGains.g(), + level / awbGains.b() }); + + RGB outBaked = applyBakedMatrix(ccm4000, awbGains, midGrey); + RGB outClamped = applyClampedAwbThenCcm(ccm4000, awbGains, midGrey); + + /* Both approaches identical when no clipping occurs. */ + ASSERT_NEAR(outBaked.r(), outClamped.r(), kTol); + ASSERT_NEAR(outBaked.g(), outClamped.g(), kTol); + ASSERT_NEAR(outBaked.b(), outClamped.b(), kTol); + + /* Output should be neutral (CCM row sums = 1, input is balanced). */ + ASSERT_TRUE(isNeutral(outClamped)); + } + + /* --- Test 4: fix works across multiple CCMs --- */ + { + /* OV01A10 CCM at 6500K */ + const Matrix ccm6500{ { + 1.8163f, -0.7062f, -0.1100f, + -0.1640f, 1.5736f, -0.4096f, + -0.0084f, -0.8294f, 1.8378f, + } }; + + /* Cooler AWB gains at 6500K (less R boost needed) */ + const RGB awbGains6500({ 1.2f, 1.0f, 1.5f }); + + RGB brightNeutral({ 1.0f, 1.0f, 1.0f }); + + /* Old approach: still magenta */ + RGB outBaked = applyBakedMatrix(ccm6500, awbGains6500, brightNeutral); + RGB clampedBaked({ std::clamp(outBaked.r(), 0.0f, 1.0f), + std::clamp(outBaked.g(), 0.0f, 1.0f), + std::clamp(outBaked.b(), 0.0f, 1.0f) }); + ASSERT_TRUE(isMagenta(clampedBaked)); + + /* New approach: neutral white */ + RGB outClamped = applyClampedAwbThenCcm(ccm6500, awbGains6500, brightNeutral); + ASSERT_TRUE(isNeutral(outClamped)); + } + + /* --- Test 5: black pixel is unaffected --- */ + { + RGB black({ 0.0f, 0.0f, 0.0f }); + RGB out = applyClampedAwbThenCcm(ccm4000, awbGains, black); + ASSERT_NEAR(out.r(), 0.0f, kTol); + ASSERT_NEAR(out.g(), 0.0f, kTol); + ASSERT_NEAR(out.b(), 0.0f, kTol); + } + + return TestPass; + } +}; + +TEST_REGISTER(AwbCcmPipelineTest) diff --git a/test/ipa/libipa/meson.build b/test/ipa/libipa/meson.build index 8c36800c..3ce7b3c4 100644 --- a/test/ipa/libipa/meson.build +++ b/test/ipa/libipa/meson.build @@ -1,6 +1,7 @@ # SPDX-License-Identifier: CC0-1.0 libipa_test = [ + {'name': 'awb_ccm_pipeline', 'sources': ['awb_ccm_pipeline.cpp']}, {'name': 'ccm', 'sources': ['ccm.cpp']}, {'name': 'fixedpoint', 'sources': ['fixedpoint.cpp']}, {'name': 'histogram', 'sources': ['histogram.cpp']}, From 5e57e7923ec5a142a7e5e84e6e4620dd8c4892be Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Fri, 8 May 2026 19:34:48 +0200 Subject: [PATCH 13/16] ipa: simple: agc: Add percentile-based highlight metering Add a meteringPercentile tuning parameter that switches the AGC from mean-sample-value (MSV) metering to percentile metering. Instead of targeting the mean histogram bin, the AGC finds the Nth percentile pixel in the full 64-bin luminance histogram and targets it at exposureTarget. With meteringPercentile=1.0 (default) the original MSV behaviour is preserved. With values below 1.0, the top (1-N)% of pixels are allowed to clip while the subject is correctly exposed -- preventing bright windows or lights from pulling down the exposure of the main subject. Set exposureTarget near the top of the 5-bin scale (4.5) when using percentile metering so the Nth percentile pixel lands near full brightness. The OV01A10 tuning uses meteringPercentile=0.97 which allows the brightest 3% of pixels to clip. --- src/ipa/simple/algorithms/agc.cpp | 52 ++++++++++++++++++++++++++++++- src/ipa/simple/algorithms/agc.h | 1 + src/ipa/simple/data/ov01a10.yaml | 10 +++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/ipa/simple/algorithms/agc.cpp b/src/ipa/simple/algorithms/agc.cpp index 66618d0f..27687690 100644 --- a/src/ipa/simple/algorithms/agc.cpp +++ b/src/ipa/simple/algorithms/agc.cpp @@ -54,6 +54,16 @@ static constexpr float kHysteresisDefault = 0.2; */ static constexpr float kProportionalGainDefault = 0.04; +/* + * Percentile of the luminance histogram used for metering. + * 0.5 = median (expose for the middle pixel), 1.0 = brightest pixel. + * Values below 1.0 protect highlights: e.g. 0.9 means expose so that + * 90% of pixels are below the target bin, preventing bright areas from + * blowing out at the expense of slightly darker midtones. + * Overridable via YAML meteringPercentile. + */ +static constexpr float kMeteringPercentileDefault = 1.0; + Agc::Agc() { } @@ -66,6 +76,8 @@ int Agc::init([[maybe_unused]] IPAContext &context, const ValueNode &tuningData) .value_or(kHysteresisDefault); proportionalGain_ = tuningData["proportionalGain"].get() .value_or(kProportionalGainDefault); + meteringPercentile_ = tuningData["meteringPercentile"].get() + .value_or(kMeteringPercentileDefault); return 0; } @@ -191,7 +203,45 @@ void Agc::process(IPAContext &context, num += exposureBins[i] * (i + 1); } - float exposureMSV = (denom == 0 ? 0 : static_cast(num) / denom); + float exposureMSV; + if (meteringPercentile_ >= 1.0f) { + /* Default: mean sample value across all bins. */ + exposureMSV = (denom == 0 ? 0 : static_cast(num) / denom); + } else { + /* + * Percentile metering: find the histogram bin (in the full + * 64-bin space) at which the cumulative pixel count reaches + * meteringPercentile_ of total pixels. + * + * We then express the result directly on the kExposureBinsCount + * MSV scale so it can be compared to exposureTarget. The + * exposureTarget should be set close to kExposureBinsCount + * (e.g. 4.5 out of 5) so that the percentile pixel lands near + * the top of the range -- protecting highlights while keeping + * the subject bright. + * + * Example: meteringPercentile_=0.90, exposureTarget=4.5 means + * "expose so the 90th percentile pixel is at 90% of full scale". + */ + unsigned int totalPixels = denom; + unsigned int threshold = static_cast(totalPixels * meteringPercentile_); + unsigned int cumulative = 0; + unsigned int percentileHistBin = histogramSize - 1; + for (unsigned int i = 0; i < histogramSize; i++) { + cumulative += histogram[blackLevelHistIdx + i]; + if (cumulative >= threshold) { + percentileHistBin = i; + break; + } + } + /* Map from [0, histogramSize) to [1, kExposureBinsCount]. */ + exposureMSV = 1.0f + static_cast(percentileHistBin) * + (kExposureBinsCount - 1) / (histogramSize - 1); + LOG(IPASoftExposure, Debug) + << "percentile " << meteringPercentile_ + << " -> histBin " << percentileHistBin + << " (MSV=" << exposureMSV << ")"; + } updateExposure(context, frameContext, exposureMSV); } diff --git a/src/ipa/simple/algorithms/agc.h b/src/ipa/simple/algorithms/agc.h index b8ed542b..9de8492c 100644 --- a/src/ipa/simple/algorithms/agc.h +++ b/src/ipa/simple/algorithms/agc.h @@ -34,6 +34,7 @@ class Agc : public Algorithm float exposureTarget_; float hysteresis_; float proportionalGain_; + float meteringPercentile_; }; } /* namespace ipa::soft::algorithms */ diff --git a/src/ipa/simple/data/ov01a10.yaml b/src/ipa/simple/data/ov01a10.yaml index 4b208879..edc74f8f 100644 --- a/src/ipa/simple/data/ov01a10.yaml +++ b/src/ipa/simple/data/ov01a10.yaml @@ -60,7 +60,15 @@ algorithms: contrast: 1.0 saturation: 1.0 - Agc: - exposureTarget: 2.0 + # exposureTarget is on a 1-5 scale (kExposureBinsCount). + # With percentile metering, set this near the top (4.5) so the + # Nth percentile pixel lands near full brightness -- the subject + # stays well exposed while only the top (1-N)% of pixels clip. + exposureTarget: 4.5 hysteresis: 0.3 proportionalGain: 0.12 + # Expose for the 90th percentile pixel: the brightest 10% of + # pixels (window, specular highlights) are allowed to clip while + # the subject (face) is correctly exposed. + meteringPercentile: 0.97 ... From 8a2a3b72ac0299af25c892cc506d3fb498a3aafe Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Thu, 21 May 2026 13:59:55 +0200 Subject: [PATCH 14/16] ipa: simple: Handle FrameDurationLimits to cap AGC exposure When an application requests a specific frame rate via FrameDurationLimits (e.g. Firefox/WebRTC at 30fps), the sensor silently clips exposure to fit within the frame period. Without the IPA knowing about this cap, the AGC would see the scene as too bright (since exposure is capped by the sensor but the IPA thinks there is still headroom) and compensate by increasing analogue gain, resulting in a severely overexposed image. Fix this by: - Advertising FrameDurationLimits in the IPA controls map (with the sensor's absolute exposure range as min/max, and max as the default so that unconstrained clients like qcam get full exposure headroom) - Storing absolute frameDurationMin/Max in IPASessionConfiguration - Handling FrameDurationLimits in queueRequest() to dynamically update exposureMax based on the requested max frame duration The AGC already respects exposureMax, so no changes to agc.cpp are needed. --- src/ipa/simple/ipa_context.h | 1 + src/ipa/simple/soft_simple.cpp | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/ipa/simple/ipa_context.h b/src/ipa/simple/ipa_context.h index cd9a8eda..3614c7b0 100644 --- a/src/ipa/simple/ipa_context.h +++ b/src/ipa/simple/ipa_context.h @@ -29,6 +29,7 @@ struct IPASessionConfiguration { int32_t exposureMin, exposureMax; double againMin, againMax, again10, againMinStep; utils::Duration lineDuration; + utils::Duration frameDurationMin, frameDurationMax; } agc; struct { std::optional level; diff --git a/src/ipa/simple/soft_simple.cpp b/src/ipa/simple/soft_simple.cpp index 629e1a32..5819e31f 100644 --- a/src/ipa/simple/soft_simple.cpp +++ b/src/ipa/simple/soft_simple.cpp @@ -177,6 +177,26 @@ int IPASoftSimple::init(const IPASettings &settings, stats_ = static_cast(mem); } + /* + * Advertise FrameDurationLimits based on the sensor's absolute exposure + * range. The actual limits will be constrained at configure() time and + * updated dynamically when the application sets FrameDurationLimits via + * queueRequest(). + */ + const ControlInfo &exposureInfo = + sensorControls.find(V4L2_CID_EXPOSURE)->second; + utils::Duration lineDuration = + utils::Duration(sensorInfo.minLineLength * 1.0s / sensorInfo.pixelRate); + int64_t minFrameDurationUs = + static_cast(utils::Duration(exposureInfo.min().get() * + lineDuration).get()); + int64_t maxFrameDurationUs = + static_cast(utils::Duration(exposureInfo.max().get() * + lineDuration).get()); + context_.ctrlMap[&controls::FrameDurationLimits] = + ControlInfo(minFrameDurationUs, maxFrameDurationUs, + maxFrameDurationUs); + ControlInfoMap::Map ctrlMap = context_.ctrlMap; *ipaControls = ControlInfoMap(std::move(ctrlMap), controls::controls); @@ -215,6 +235,14 @@ int IPASoftSimple::configure(const IPAConfigInfo &configInfo) context_.sensorInfo.minLineLength * 1.0s / context_.sensorInfo.pixelRate; context_.configuration.agc.exposureMin = exposureInfo.min().get(); context_.configuration.agc.exposureMax = exposureInfo.max().get(); + + /* Compute absolute frame duration limits from the sensor exposure range. */ + context_.configuration.agc.frameDurationMin = + context_.configuration.agc.exposureMin * + context_.configuration.agc.lineDuration; + context_.configuration.agc.frameDurationMax = + context_.configuration.agc.exposureMax * + context_.configuration.agc.lineDuration; if (!context_.configuration.agc.exposureMin) { LOG(IPASoft, Warning) << "Minimum exposure is zero, that can't be linear"; context_.configuration.agc.exposureMin = 1; @@ -279,6 +307,46 @@ void IPASoftSimple::queueRequest(const uint32_t frame, const ControlList &contro { IPAFrameContext &frameContext = context_.frameContexts.alloc(frame); + /* + * Handle FrameDurationLimits: translate the requested max frame duration + * into a maximum exposure time (in lines) so the AGC doesn't set an + * exposure that the sensor can't achieve at the requested frame rate. + * Without this, the sensor silently clips exposure to the frame period + * while the IPA keeps cranking up gain to compensate, causing overexposure. + */ + const auto &frameDurationLimits = controls.get(controls::FrameDurationLimits); + if (frameDurationLimits) { + utils::Duration maxFrameDuration = + frameDurationLimits->back() * 1.0us; + utils::Duration minFrameDuration = + frameDurationLimits->front() * 1.0us; + + /* Clamp to the sensor's absolute limits. */ + maxFrameDuration = std::clamp(maxFrameDuration, + context_.configuration.agc.frameDurationMin, + context_.configuration.agc.frameDurationMax); + minFrameDuration = std::clamp(minFrameDuration, + context_.configuration.agc.frameDurationMin, + maxFrameDuration); + + /* Convert max frame duration to max exposure in lines. */ + int32_t newExposureMax = static_cast( + maxFrameDuration / context_.configuration.agc.lineDuration); + newExposureMax = std::clamp(newExposureMax, + context_.configuration.agc.exposureMin, + static_cast( + context_.configuration.agc.frameDurationMax / + context_.configuration.agc.lineDuration)); + + context_.configuration.agc.exposureMax = newExposureMax; + + LOG(IPASoft, Debug) + << "FrameDurationLimits: [" + << minFrameDuration.get() << ", " + << maxFrameDuration.get() << "] ms" + << " -> exposureMax=" << newExposureMax; + } + for (const auto &algo : algorithms()) algo->queueRequest(context_, frame, frameContext, controls); } From ad2a5375479506cdd3222f616b113b12839985dd Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Mon, 25 May 2026 06:11:37 +0200 Subject: [PATCH 15/16] ipa: simple: agc: Replace proportional steps with damped approach curve The previous AGC applied a fixed proportional step each frame (factor = 1 + error * proportionalGain), which produced discrete visible jumps in exposure, especially when the scene changed rapidly. Replace this with a damped exponential approach: correctionFull = exposureTarget / exposureMSV factor = 1 + damping * (correctionFull - 1) Each frame moves a fraction (damping) of the remaining distance to the target, producing a smooth asymptotic curve rather than discrete steps. At the default damping of 0.25, ~94% of the correction is applied within 10 frames. Also add an asymmetric EMA filter on the metered MSV before computing the correction: - msvFilterAlphaUp (default 0.2): slow response when MSV rises (scene gets darker), avoiding pumping exposure up on transient dark frames. - msvFilterAlphaDown (default 0.6): fast response when MSV falls (scene gets brighter), preventing overexposure on sudden bright scenes. Both damping and the EMA alphas are tunable via YAML. --- src/ipa/simple/algorithms/agc.cpp | 91 +++++++++++++++++++++++++++++-- src/ipa/simple/algorithms/agc.h | 4 ++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/ipa/simple/algorithms/agc.cpp b/src/ipa/simple/algorithms/agc.cpp index 27687690..91c9dbe5 100644 --- a/src/ipa/simple/algorithms/agc.cpp +++ b/src/ipa/simple/algorithms/agc.cpp @@ -39,6 +39,24 @@ static constexpr float kExposureTargetDefault = kExposureBinsCount / 2.0; */ static constexpr float kHysteresisDefault = 0.2; +/* + * Damping coefficient for the exposure approach curve. + * + * On each frame we compute the *full* correction factor needed to reach the + * target (correctionFull = exposureTarget / exposureMSV) and then move + * a fraction of that distance: + * + * exposureNew = exposureCurrent * (1 - damping + damping * correctionFull) + * + * Equivalently in log/stops space this is an exponential approach. A damping + * of 1.0 jumps directly to the target (no smoothing); 0.0 never moves. + * The default 0.25 reaches ~94% of the target in 10 frames -- smooth without + * being sluggish. + * + * Overridable via YAML damping. + */ +static constexpr float kDampingDefault = 0.25f; + /* * Proportional gain for exposure/gain adjustment. Maps the MSV error to a * multiplicative correction factor: @@ -64,7 +82,21 @@ static constexpr float kProportionalGainDefault = 0.04; */ static constexpr float kMeteringPercentileDefault = 1.0; +/* + * Asymmetric EMA alpha for the metered MSV. + * Two separate smoothing constants: + * - alphaUp: applied when MSV rises (scene gets darker -> increase exposure). + * Slower response avoids pumping up exposure on transient dark frames. + * - alphaDown: applied when MSV falls (scene gets brighter -> decrease exposure). + * Faster response prevents overexposure when a bright scene is encountered. + * Range: (0, 1]. 1.0 = no filtering (instant response). + * Overridable via YAML msvFilterAlphaUp / msvFilterAlphaDown. + */ +static constexpr float kMsvFilterAlphaUpDefault = 0.2f; +static constexpr float kMsvFilterAlphaDownDefault = 0.6f; + Agc::Agc() + : filteredMSV_(-1.0f) { } @@ -76,8 +108,17 @@ int Agc::init([[maybe_unused]] IPAContext &context, const ValueNode &tuningData) .value_or(kHysteresisDefault); proportionalGain_ = tuningData["proportionalGain"].get() .value_or(kProportionalGainDefault); + damping_ = std::clamp( + tuningData["damping"].get().value_or(kDampingDefault), + 0.01f, 1.0f); meteringPercentile_ = tuningData["meteringPercentile"].get() .value_or(kMeteringPercentileDefault); + msvFilterAlphaUp_ = std::clamp( + tuningData["msvFilterAlphaUp"].get().value_or(kMsvFilterAlphaUpDefault), + 0.01f, 1.0f); + msvFilterAlphaDown_ = std::clamp( + tuningData["msvFilterAlphaDown"].get().value_or(kMsvFilterAlphaDownDefault), + 0.01f, 1.0f); return 0; } @@ -93,11 +134,27 @@ void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, dou return; /* - * Compute a proportional correction factor. The sign of the error - * determines the direction: positive error means too dark (increase), - * negative means too bright (decrease). + * Compute the full correction factor needed to reach the target, + * then move only a fraction (damping_) of the way there. This produces + * a smooth exponential approach curve rather than discrete steps. + * + * correctionFull = exposureTarget / exposureMSV + * factor = 1 + damping * (correctionFull - 1) + * + * Clamp exposureMSV to a small positive number to avoid division by + * zero and runaway correction factors on near-black frames. */ - float factor = 1.0f + static_cast(error) * proportionalGain_; + const double msvClamped = std::max(exposureMSV, 0.1); + const double correctionFull = exposureTarget_ / msvClamped; + float factor = 1.0f + damping_ * static_cast(correctionFull - 1.0); + + /* + * Limit the per-frame factor to a reasonable range to prevent extreme + * jumps if the metering is briefly very wrong (e.g. occlusion, sudden + * scene change). The bounds also ensure stability of the asymptotic + * convergence. + */ + factor = std::clamp(factor, 0.5f, 2.0f); if (factor > 1.0f) { /* Scene too dark: increase exposure first, then gain. */ @@ -135,7 +192,9 @@ void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, dou LOG(IPASoftExposure, Debug) << "exposureMSV " << exposureMSV - << " error " << error << " factor " << factor + << " error " << error + << " correctionFull " << correctionFull + << " factor " << factor << " exp " << exposure << " again " << again; } @@ -242,7 +301,27 @@ void Agc::process(IPAContext &context, << " -> histBin " << percentileHistBin << " (MSV=" << exposureMSV << ")"; } - updateExposure(context, frameContext, exposureMSV); + + /* + * Apply an asymmetric EMA to the metered MSV: + * - When MSV rises (scene darker, need more exposure): smooth slowly to + * avoid pumping exposure up on transient dark frames. + * - When MSV falls (scene brighter, need less exposure): react quickly + * to prevent overexposure. + * Seed the filter on the first valid frame. + */ + if (filteredMSV_ < 0.0f) { + filteredMSV_ = exposureMSV; + } else { + float alpha = (exposureMSV > filteredMSV_) ? msvFilterAlphaUp_ + : msvFilterAlphaDown_; + filteredMSV_ = alpha * exposureMSV + (1.0f - alpha) * filteredMSV_; + } + + LOG(IPASoftExposure, Debug) + << "raw MSV=" << exposureMSV << " filtered=" << filteredMSV_; + + updateExposure(context, frameContext, filteredMSV_); } REGISTER_IPA_ALGORITHM(Agc, "Agc") diff --git a/src/ipa/simple/algorithms/agc.h b/src/ipa/simple/algorithms/agc.h index 9de8492c..c11ac116 100644 --- a/src/ipa/simple/algorithms/agc.h +++ b/src/ipa/simple/algorithms/agc.h @@ -34,7 +34,11 @@ class Agc : public Algorithm float exposureTarget_; float hysteresis_; float proportionalGain_; + float damping_; float meteringPercentile_; + float msvFilterAlphaUp_; + float msvFilterAlphaDown_; + float filteredMSV_; }; } /* namespace ipa::soft::algorithms */ From a0ba9bddb24d26762daf9158202a3b591033ea81 Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Mon, 25 May 2026 06:11:48 +0200 Subject: [PATCH 16/16] ipa: simple: data: Update AGC tuning and document new parameters ov01a10.yaml: - Switch to damped approach AGC (remove proportionalGain, add damping: 0.2) - Restore meteringPercentile: 0.98 and exposureTarget: 4.5 -- empirically correct for scenes with bright backgrounds (windows, ceiling lights). The top 2% of pixels are allowed to clip; the subject is well exposed. - Lower hysteresis to 0.15 (damped curve naturally settles without needing a large dead zone) - Add msvFilterAlphaUp/Down for smooth, asymmetric metering response uncalibrated.yaml: - Document all new AGC parameters: damping, meteringPercentile, msvFilterAlphaUp, msvFilterAlphaDown - Expand exposureTarget documentation to explain the percentile metering interaction --- src/ipa/simple/data/ov01a10.yaml | 28 ++++++++++++++------- src/ipa/simple/data/uncalibrated.yaml | 35 +++++++++++++++++++++------ 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/ipa/simple/data/ov01a10.yaml b/src/ipa/simple/data/ov01a10.yaml index edc74f8f..9b2f6c4d 100644 --- a/src/ipa/simple/data/ov01a10.yaml +++ b/src/ipa/simple/data/ov01a10.yaml @@ -61,14 +61,24 @@ algorithms: saturation: 1.0 - Agc: # exposureTarget is on a 1-5 scale (kExposureBinsCount). - # With percentile metering, set this near the top (4.5) so the - # Nth percentile pixel lands near full brightness -- the subject - # stays well exposed while only the top (1-N)% of pixels clip. + # With high meteringPercentile (0.98) and target near the top (4.5), + # the AGC exposes so the 98th percentile pixel lands at ~88% of full + # scale -- the brightest 2% (windows, specular highlights) clip, + # everything else (face, normal scene) is properly exposed. exposureTarget: 4.5 - hysteresis: 0.3 - proportionalGain: 0.12 - # Expose for the 90th percentile pixel: the brightest 10% of - # pixels (window, specular highlights) are allowed to clip while - # the subject (face) is correctly exposed. - meteringPercentile: 0.97 + hysteresis: 0.15 + # damping controls the smoothness of the exposure approach curve. + # Each frame moves a fraction (damping) of the way toward the target, + # producing an exponential approach. + # 0.25 = smooth (~94% in 10 frames), 0.5 = faster, 1.0 = instant. + damping: 0.2 + # Expose for the 98th percentile pixel: ignore the brightest 2% + # (windows, lights, specular highlights) so the subject is properly + # exposed even with very bright background. + meteringPercentile: 0.98 + # Asymmetric EMA smoothing on the metered MSV before computing + # the correction. Slow up (avoid pumping on transient dark frames), + # fast down (react quickly to avoid overexposure). + msvFilterAlphaUp: 0.25 + msvFilterAlphaDown: 0.6 ... diff --git a/src/ipa/simple/data/uncalibrated.yaml b/src/ipa/simple/data/uncalibrated.yaml index 7391c36e..8a309694 100644 --- a/src/ipa/simple/data/uncalibrated.yaml +++ b/src/ipa/simple/data/uncalibrated.yaml @@ -34,14 +34,35 @@ algorithms: # Only active when CCM is enabled. - Adjust: - # --- Auto Gain/Exposure Control (proportional) --- - # exposureTarget: Target MSV (mean sample value) in histogram bins. - # Default: 2.5 (middle of 5-bin range). - # Lower values target a darker exposure. + # --- Auto Gain/Exposure Control --- + # + # exposureTarget: Target MSV on the 1-5 histogram bin scale. + # Default: 2.5 (middle of range). With high meteringPercentile values + # (0.97+), set near the top (4.5) so the Nth percentile pixel lands + # near full brightness -- the subject stays well exposed while the + # brightest (1-N)% of pixels (windows, lights) are allowed to clip. + # # hysteresis: Deadband around target where no adjustment occurs. # Default 0.2. Larger values reduce sensitivity near target. - # proportionalGain: Step scaling factor (default 0.04). - # At max error (~2.5): factor 1.10 (~10% step). - # Near target: factor ~1.01 (~1% step). + # + # damping: Fraction of the full correction applied each frame (default 0.25). + # Produces a smooth exponential approach to the target rather than + # discrete steps. 1.0 = instant (jump to target), 0.1 = very slow. + # At 0.25, ~94% of the correction is applied within 10 frames. + # + # meteringPercentile: Histogram percentile used for metering (default 1.0). + # 1.0 = expose for the brightest pixel (mean metering). + # 0.97-0.99 = ignore the top 1-3% brightest pixels (windows, specular + # highlights) and expose for the rest -- recommended for scenes with + # bright backgrounds. Pair with exposureTarget near 4.5. + # 0.5 = median metering (expose for the middle pixel). + # + # msvFilterAlphaUp: EMA smoothing when metered MSV rises (scene gets darker). + # Default 0.2. Lower = slower response, avoids pumping exposure up on + # transient dark frames (e.g. hand passing in front of camera). + # + # msvFilterAlphaDown: EMA smoothing when metered MSV falls (scene gets brighter). + # Default 0.6. Higher = faster response, prevents overexposure when a + # bright scene is encountered. - Agc: ...