======= Sample AWB implementation in C++ ======= The code below provides a sample implementation of the AWB algorithm described in [[Automatic white balance]]. It relies on a few external dependencies outlined below: * ''%%S2R::IImgSensor%%'' from the [[code:SUB2r-lib]] - a conveniency library to access image sensor's chip and control its registers * ''%%S2R::LumaCalc%%'' - a parameter that affects the choice of algorithm for RGB<=>YUV((more precisely - a Y'UV)) conversions * ''%%S2R::ImgStats%%'' - image's statistical data, mainly histograms. This structure is not very well documented in here but should be fairly obvious from its usage * ''%%S2R::enum HIST_TYPE%%'' - a helper enum used as an index into histogram data arrays The code below conforms to C++17 standard and it is recommended to use the C++17 toolchain as this is the version the [[code::SUB2r-lib]] is targetting. ====== Helper one-liners ====== A few short functions for code readability, put into a local anonymous namespace (effectively making these function ''static'' and making sure we don't pollute/collide with the global namespace). Side note: I'm fully aware of the ''std::clamp()'' template yet using my own ''_box()'' because of type conversions I need. In one case I'm limiting a ''double'' into an ''int'' range, returning an ''int'' result. In the second case the range is specified by ''[[#PerPart]]'' values that would otherwise need to be explicitly cast to ''double'' which looked too ugly, TBH. namespace{ // "box" a given value _x between two bounding limits _l and _r, both inclusive constexpr int _box(long double _x, int _l, int _r) { return static_cast(max(_l, min(_r, round(_x)))); } constexpr auto _box(long double _x, long double _l, long double _r) { return max(_l, min(_r, _x)); } // sum-up the values of the _count right-most elements of a histogram (assumed to be of 256 elements) auto _histRightNSum(const long * _el, int _count) { return std::accumulate(_el + 256 - _count, _el + 256, 0); } // implementation not shown - basically just a matrix multiplication struct RGB_DBL{ double r, g, b; }; constexpr RGB_DBL _RGB(LumaCalc _algo, double _y, double _u, double _v); } ====== PerPart ====== This helper class is to avoid stupid arithmetic errors when mixing various ratios, like absolute ratios, percentages (%), per-milles(‰), parts-per-million (ppm), etc. It also helps with gracefully handling division by zero (which results in defaulting the result of such ratio to ''0''). In addition a few helpful user-defined literals are provided for better code readability when it comes to constants. This is a super-minimalistic class that doesn't pretend to be production-quality. namespace{ struct PerPart{ PerPart(long double _val = 0.f) : m_val(_val) {} // 1.0f == 100_pcnt PerPart(unsigned long long int _val, unsigned long long int _div) : PerPart(_div == 0 ? 0.f : static_cast(_val) / _div) {} operator long double() const { return m_val; } int pcnt() const { return static_cast(round(m_val * 100)); } private: long double m_val = 0.f; }; PerPart operator"" _pcnt(unsigned long long int _val){return PerPart(_val, 100);} PerPart operator"" _pmille(unsigned long long int _val){return PerPart(_val, 1000);} PerPart operator"" _ppm(unsigned long long int _val){return PerPart(_val, 1000 * 1000);} } ====== AWB ====== Main auto-white balance implementation class. The unnamed internal ''struct m_vars'' is here to provide an overview of the notable results of calculation and a convenient way to display debug info in the UI part of the program (which is more or less irrelevant for the on-device implementation and therefore is kept to the minimum in this sample implementation). Primarily the class is used in 3 steps: - Instantiate an object - Call its functor - (Optionally) display the results in UI - (Optionally) adjust the "saturation threshold", based on the recommendation from the analysis The "saturation threshold" affects data collection in such a way that only those pixels for which the statement ''(|U| + |V|) / Y < threshold'' holds true, i.e. only pixels that are "grey enough" are considered for the data set and those which are way too saturated are ignored. ===== Declaration ===== struct AWB{ enum class Action{noop, adjTolerance, correctGains, resetRed}; const PerPart m_satTH; // threshold, max saturation of pixels to consider them "grey enough", [0..1] const long long m_pxUsed; // number of pixels in the stats AWB(IImgSensor * _ov, LumaCalc _alg, const ImgStats & _stats, const PerPart & _satTH); auto operator()(bool _performCorrection) // analyze and, if asked and is necessary, adjust sensor's config { _analyze(); if(_performCorrection){ switch(m_vars.op){ case Action::correctGains: _correctGains(); break; case Action::resetRed: _resetRed(); break; } } return m_vars; } const auto & details() const {return m_vars;} // diagnostics to display private: void _analyze(); // analyze data bool _isRedOE() const; // true if there's way too much red void _resetRed(); // bring Red channel in line with Green if the picture is way too red void _correctGains(); // perform the gains' adjustment to normalize colors on the output void _adjTol(long double _adj); // adjust tolerance level to make m_rezA.pxCount into m_sweetZone range private: using Val = IImgSensor::Value; // avoid typing out the full type qualifier IImgSensor & m_ov; // OmniVision sensor interface const struct{ PerPart low, high; } m_tgtPxUsed = {16_pcnt, 35_pcnt} , m_tgtSatTH = {1_pcnt, 60_pcnt}; struct{ PerPart pxPcnt = 0_pcnt; // pixels contributing to statistics double avgY = 0; double avgU = 0; double avgV = 0; double avgR = 0; double avgG = 0; double avgB = 0; int oeR = 0; // overxposed Red channel pixels int oeG = 0; // overexposed Green channel pixels PerPart suggTolerance = 0; // suggested tolerance, based on m_tolerance Action op = Action::noop; // suggested action to perform } m_vars; // useful results of calculations }; ===== Constructor AWB::AWB ===== A bulk of (quite simplistic) calculations is performed with data in ''_stats'' and the results are stored in the ''m_vars'' struct: AWB::AWB(IImgSensor * _ov, LumaCalc _alg, const ImgStats & _stats, const PerPart & _satTH) : m_ov(*_ov) , m_pxUsed(_stats.awb.pxUsed) , m_satTH(_satTH) { m_vars.pxPcnt = PerPart(m_pxUsed, _stats.awb.pxTotal); m_vars.oeR = _histRightNSum(_stats.hist[HIST_R], 25); m_vars.oeG = _histRightNSum(_stats.hist[HIST_G], 25); if(m_pxUsed > 0){ m_vars.avgY = 1. * _stats.awb.y / m_pxUsed; m_vars.avgU = 1. * _stats.awb.u / m_pxUsed; m_vars.avgV = 1. * _stats.awb.v / m_pxUsed; // _alg is either BT.601 or BT.709 for RGB<->Y'UV, must match what we used to gather stats const auto rgb = _RGB(_alg, m_vars.avgY, m_vars.avgU, m_vars.avgV); m_vars.avgR = rgb.r; m_vars.avgG = rgb.g; m_vars.avgB = rgb.b; } } ===== AWB::_analyze() ===== Perform the analysis of available data and come up with a recommendation on how to adjust the sensor, if necessary. void AWB::_analyze() { m_vars.op = Action::correctGains; if(_isRedOE()){ m_vars.op = Action::resetRed; }else if(m_vars.pxPcnt < m_tgtPxUsed.low && m_satTH < m_tgtSatTH.high){ m_vars.op = Action::adjTolerance; _adjTol(m_tgtPxUsed.low - m_vars.pxPcnt + 1_pcnt); }else if(m_vars.pxPcnt > m_tgtPxUsed.high && m_satTH > m_tgtSatTH.low){ m_vars.op = Action::adjTolerance; _adjTol(m_tgtPxUsed.high - m_vars.pxPcnt - 1_pcnt); } } ===== AWB::_isRedOE() ===== Figure out if the red channel is way hot. // special conditions: way too little light and way too much green that "looks like grey" due // to red channel over-exposure caused by red gain, which is noticeable by the huge spike of // overexposed pixels in the red channel's histogram // A more generic definition of this special condition is: too much red overexposure relative to // green overexposure relative to overall brightness of the picture. The brighter the image the // more overexposure we can tolerate, but to a certain point bool AWB::_isRedOE() const { if(m_vars.oeR < m_pxUsed * 0.15 && m_vars.oeG < m_pxUsed * 0.01){ return false; } if(m_vars.avgY < 30){ return m_vars.oeR > m_vars.oeG; }else if(m_vars.avgY < 60){ return m_vars.oeR > m_vars.oeG * m_vars.avgY * 3; }else if(m_vars.avgY < 85){ return m_vars.oeR > m_vars.oeG * m_vars.avgY; }else{ return m_vars.oeR > m_vars.oeG * 8; } } ===== AWB::_resetRed() ===== If the red channel is way hot what we do is bring it almost half-way back to where the green gain is (''62.5%'' to be precise, but that number is just a convenient "slightly bigger than half" and easy to express as a simple fraction). void AWB::_resetRed() { const int gainG = m_ov[Val::gain_g]; const int gainR = m_ov[Val::gain_r]; if(gainR > gainG){ const auto diff = (gainG - gainR) * 5 / 8; // 62.5% closer to green gain value m_ov[Val::gain_r] = gainR + diff; } } ===== AWB::_correctGains() ===== Once we've determined that we should correct the gains this function is called to do just that. Both Red and Blue channels are corrected together, which may produce an oscillation effect and if that is observed when implemented in the hardware we should change this to only correct one channel or the other at a time, but not both. The channel's Gain correction is performed if it's corresponding chroma component's "global average" value (''U'' for Blue, ''V'' for Red) is more than ''precision'' further from ''0''. void AWB::_correctGains() { assert(m_vars.op == Action::correctGains); const auto limit = m_ov.getLimit(Val::gain_g); // assume the same limit for all 3 channels const double precision = 0.01; if(abs(m_vars.avgU) > precision){ //// blue shift could just be caused by current post-processing - then this should be ignored //const double blueAdj = 0.98; //m_vars.avgB /= blueAdj; const auto gb = m_vars.avgB == 0 ? 0 : m_vars.avgG / m_vars.avgB; const int gainB = _box(m_ov[Val::gain_b] * gb, 10, limit); m_ov[Val::gain_b] = gainB; } if(abs(m_vars.avgV) > precision){ const auto gr = m_vars.avgR == 0 ? 0 : m_vars.avgG / m_vars.avgR; const int gainR = _box(m_ov[Val::gain_r] * gr, 10, limit); m_ov[Val::gain_r] = gainR; } } ===== AWB::_adjTol() ===== In cases when our stats collection yielded too little or too much data we need to adjust the "saturation tolerance" accordingly and try to bring the amount of collected data into a reasonable range. Experimentally we've determined that such range is [''16''..''35'']% which we try to reach by using a 'saturation tolerance" in [''1''..''60'']% range. void AWB::_adjTol(long double _adj) { auto diff = _adj * m_satTH; // adjust proportionally to current Saturation tolerance if(diff < 0){ diff = min(-1_pcnt, diff); }else{ diff = max(+1_pcnt, diff); } m_vars.suggTolerance = _box(m_satTH + diff, m_tgtSatTH.low, m_tgtSatTH.high); } ====== Sample usage ====== This is a copy-paste of the code used in [[manual:Aria]] utility for the POC. This code has minimal comments but should be fairly easy to follow: void ImgAnalyzerDlg::_awbInfo() { static long long counter = 0; // skip a frame to catch up to the image in camera if(++counter % 2 == 0){ return; } if(((CButton *)(GetDlgItem(IDC_CHECK_ISA_AWB)))->GetCheck() != BST_CHECKED){ return; } const auto algo = m_ComboLumaCalc.GetCurSel() == 0 ? LumaCalc::BT601 : LumaCalc::BT709; const auto currTolerance = PerPart(GetDlgItemInt(IDC_EDIT_ISA_AWB_TOLERANCE, NULL, FALSE), 100); const auto & stats = m_analyzer.getStats(); AWB awb{m_fx3.sensor(), algo, stats, currTolerance}; const bool performCorrection = ((CButton *)(GetDlgItem(IDC_CHECK_ISA_AWB_CORRECT)))->GetCheck() == BST_CHECKED; const auto rez = awb(performCorrection); if(rez.op == AWB::Action::adjTolerance){ SetDlgItemInt(IDC_EDIT_ISA_AWB_TOLERANCE, rez.suggTolerance.pcnt(), FALSE); } // display the information { CString msg; msg.AppendFormat(L"Set: %llu (%.1f%%)\n", stats.awb.pxUsed, rez.pxPcnt * 100); msg.AppendFormat(L"Avg. U, V: %.3f, %.3f\n", rez.avgU, rez.avgV); msg.AppendFormat(L"Avg. Y: %.1f\n", rez.avgY); msg.AppendFormat(L"Avg. R: %.1f\n", rez.avgR); msg.AppendFormat(L"Avg. G: %.1f\n", rez.avgG); msg.AppendFormat(L"Avg. B: %.1f\n", rez.avgB); msg.AppendFormat(L"R/G: %.4f\n", rez.avgG == 0 ? 0 : rez.avgR / rez.avgG); msg.AppendFormat(L"B/G: %.4f\n", rez.avgG == 0 ? 0 : rez.avgB / rez.avgG); msg.AppendFormat(L"oeR/oeG: %.5f\n", rez.oeG == 0 ? 0 : 1. * rez.oeR / rez.oeG); SetDlgItemTextW(IDC_STATIC_ISA_AWB, msg); } } ====== Initial values ====== Following are the initial defaults for the variables that affect the process of automatic white balance adjustment: ^ Name (as seen in ''%%ImgAnalyzerDlg%%::_awbInfo()'' above) ^ Value ^ | ''currTolerance'' | ''15/100'', adjusted automagically based on last frame's stats |