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/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/ipa/libipa/camera_sensor_helper.cpp b/src/ipa/libipa/camera_sensor_helper.cpp index e3e3e535..f3e8d7c8 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: @@ -672,6 +684,8 @@ class CameraSensorHelperOv2740 : public CameraSensorHelper public: CameraSensorHelperOv2740() { + /* From Linux kernel driver: 0x40 at 10bits. */ + blackLevel_ = 4096; gain_ = AnalogueGainLinear{ 1, 0, 0, 128 }; } }; 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/algorithms/agc.cpp b/src/ipa/simple/algorithms/agc.cpp index 2f7e040c..91c9dbe5 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 @@ -26,63 +28,157 @@ 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; + +/* + * 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: + * + * factor = 1.0 + proportionalGain_ * error + * + * 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) + * + * Overridable via YAML proportionalGain. + */ +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; + +/* + * 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) { } +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); + 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; +} + void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, double exposureMSV) { + int32_t &exposure = frameContext.sensor.exposure; + double &again = frameContext.sensor.gain; + + double error = exposureTarget_ - exposureMSV; + + if (std::abs(error) <= hysteresis_) + return; + /* - * kExpDenominator of 10 gives ~10% increment/decrement; - * kExpDenominator of 5 - about ~20% + * 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. */ - static constexpr uint8_t kExpDenominator = 10; - static constexpr uint8_t kExpNumeratorUp = kExpDenominator + 1; - static constexpr uint8_t kExpNumeratorDown = kExpDenominator - 1; + const double msvClamped = std::max(exposureMSV, 0.1); + const double correctionFull = exposureTarget_ / msvClamped; + float factor = 1.0f + damping_ * static_cast(correctionFull - 1.0); - int32_t &exposure = frameContext.sensor.exposure; - double &again = frameContext.sensor.gain; + /* + * 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 (exposureMSV < kExposureOptimal - kExposureSatisfactory) { + 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 +192,9 @@ void Agc::updateExposure(IPAContext &context, IPAFrameContext &frameContext, dou LOG(IPASoftExposure, Debug) << "exposureMSV " << exposureMSV + << " error " << error + << " correctionFull " << correctionFull + << " factor " << factor << " exp " << exposure << " again " << again; } @@ -163,8 +262,66 @@ void Agc::process(IPAContext &context, num += exposureBins[i] * (i + 1); } - float exposureMSV = (denom == 0 ? 0 : static_cast(num) / denom); - updateExposure(context, frameContext, exposureMSV); + 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 << ")"; + } + + /* + * 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 112d9f5a..c11ac116 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,15 @@ class Agc : public Algorithm private: void updateExposure(IPAContext &context, IPAFrameContext &frameContext, double exposureMSV); + + float exposureTarget_; + float hysteresis_; + float proportionalGain_; + float damping_; + float meteringPercentile_; + float msvFilterAlphaUp_; + float msvFilterAlphaDown_; + float filteredMSV_; }; } /* namespace ipa::soft::algorithms */ diff --git a/src/ipa/simple/algorithms/awb.cpp b/src/ipa/simple/algorithms/awb.cpp index f5c88ea6..8ccd152d 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) { @@ -38,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, @@ -84,14 +100,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 */ 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..9b2f6c4d --- /dev/null +++ b/src/ipa/simple/data/ov01a10.yaml @@ -0,0 +1,84 @@ +# 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 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, + -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 is on a 1-5 scale (kExposureBinsCount). + # 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.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 fc90ca52..8a309694 100644 --- a/src/ipa/simple/data/uncalibrated.yaml +++ b/src/ipa/simple/data/uncalibrated.yaml @@ -3,17 +3,66 @@ --- 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 --- + # + # 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. + # + # 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: ... diff --git a/src/ipa/simple/ipa_context.h b/src/ipa/simple/ipa_context.h index 34f7403a..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; @@ -58,8 +59,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 +78,8 @@ struct IPAFrameContext : public FrameContext { } gains; float gamma; - std::optional contrast; - std::optional saturation; + float contrast; + float saturation; }; struct IPAContext { 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); } 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_; 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) { 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/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..3ce7b3c4 100644 --- a/test/ipa/libipa/meson.build +++ b/test/ipa/libipa/meson.build @@ -1,6 +1,8 @@ # 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']}, {'name': 'interpolator', 'sources': ['interpolator.cpp']},