{"id":24427,"url":"https://patchwork.libcamera.org/api/1.1/patches/24427/?format=json","web_url":"https://patchwork.libcamera.org/patch/24427/","project":{"id":1,"url":"https://patchwork.libcamera.org/api/1.1/projects/1/?format=json","name":"libcamera","link_name":"libcamera","list_id":"libcamera_core","list_email":"libcamera-devel@lists.libcamera.org","web_url":"","scm_url":"","webscm_url":""},"msgid":"<20250919094041.183031-19-stefan.klug@ideasonboard.com>","date":"2025-09-19T09:40:33","name":"[v5,18/19] ipa: rkisp1: Add WDR algorithm","commit_ref":"f62a1498e9c53dffb65857feb9fcd859e4354163","pull_url":null,"state":"accepted","archived":false,"hash":"171ec1e6df4a2f34f4e1eb3a3bbedbf13d2d2930","submitter":{"id":184,"url":"https://patchwork.libcamera.org/api/1.1/people/184/?format=json","name":"Stefan Klug","email":"stefan.klug@ideasonboard.com"},"delegate":null,"mbox":"https://patchwork.libcamera.org/patch/24427/mbox/","series":[{"id":5450,"url":"https://patchwork.libcamera.org/api/1.1/series/5450/?format=json","web_url":"https://patchwork.libcamera.org/project/libcamera/list/?series=5450","date":"2025-09-19T09:40:15","name":"Implement WDR algorithm","version":5,"mbox":"https://patchwork.libcamera.org/series/5450/mbox/"}],"comments":"https://patchwork.libcamera.org/api/patches/24427/comments/","check":"pending","checks":"https://patchwork.libcamera.org/api/patches/24427/checks/","tags":{},"headers":{"Return-Path":"<libcamera-devel-bounces@lists.libcamera.org>","X-Original-To":"parsemail@patchwork.libcamera.org","Delivered-To":"parsemail@patchwork.libcamera.org","Received":["from lancelot.ideasonboard.com (lancelot.ideasonboard.com\n\t[92.243.16.209])\n\tby patchwork.libcamera.org (Postfix) with ESMTPS id 722F5C3329\n\tfor <parsemail@patchwork.libcamera.org>;\n\tFri, 19 Sep 2025 09:41:51 +0000 (UTC)","from lancelot.ideasonboard.com (localhost [IPv6:::1])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTP id AB4F76B5F8;\n\tFri, 19 Sep 2025 11:41:50 +0200 (CEST)","from perceval.ideasonboard.com (perceval.ideasonboard.com\n\t[IPv6:2001:4b98:dc2:55:216:3eff:fef7:d647])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTPS id DCF4F6B5E3\n\tfor <libcamera-devel@lists.libcamera.org>;\n\tFri, 19 Sep 2025 11:41:46 +0200 (CEST)","from ideasonboard.com (unknown\n\t[IPv6:2a00:6020:448c:6c00:4d54:eab8:98ca:163b])\n\tby perceval.ideasonboard.com (Postfix) with UTF8SMTPSA id 91475D3E;\n\tFri, 19 Sep 2025 11:40:26 +0200 (CEST)"],"Authentication-Results":"lancelot.ideasonboard.com; dkim=pass (1024-bit key;\n\tunprotected) header.d=ideasonboard.com header.i=@ideasonboard.com\n\theader.b=\"mewOF5EO\"; dkim-atps=neutral","DKIM-Signature":"v=1; a=rsa-sha256; c=relaxed/simple; d=ideasonboard.com;\n\ts=mail; t=1758274826;\n\tbh=Fnt7c+vv1pKsVyayOa3W3inpEUFox78T7Zi+5vVUDys=;\n\th=From:To:Cc:Subject:Date:In-Reply-To:References:From;\n\tb=mewOF5EO2BRBGGSTYMoDbwYPLBp5wUNmjif75rA8mFFMFqalmev/tX3AEtaQ5ztzw\n\tvknjH2CFEKXV6f9DEOtYtaxfVmvUU5gXEr5HKreNGhrwIlaQPyRHTg11SBOnaacxlf\n\t9QHeUgA7RBzBe9klmN2ZiYfR3qK+NE4/oKeA65cE=","From":"Stefan Klug <stefan.klug@ideasonboard.com>","To":"libcamera-devel@lists.libcamera.org","Cc":"Stefan Klug <stefan.klug@ideasonboard.com>,\n\tPaul Elder <paul.elder@ideasonboard.com>,\n\tKieran Bingham <kieran.bingham@ideasonboard.com>","Subject":"[PATCH v5 18/19] ipa: rkisp1: Add WDR algorithm","Date":"Fri, 19 Sep 2025 11:40:33 +0200","Message-ID":"<20250919094041.183031-19-stefan.klug@ideasonboard.com>","X-Mailer":"git-send-email 2.48.1","In-Reply-To":"<20250919094041.183031-1-stefan.klug@ideasonboard.com>","References":"<20250919094041.183031-1-stefan.klug@ideasonboard.com>","MIME-Version":"1.0","Content-Transfer-Encoding":"8bit","X-BeenThere":"libcamera-devel@lists.libcamera.org","X-Mailman-Version":"2.1.29","Precedence":"list","List-Id":"<libcamera-devel.lists.libcamera.org>","List-Unsubscribe":"<https://lists.libcamera.org/options/libcamera-devel>,\n\t<mailto:libcamera-devel-request@lists.libcamera.org?subject=unsubscribe>","List-Archive":"<https://lists.libcamera.org/pipermail/libcamera-devel/>","List-Post":"<mailto:libcamera-devel@lists.libcamera.org>","List-Help":"<mailto:libcamera-devel-request@lists.libcamera.org?subject=help>","List-Subscribe":"<https://lists.libcamera.org/listinfo/libcamera-devel>,\n\t<mailto:libcamera-devel-request@lists.libcamera.org?subject=subscribe>","Errors-To":"libcamera-devel-bounces@lists.libcamera.org","Sender":"\"libcamera-devel\" <libcamera-devel-bounces@lists.libcamera.org>"},"content":"Add a WDR algorithm to do global tone mapping. Global tone mapping is\nused to increase the perceived dynamic range of an image. The typical\neffect is that in areas that are normally overexposed, additional\nstructure becomes visible.\n\nThe overall idea is that the algorithm applies an exposure value\ncorrection to underexpose the image to the point where only a small\nnumber of saturated pixels is left. This artificial underexposure is\nthen mitigated by applying a tone mapping curve.\n\nThis algorithm implements 4 tone mapping strategies:\n- Linear\n- Power\n- Exponential\n- Histogram equalization\n\nSigned-off-by: Stefan Klug <stefan.klug@ideasonboard.com>\nReviewed-by: Paul Elder <paul.elder@ideasonboard.com>\nReviewed-by: Kieran Bingham <kieran.bingham@ideasonboard.com>\n\n---\n\nChanges in v5:\n- Moved Wdr controls from draft to core\n- Fixed a few style issue in comments\n- Corrected the copyright date in header\n- Collected tag\n\nChanges in v4:\n- Properly initialize activeState.wdr.gain\n- Fixed some typos and improved wording\n\nChanges in v3:\n- Removed the need for a separate regulation loop, by not relying on an\n  added ExposureValue but by applying a constraint to AGC regulation and\ndeducing the required WDR gain from the histogram. This makes the\nstructure easier and hopefully less prone to oscillations.\n- Dropped debug metadata as this needs more discussions and is not\n  required for the algorithm to work.\n- Dropped minExposureValue as it is not used anymore.\n- Added a damping on the gain applied by the WDR curve\n- Moved strength into activeState so that it is reset on configure()\n\nChanges in v2:\n- Fixed default value for min bright pixels\n- Added check for supported params type\n- Reset PID controller\n- Various fixes from Pauls review\n---\n src/ipa/rkisp1/algorithms/agc.cpp     |   7 +-\n src/ipa/rkisp1/algorithms/meson.build |   1 +\n src/ipa/rkisp1/algorithms/wdr.cpp     | 493 ++++++++++++++++++++++++++\n src/ipa/rkisp1/algorithms/wdr.h       |  58 +++\n src/ipa/rkisp1/ipa_context.h          |  14 +\n src/ipa/rkisp1/params.cpp             |   1 +\n src/ipa/rkisp1/params.h               |   2 +\n src/libcamera/control_ids_core.yaml   |  62 ++++\n 8 files changed, 637 insertions(+), 1 deletion(-)\n create mode 100644 src/ipa/rkisp1/algorithms/wdr.cpp\n create mode 100644 src/ipa/rkisp1/algorithms/wdr.h","diff":"diff --git a/src/ipa/rkisp1/algorithms/agc.cpp b/src/ipa/rkisp1/algorithms/agc.cpp\nindex 046a1ac9caa2..f5a3c917cb69 100644\n--- a/src/ipa/rkisp1/algorithms/agc.cpp\n+++ b/src/ipa/rkisp1/algorithms/agc.cpp\n@@ -594,7 +594,12 @@ void Agc::process(IPAContext &context, [[maybe_unused]] const uint32_t frame,\n \t\tmaxAnalogueGain = frameContext.agc.gain;\n \t}\n \n-\tsetLimits(minExposureTime, maxExposureTime, minAnalogueGain, maxAnalogueGain, {});\n+\tstd::vector<AgcMeanLuminance::AgcConstraint> additionalConstraints;\n+\tif (context.activeState.wdr.mode != controls::WdrOff)\n+\t\tadditionalConstraints.push_back(context.activeState.wdr.constraint);\n+\n+\tsetLimits(minExposureTime, maxExposureTime, minAnalogueGain, maxAnalogueGain,\n+\t\t  std::move(additionalConstraints));\n \n \t/*\n \t * The Agc algorithm needs to know the effective exposure value that was\ndiff --git a/src/ipa/rkisp1/algorithms/meson.build b/src/ipa/rkisp1/algorithms/meson.build\nindex 2e42a80cf99d..d329dbfb432d 100644\n--- a/src/ipa/rkisp1/algorithms/meson.build\n+++ b/src/ipa/rkisp1/algorithms/meson.build\n@@ -14,4 +14,5 @@ rkisp1_ipa_algorithms = files([\n     'gsl.cpp',\n     'lsc.cpp',\n     'lux.cpp',\n+    'wdr.cpp',\n ])\ndiff --git a/src/ipa/rkisp1/algorithms/wdr.cpp b/src/ipa/rkisp1/algorithms/wdr.cpp\nnew file mode 100644\nindex 000000000000..45144913dcd8\n--- /dev/null\n+++ b/src/ipa/rkisp1/algorithms/wdr.cpp\n@@ -0,0 +1,493 @@\n+/* SPDX-License-Identifier: LGPL-2.1-or-later */\n+/*\n+ * Copyright (C) 2025, Ideas On Board\n+ *\n+ * RkISP1 Wide Dynamic Range control\n+ */\n+\n+#include \"wdr.h\"\n+\n+#include <libcamera/base/log.h>\n+#include <libcamera/base/utils.h>\n+\n+#include \"libcamera/internal/yaml_parser.h\"\n+\n+#include <libipa/agc_mean_luminance.h>\n+#include <libipa/histogram.h>\n+#include <libipa/pwl.h>\n+\n+#include \"linux/rkisp1-config.h\"\n+\n+/**\n+ * \\file wdr.h\n+ */\n+\n+namespace libcamera {\n+\n+namespace ipa::rkisp1::algorithms {\n+\n+/**\n+ * \\class WideDynamicRange\n+ * \\brief RkISP1 Wide Dynamic Range algorithm\n+ *\n+ * This algorithm implements automatic global tone mapping for the RkISP1.\n+ * Global tone mapping is done by the GWDR hardware block and applies\n+ * a global tone mapping curve to the image to increase the perceived dynamic\n+ * range. Imagine an indoor scene with bright outside visible through the\n+ * windows. With normal exposure settings, the windows will be completely\n+ * saturated and no structure (sky/clouds) will be visible because the AEGC has\n+ * to increase overall exposure to reach a certain level of mean brightness. In\n+ * WDR mode, the algorithm will artifically reduce the exposure time so that the\n+ * texture and colours become visible in the formerly saturated areas. Then the\n+ * global tone mapping curve is applied to mitigate the loss of brightness.\n+ *\n+ * Calculating that tone mapping curve is the most difficult part. This\n+ * algorithm implements four tone mapping strategies:\n+ * - Linear: The tone mapping curve is a combination of two linear functions\n+ *   with one kneepoint\n+ * - Power: The tone mapping curve follows a power function\n+ * - Exponential: The tone mapping curve follows an exponential function\n+ * - HistogramEqualization: The tone mapping curve tries to equalize the\n+ *   histogram\n+ *\n+ * The overall strategy is the same in all cases: Add a constraint to the AEGC\n+ * regulation so that the number of nearly saturated pixels goes below a given\n+ * threshold (default 2%). This threshold can either be specified in the tuning\n+ * file or set via the WdrMaxBrightPixels control.\n+ *\n+ * The global tone mapping curve is then calculated so that it accounts for the\n+ * reduction of brightness due to the exposure constraint. We'll call this the\n+ * WDR-gain. As the result of tone mapping is very difficult to quantize and is\n+ * by definition a lossy process there is not a single \"correct\" solution on how\n+ * this curve should look like.\n+ *\n+ * The approach taken here is based on a simple linear model. Consider a pixel\n+ * that was originally 50% grey. It will have its exposure pushed down by the\n+ * WDR's initial exposure compensation. This value then needs to be pushed back\n+ * up by the tone mapping curve so that it is 50% grey again. This point serves\n+ * as our kneepoint. To get to this kneepoint, this pixel and all darker pixels\n+ * (to the left of the kneepoint on the tone mapping curve) will simply have the\n+ * exposure compensation undone by WDR-gain. This cancels out the\n+ * original exposure compensation, which was 1/WDR-gain. The remaining\n+ * brigher pixels (to the right of the kneepoint on the tone mapping curve) will\n+ * be compressed. The WdrStrength control adjusts the gain of the left part of\n+ * the tone mapping curve.\n+ *\n+ * In the Power and Exponential modes, the curves are calculated so that they\n+ * pass through that kneepoint.\n+ *\n+ * The histogram equalization mode tries to equalize the histogram of the\n+ * image and acts independently of the calculated exposure value.\n+ *\n+ * \\code{.unparsed}\n+ * algorithms:\n+ *   - WideDynamicRange:\n+ *       ExposureConstraint:\n+ *         MaxBrightPixels: 0.02\n+ *         yTarget: 0.95\n+ * \\endcode\n+ */\n+\n+LOG_DEFINE_CATEGORY(RkISP1Wdr)\n+\n+static constexpr unsigned int kTonecurveXIntervals = RKISP1_CIF_ISP_WDR_CURVE_NUM_INTERV;\n+\n+/*\n+ * Increasing interval sizes. The intervals are crafted so that they sum\n+ * up to 4096. This results in better fitting curves than the constant intervals\n+ * (all entries are 4)\n+ */\n+static constexpr std::array<int, kTonecurveXIntervals> kLoglikeIntervals = {\n+\t{ 0, 0, 0, 0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4,\n+\t  4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6 }\n+};\n+\n+WideDynamicRange::WideDynamicRange()\n+{\n+}\n+\n+/**\n+ * \\copydoc libcamera::ipa::Algorithm::init\n+ */\n+int WideDynamicRange::init([[maybe_unused]] IPAContext &context,\n+\t\t\t   [[maybe_unused]] const YamlObject &tuningData)\n+{\n+\tif (!(context.hw.supportedBlocks & 1 << RKISP1_EXT_PARAMS_BLOCK_TYPE_WDR)) {\n+\t\tLOG(RkISP1Wdr, Error)\n+\t\t\t<< \"Wide Dynamic Range not supported by the hardware or kernel.\";\n+\t\treturn -ENOTSUP;\n+\t}\n+\n+\ttoneCurveIntervalValues_ = kLoglikeIntervals;\n+\n+\t/* Calculate a list of normed x values */\n+\ttoneCurveX_[0] = 0.0;\n+\tint lastValue = 0;\n+\tfor (unsigned int i = 1; i < toneCurveX_.size(); i++) {\n+\t\tlastValue += std::pow(2, toneCurveIntervalValues_[i - 1] + 3);\n+\t\tlastValue = std::min(lastValue, 4096);\n+\t\ttoneCurveX_[i] = lastValue / 4096.0;\n+\t}\n+\n+\texposureConstraintMaxBrightPixels_ = 0.02;\n+\texposureConstraintY_ = 0.95;\n+\n+\tconst auto &constraint = tuningData[\"ExposureConstraint\"];\n+\tif (!constraint.isDictionary()) {\n+\t\tLOG(RkISP1Wdr, Warning)\n+\t\t\t<< \"ExposureConstraint not found in tuning data.\"\n+\t\t\t   \"Using default values MaxBrightPixels: \"\n+\t\t\t<< exposureConstraintMaxBrightPixels_\n+\t\t\t<< \" yTarget: \" << exposureConstraintY_;\n+\t} else {\n+\t\texposureConstraintMaxBrightPixels_ =\n+\t\t\tconstraint[\"MaxBrightPixels\"]\n+\t\t\t\t.get<double>()\n+\t\t\t\t.value_or(exposureConstraintMaxBrightPixels_);\n+\t\texposureConstraintY_ =\n+\t\t\tconstraint[\"yTarget\"]\n+\t\t\t\t.get<double>()\n+\t\t\t\t.value_or(exposureConstraintY_);\n+\t}\n+\n+\tcontext.ctrlMap[&controls::WdrMode] =\n+\t\tControlInfo(controls::WdrModeValues, controls::WdrOff);\n+\tcontext.ctrlMap[&controls::WdrStrength] =\n+\t\tControlInfo(0.0f, 2.0f, 1.0f);\n+\tcontext.ctrlMap[&controls::WdrMaxBrightPixels] =\n+\t\tControlInfo(0.0f, 1.0f, static_cast<float>(exposureConstraintMaxBrightPixels_));\n+\n+\tapplyCompensationLinear(1.0, 0.0);\n+\n+\treturn 0;\n+}\n+\n+/**\n+ * \\copydoc libcamera::ipa::Algorithm::configure\n+ */\n+int WideDynamicRange::configure(IPAContext &context,\n+\t\t\t\t[[maybe_unused]] const IPACameraSensorInfo &configInfo)\n+{\n+\tcontext.activeState.wdr.mode = controls::WdrOff;\n+\tcontext.activeState.wdr.gain = 1.0;\n+\tcontext.activeState.wdr.strength = 1.0;\n+\tauto &constraint = context.activeState.wdr.constraint;\n+\tconstraint.bound = AgcMeanLuminance::AgcConstraint::Bound::Upper;\n+\tconstraint.qHi = 1.0;\n+\tconstraint.qLo = 1.0 - exposureConstraintMaxBrightPixels_;\n+\tconstraint.yTarget = exposureConstraintY_;\n+\treturn 0;\n+}\n+\n+void WideDynamicRange::applyHistogramEqualization(double strength)\n+{\n+\tif (hist_.empty())\n+\t\treturn;\n+\n+\t/*\n+\t * Apply a factor on strength, so that it roughly matches the optical\n+\t * impression that is produced by the other algorithms. The goal is that\n+\t * the user can switch algorithms for different looks but similar\n+\t * \"strength\".\n+\t */\n+\tstrength *= 0.65;\n+\n+\t/*\n+\t * In a fully equalized histogram, all bins have the same value. Try\n+\t * to equalize the histogram by applying a gain or damping depending on\n+\t * the distance of the actual bin value from that norm.\n+\t */\n+\tstd::vector<double> gains;\n+\tgains.resize(hist_.size());\n+\tdouble sum = 0;\n+\tdouble norm = 1.0 / (gains.size());\n+\tfor (unsigned i = 0; i < hist_.size(); i++) {\n+\t\tdouble diff = 1.0 + strength * (hist_[i] - norm) / norm;\n+\t\tgains[i] = diff;\n+\t\tsum += diff;\n+\t}\n+\n+\t/* Never amplify the last entry. */\n+\tgains.back() = std::max(gains.back(), 1.0);\n+\n+\tdouble scale = gains.size() / sum;\n+\tfor (auto &v : gains)\n+\t\tv *= scale;\n+\n+\tPwl pwl;\n+\tdouble step = 1.0 / gains.size();\n+\tdouble lastX = 0;\n+\tdouble lastY = 0;\n+\n+\tpwl.append(lastX, lastY);\n+\tfor (unsigned int i = 0; i < gains.size() - 1; i++) {\n+\t\tlastY += gains[i] * step;\n+\t\tlastX += step;\n+\t\tpwl.append(lastX, lastY);\n+\t}\n+\tpwl.append(1.0, 1.0);\n+\n+\tfor (unsigned int i = 0; i < toneCurveX_.size(); i++)\n+\t\ttoneCurveY_[i] = pwl.eval(toneCurveX_[i]);\n+}\n+\n+Vector<double, 2> WideDynamicRange::kneePoint(double gain, double strength)\n+{\n+\tgain = std::pow(gain, strength);\n+\tdouble y = 0.5;\n+\tdouble x = y / gain;\n+\n+\treturn { { x, y } };\n+}\n+\n+void WideDynamicRange::applyCompensationLinear(double gain, double strength)\n+{\n+\tauto kp = kneePoint(gain, strength);\n+\tdouble g1 = kp.y() / kp.x();\n+\tdouble g2 = (kp.y() - 1) / (kp.x() - 1);\n+\n+\tfor (unsigned int i = 0; i < toneCurveX_.size(); i++) {\n+\t\tdouble x = toneCurveX_[i];\n+\t\tdouble y;\n+\t\tif (x <= kp.x()) {\n+\t\t\ty = g1 * x;\n+\t\t} else {\n+\t\t\ty = g2 * x + 1 - g2;\n+\t\t}\n+\t\ttoneCurveY_[i] = y;\n+\t}\n+}\n+\n+void WideDynamicRange::applyCompensationPower(double gain, double strength)\n+{\n+\tdouble e = 1.0;\n+\tif (strength > 1e-6) {\n+\t\tauto kp = kneePoint(gain, strength);\n+\t\t/* Calculate an exponent to go through the knee point. */\n+\t\te = log(kp.y()) / log(kp.x());\n+\t}\n+\n+\t/*\n+\t * The power function tends to be extremely steep at the beginning. This\n+\t * leads to noise and image artifacts in the dark areas. To mitigate\n+\t * that, we add a short linear section at the beginning of the curve.\n+\t * The connection between linear and power is the point where the linear\n+\t * section reaches the y level yLin. The power curve is then scaled so\n+\t * that it starts at the connection point with the steepness it would\n+\t * have at y=yLin but still goes through 1,1\n+\t */\n+\tdouble yLin = 0.1;\n+\t/* x position of the connection point */\n+\tdouble xb = yLin / gain;\n+\t/* x offset for the scaled power function */\n+\tdouble q = xb - std::exp(std::log(yLin) / e);\n+\n+\tfor (unsigned int i = 0; i < toneCurveX_.size(); i++) {\n+\t\tdouble x = toneCurveX_[i];\n+\t\tif (x < xb) {\n+\t\t\ttoneCurveY_[i] = x * gain;\n+\t\t} else {\n+\t\t\tx = (x - q) / (1 - q);\n+\t\t\ttoneCurveY_[i] = std::pow(x, e);\n+\t\t}\n+\t}\n+}\n+\n+void WideDynamicRange::applyCompensationExponential(double gain, double strength)\n+{\n+\tdouble k = 0.1;\n+\tauto kp = kneePoint(gain, strength);\n+\tdouble kx = kp.x();\n+\tdouble ky = kp.y();\n+\n+\tif (kx > ky) {\n+\t\tLOG(RkISP1Wdr, Warning) << \"Invalid knee point: \" << kp;\n+\t\tkx = ky;\n+\t}\n+\n+\t/*\n+\t * The exponential curve is based on the function proposed by Glozman\n+\t * et al. in\n+\t * S. Glozman, T. Kats, and O. Yadid-Pecht, \"Exponent Operator Based\n+\t * Tone Mapping Algorithm for Color Wide Dynamic Range Images,\" 2011.\n+\t *\n+\t * That function uses a k factor as parameter for the WDR compression\n+\t * curve:\n+\t * k=0: maximum compression\n+\t * k=infinity: linear curve\n+\t *\n+\t * To calculate a k factor that results in a curve that passes through\n+\t * the kneepoint, the equation needs to be solved for k after inserting\n+\t * the kneepoint.  This can be formulated as search for a zero point.\n+\t * Unfortunately there is no closed solution for that transformation.\n+\t * Using newton's method to approximate the value is numerically\n+\t * unstable.\n+\t *\n+\t * Luckily the function only crosses the x axis once and for the set of\n+\t * possible kneepoints, a negative and a positive point can be guessed.\n+\t * The approximation is then implemented using bisection.\n+\t */\n+\tif (std::abs(kx - ky) < 0.001) {\n+\t\tk = 1e8;\n+\t} else {\n+\t\tdouble kl = 0.0001;\n+\t\tdouble kh = 1000;\n+\n+\t\tauto fk = [=](double v) {\n+\t\t\treturn std::exp(-kx / v) -\n+\t\t\t       ky * std::exp(-1.0 / v) + ky - 1.0;\n+\t\t};\n+\n+\t\tASSERT(fk(kl) < 0);\n+\t\tASSERT(fk(kh) > 0);\n+\n+\t\tk = kh / 10.0;\n+\t\twhile (fk(k) > 0) {\n+\t\t\tkh = k;\n+\t\t\tk /= 10.0;\n+\t\t}\n+\n+\t\tdo {\n+\t\t\tk = (kl + kh) / 2;\n+\t\t\tif (fk(k) < 0)\n+\t\t\t\tkl = k;\n+\t\t\telse\n+\t\t\t\tkh = k;\n+\t\t} while (std::abs(kh - kl) > 1e-3);\n+\t}\n+\n+\tdouble a = 1.0 / (1.0 - std::exp(-1.0 / k));\n+\tfor (unsigned int i = 0; i < toneCurveX_.size(); i++)\n+\t\ttoneCurveY_[i] = a * (1.0 - std::exp(-toneCurveX_[i] / k));\n+}\n+\n+/**\n+ * \\copydoc libcamera::ipa::Algorithm::queueRequest\n+ */\n+void WideDynamicRange::queueRequest([[maybe_unused]] IPAContext &context,\n+\t\t\t\t    [[maybe_unused]] const uint32_t frame,\n+\t\t\t\t    IPAFrameContext &frameContext,\n+\t\t\t\t    const ControlList &controls)\n+{\n+\tauto &activeState = context.activeState;\n+\n+\tconst auto &mode = controls.get(controls::WdrMode);\n+\tif (mode)\n+\t\tactiveState.wdr.mode = static_cast<controls::WdrModeEnum>(*mode);\n+\n+\tconst auto &brightPixels = controls.get(controls::WdrMaxBrightPixels);\n+\tif (brightPixels)\n+\t\tactiveState.wdr.constraint.qLo = 1.0 - *brightPixels;\n+\n+\tconst auto &strength = controls.get(controls::WdrStrength);\n+\tif (strength)\n+\t\tactiveState.wdr.strength = *strength;\n+\n+\tframeContext.wdr.mode = activeState.wdr.mode;\n+\tframeContext.wdr.strength = activeState.wdr.strength;\n+}\n+\n+/**\n+ * \\copydoc libcamera::ipa::Algorithm::prepare\n+ */\n+void WideDynamicRange::prepare(IPAContext &context,\n+\t\t\t       [[maybe_unused]] const uint32_t frame,\n+\t\t\t       IPAFrameContext &frameContext,\n+\t\t\t       RkISP1Params *params)\n+{\n+\tif (!params) {\n+\t\tLOG(RkISP1Wdr, Warning) << \"Params is null\";\n+\t\treturn;\n+\t}\n+\n+\tauto mode = frameContext.wdr.mode;\n+\n+\tauto config = params->block<BlockType::Wdr>();\n+\tconfig.setEnabled(mode != controls::WdrOff);\n+\n+\t/* Calculate how much EV we need to compensate with the WDR curve. */\n+\tdouble gain = context.activeState.wdr.gain;\n+\tframeContext.wdr.gain = gain;\n+\n+\tif (mode == controls::WdrOff) {\n+\t\tapplyCompensationLinear(1.0, 0.0);\n+\t} else if (mode == controls::WdrLinear) {\n+\t\tapplyCompensationLinear(gain, frameContext.wdr.strength);\n+\t} else if (mode == controls::WdrPower) {\n+\t\tapplyCompensationPower(gain, frameContext.wdr.strength);\n+\t} else if (mode == controls::WdrExponential) {\n+\t\tapplyCompensationExponential(gain, frameContext.wdr.strength);\n+\t} else if (mode == controls::WdrHistogramEqualization) {\n+\t\tapplyHistogramEqualization(frameContext.wdr.strength);\n+\t}\n+\n+\t/* Reset value */\n+\tconfig->dmin_strength = 0x10;\n+\tconfig->dmin_thresh = 0;\n+\n+\tfor (unsigned int i = 0; i < kTonecurveXIntervals; i++) {\n+\t\tint v = toneCurveIntervalValues_[i];\n+\t\tconfig->tone_curve.dY[i / 8] |= (v & 0x07) << ((i % 8) * 4);\n+\t}\n+\n+\t/*\n+\t * Fix the curve to adhere to the hardware constraints. Don't apply a\n+\t * constraint on the first element, which is most likely zero anyways.\n+\t */\n+\tint lastY = toneCurveY_[0] * 4096.0;\n+\tfor (unsigned int i = 0; i < toneCurveX_.size(); i++) {\n+\t\tint diff = static_cast<int>(toneCurveY_[i] * 4096.0) - lastY;\n+\t\tdiff = std::clamp(diff, -2048, 2048);\n+\t\tlastY = lastY + diff;\n+\t\tconfig->tone_curve.ym[i] = lastY;\n+\t}\n+}\n+\n+void WideDynamicRange::process(IPAContext &context, [[maybe_unused]] const uint32_t frame,\n+\t\t\t       IPAFrameContext &frameContext,\n+\t\t\t       const rkisp1_stat_buffer *stats,\n+\t\t\t       ControlList &metadata)\n+{\n+\tif (!stats || !(stats->meas_type & RKISP1_CIF_ISP_STAT_HIST)) {\n+\t\tLOG(RkISP1Wdr, Warning) << \"No histogram data in statistics\";\n+\t\treturn;\n+\t}\n+\n+\tconst rkisp1_cif_isp_stat *params = &stats->params;\n+\tauto mode = frameContext.wdr.mode;\n+\n+\tmetadata.set(controls::WdrMode, mode);\n+\n+\tHistogram cumHist({ params->hist.hist_bins, context.hw.numHistogramBins },\n+\t\t\t  [](uint32_t x) { return x >> 4; });\n+\n+\t/* Calculate the gain needed to reach the requested yTarget. */\n+\tdouble value = cumHist.interQuantileMean(0, 1.0) / cumHist.bins();\n+\tdouble gain = context.activeState.agc.automatic.yTarget / value;\n+\tgain = std::max(gain, 1.0);\n+\n+\tdouble speed = 0.2;\n+\tgain = gain * speed + context.activeState.wdr.gain * (1.0 - speed);\n+\n+\tcontext.activeState.wdr.gain = gain;\n+\n+\tstd::vector<double> hist;\n+\tdouble sum = 0;\n+\tfor (unsigned i = 0; i < context.hw.numHistogramBins; i++) {\n+\t\tdouble v = params->hist.hist_bins[i] >> 4;\n+\t\thist.push_back(v);\n+\t\tsum += v;\n+\t}\n+\n+\t/* Scale so that the entries sum up to 1. */\n+\tdouble scale = 1.0 / sum;\n+\tfor (auto &v : hist)\n+\t\tv *= scale;\n+\thist_.swap(hist);\n+}\n+\n+REGISTER_IPA_ALGORITHM(WideDynamicRange, \"WideDynamicRange\")\n+\n+} /* namespace ipa::rkisp1::algorithms */\n+\n+} /* namespace libcamera */\ndiff --git a/src/ipa/rkisp1/algorithms/wdr.h b/src/ipa/rkisp1/algorithms/wdr.h\nnew file mode 100644\nindex 000000000000..46f7cdeea69d\n--- /dev/null\n+++ b/src/ipa/rkisp1/algorithms/wdr.h\n@@ -0,0 +1,58 @@\n+/* SPDX-License-Identifier: LGPL-2.1-or-later */\n+/*\n+ * Copyright (C) 2025, Ideas On Board\n+ *\n+ * RkISP1 Wide Dynamic Range control\n+ */\n+\n+#pragma once\n+\n+#include <libcamera/control_ids.h>\n+\n+#include \"linux/rkisp1-config.h\"\n+\n+#include \"algorithm.h\"\n+\n+namespace libcamera {\n+\n+namespace ipa::rkisp1::algorithms {\n+\n+class WideDynamicRange : public Algorithm\n+{\n+public:\n+\tWideDynamicRange();\n+\t~WideDynamicRange() = default;\n+\n+\tint init(IPAContext &context, const YamlObject &tuningData) override;\n+\tint configure(IPAContext &context, const IPACameraSensorInfo &configInfo) override;\n+\n+\tvoid queueRequest(IPAContext &context, const uint32_t frame,\n+\t\t\t  IPAFrameContext &frameContext,\n+\t\t\t  const ControlList &controls) override;\n+\tvoid prepare(IPAContext &context, const uint32_t frame,\n+\t\t     IPAFrameContext &frameContext,\n+\t\t     RkISP1Params *params) override;\n+\tvoid process(IPAContext &context, const uint32_t frame,\n+\t\t     IPAFrameContext &frameContext,\n+\t\t     const rkisp1_stat_buffer *stats,\n+\t\t     ControlList &metadata) override;\n+\n+private:\n+\tVector<double, 2> kneePoint(double gain, double strength);\n+\tvoid applyCompensationLinear(double gain, double strength);\n+\tvoid applyCompensationPower(double gain, double strength);\n+\tvoid applyCompensationExponential(double gain, double strength);\n+\tvoid applyHistogramEqualization(double strength);\n+\n+\tdouble exposureConstraintMaxBrightPixels_;\n+\tdouble exposureConstraintY_;\n+\n+\tstd::vector<double> hist_;\n+\n+\tstd::array<int, RKISP1_CIF_ISP_WDR_CURVE_NUM_INTERV> toneCurveIntervalValues_;\n+\tstd::array<double, RKISP1_CIF_ISP_WDR_CURVE_NUM_INTERV + 1> toneCurveX_;\n+\tstd::array<double, RKISP1_CIF_ISP_WDR_CURVE_NUM_INTERV + 1> toneCurveY_;\n+};\n+\n+} /* namespace ipa::rkisp1::algorithms */\n+} /* namespace libcamera */\ndiff --git a/src/ipa/rkisp1/ipa_context.h b/src/ipa/rkisp1/ipa_context.h\nindex 113b90428008..f85a130d9c23 100644\n--- a/src/ipa/rkisp1/ipa_context.h\n+++ b/src/ipa/rkisp1/ipa_context.h\n@@ -26,6 +26,7 @@\n \n #include <libipa/camera_sensor_helper.h>\n #include <libipa/fc_queue.h>\n+#include \"libipa/agc_mean_luminance.h\"\n \n namespace libcamera {\n \n@@ -131,6 +132,13 @@ struct IPAActiveState {\n \tstruct {\n \t\tdouble gamma;\n \t} goc;\n+\n+\tstruct {\n+\t\tcontrols::WdrModeEnum mode;\n+\t\tAgcMeanLuminance::AgcConstraint constraint;\n+\t\tdouble gain;\n+\t\tdouble strength;\n+\t} wdr;\n };\n \n struct IPAFrameContext : public FrameContext {\n@@ -200,6 +208,12 @@ struct IPAFrameContext : public FrameContext {\n \tstruct {\n \t\tdouble lux;\n \t} lux;\n+\n+\tstruct {\n+\t\tcontrols::WdrModeEnum mode;\n+\t\tdouble strength;\n+\t\tdouble gain;\n+\t} wdr;\n };\n \n struct IPAContext {\ndiff --git a/src/ipa/rkisp1/params.cpp b/src/ipa/rkisp1/params.cpp\nindex 4c0b051ce65d..5edb36c91b87 100644\n--- a/src/ipa/rkisp1/params.cpp\n+++ b/src/ipa/rkisp1/params.cpp\n@@ -74,6 +74,7 @@ const std::map<BlockType, BlockTypeInfo> kBlockTypeInfo = {\n \tRKISP1_BLOCK_TYPE_ENTRY_EXT(CompandBls, COMPAND_BLS, compand_bls),\n \tRKISP1_BLOCK_TYPE_ENTRY_EXT(CompandExpand, COMPAND_EXPAND, compand_curve),\n \tRKISP1_BLOCK_TYPE_ENTRY_EXT(CompandCompress, COMPAND_COMPRESS, compand_curve),\n+\tRKISP1_BLOCK_TYPE_ENTRY_EXT(Wdr, WDR, wdr),\n };\n \n } /* namespace */\ndiff --git a/src/ipa/rkisp1/params.h b/src/ipa/rkisp1/params.h\nindex 40450e34497a..2e60528d102e 100644\n--- a/src/ipa/rkisp1/params.h\n+++ b/src/ipa/rkisp1/params.h\n@@ -40,6 +40,7 @@ enum class BlockType {\n \tCompandBls,\n \tCompandExpand,\n \tCompandCompress,\n+\tWdr,\n };\n \n namespace details {\n@@ -74,6 +75,7 @@ RKISP1_DEFINE_BLOCK_TYPE(Afc, afc)\n RKISP1_DEFINE_BLOCK_TYPE(CompandBls, compand_bls)\n RKISP1_DEFINE_BLOCK_TYPE(CompandExpand, compand_curve)\n RKISP1_DEFINE_BLOCK_TYPE(CompandCompress, compand_curve)\n+RKISP1_DEFINE_BLOCK_TYPE(Wdr, wdr)\n \n } /* namespace details */\n \ndiff --git a/src/libcamera/control_ids_core.yaml b/src/libcamera/control_ids_core.yaml\nindex eec4b4f937ee..f781865859ac 100644\n--- a/src/libcamera/control_ids_core.yaml\n+++ b/src/libcamera/control_ids_core.yaml\n@@ -1283,5 +1283,67 @@ controls:\n         \\sa SensorTimestamp\n \n         The FrameWallClock control can only be returned in metadata.\n+  - WdrMode:\n+      type: int32_t\n+      direction: inout\n+      description: |\n+        Set the WDR mode.\n+\n+        The WDR mode is used to select the algorithm used for global tone\n+        mapping. It will automatically reduce the exposure time of the sensor\n+        so that there are only a small number of saturated pixels in the image.\n+        The algorithm then compensates for the loss of brightness by applying a\n+        global tone mapping curve to the image.\n+      enum:\n+        - name: WdrOff\n+          value: 0\n+          description: Wdr is disabled.\n+        - name: WdrLinear\n+          value: 1\n+          description:\n+            Apply a linear global tone mapping curve.\n+\n+            A curve with two linear sections is applied. This produces good\n+            results at the expense of a slightly artificial look.\n+        - name: WdrPower\n+          value: 2\n+          description: |\n+            Apply a power global tone mapping curve.\n+\n+            This curve has high gain values on the dark areas of an image and\n+            high compression values on the bright area. It therefore tends to\n+            produce noticeable noise artifacts.\n+        - name: WdrExponential\n+          value: 3\n+          description: |\n+            Apply an exponential global tone mapping curve.\n+\n+            This curve has lower gain values in dark areas compared to the power\n+            curve but produces a more natural look compared to the linear curve.\n+            It is therefore the best choice for most scenes.\n+        - name: WdrHistogramEqualization\n+          value: 4\n+          description: |\n+            Apply histogram equalization.\n+\n+            This curve preserves most of the information of the image at the\n+            expense of a very artificial look. It is therefore best suited for\n+            technical analysis.\n+  - WdrStrength:\n+      type: float\n+      direction: in\n+      description: |\n+        Specify the strength of the wdr algorithm. The exact meaning of this\n+        value is specific to the algorithm in use. Usually a value of 0 means no\n+        global tone mapping is applied. A values of 1 is the default value and\n+        the correct value for most scenes. A value above 1 increases the global\n+        tone mapping effect and can lead to unrealistic image effects.\n+  - WdrMaxBrightPixels:\n+      type: float\n+      direction: in\n+      description: |\n+        Percentage of allowed (nearly) saturated pixels. The WDR algorithm\n+        reduces the WdrExposureValue until the amount of pixels that are close\n+        to saturation is lower than this value.\n \n ...\n","prefixes":["v5","18/19"]}