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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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']},