diff --git a/src/ipa/libipa/lux.cpp b/src/ipa/libipa/lux.cpp
new file mode 100644
index 000000000000..b60060e21803
--- /dev/null
+++ b/src/ipa/libipa/lux.cpp
@@ -0,0 +1,181 @@
+/* SPDX-License-Identifier: BSD-2-Clause */
+/*
+ * Copyright (C) 2019, Raspberry Pi Ltd
+ * Copyright (C) 2024, Paul Elder <paul.elder@ideasonboard.com>
+ *
+ * Helper class that implements lux estimation
+ */
+#include "lux.h"
+
+#include <algorithm>
+#include <chrono>
+
+#include <libcamera/base/log.h>
+
+#include "libcamera/internal/yaml_parser.h"
+
+#include "histogram.h"
+
+/**
+ * \file lux.h
+ * \brief Helper class that implements lux estimation
+ *
+ * Estimating the lux level of an image is a common operation that can for
+ * instance be used to adjust the target Y value in AGC or for Bayesian AWB
+ * estimation.
+ */
+
+namespace libcamera {
+
+using namespace std::literals::chrono_literals;
+
+LOG_DEFINE_CATEGORY(Lux)
+
+namespace ipa {
+
+/**
+ * \class Lux
+ * \brief Class that implements lux estimation
+ *
+ * IPAs that wish to use lux esimation should create a Lux algorithm module
+ * that lightly wraps this module by providing the platform-specific luminance
+ * histogram. The Lux entry in the tuning file must then precede the algorithms
+ * that depend on the estimated lux value.
+ */
+
+/**
+ * \var Lux::binSize_
+ * \brief The maximum count of each bin
+ */
+
+/**
+ * \var Lux::referenceExposureTime_
+ * \brief The exposure time of the reference image, in microseconds
+ */
+
+/**
+ * \var Lux::referenceAnalogueGain_
+ * \brief The analogue gain of the reference image
+ */
+
+/**
+ * \var Lux::referenceDigitalGain_
+ * \brief The analogue gain of the reference image
+ */
+
+/**
+ * \var Lux::referenceY_
+ * \brief The measured luminance of the reference image, out of the bin size
+ *
+ * \sa binSize_
+ */
+
+/**
+ * \var Lux::referenceLux_
+ * \brief The estimated lux level of the reference image
+ */
+
+/**
+  * \brief Construct the Lux helper module
+  * \param[in] binSize The maximum count of each bin
+  */
+Lux::Lux(unsigned int binSize)
+	: binSize_(binSize)
+{
+}
+
+/**
+ * \brief Parse tuning data
+ * \param[in] tuningData The YamlObject representing the tuning data
+ *
+ * This function parses yaml tuning data for the common Lux module. It requires
+ * reference exposure time, analogue gain, digital gain, and lux values.
+ *
+ * \code{.unparsed}
+ * algorithms:
+ *   - Lux:
+ *       referenceExposureTime: 10000
+ *       referenceAnalogueGain: 4.0
+ *       referenceDigitalGain: 1.0
+ *       referenceY: 12000
+ *       referenceLux: 1000
+ * \endcode
+ *
+ * \return 0 on success or a negative error code
+ */
+int Lux::parseTuningData(const YamlObject &tuningData)
+{
+	auto value = tuningData["referenceExposureTime"].get<double>();
+	if (!value) {
+		LOG(Lux, Error) << "Missing tuning parameter: "
+				<< "'referenceExposureTime'";
+		return -EINVAL;
+	}
+	referenceExposureTime_ = *value * 1.0us;
+
+	value = tuningData["referenceAnalogueGain"].get<double>();
+	if (!value) {
+		LOG(Lux, Error) << "Missing tuning parameter: "
+				<< "'referenceAnalogueGain'";
+		return -EINVAL;
+	}
+	referenceAnalogueGain_ = *value;
+
+	value = tuningData["referenceDigitalGain"].get<double>();
+	if (!value) {
+		LOG(Lux, Error) << "Missing tuning parameter: "
+				<< "'referenceDigitalGain'";
+		return -EINVAL;
+	}
+	referenceDigitalGain_ = *value;
+
+	value = tuningData["referenceY"].get<double>();
+	if (!value) {
+		LOG(Lux, Error) << "Missing tuning parameter: "
+				<< "'referenceY'";
+		return -EINVAL;
+	}
+	referenceY_ = *value;
+
+	value = tuningData["referenceLux"].get<double>();
+	if (!value) {
+		LOG(Lux, Error) << "Missing tuning parameter: "
+				<< "'referenceLux'";
+		return -EINVAL;
+	}
+	referenceLux_ = *value;
+
+	return 0;
+}
+
+/**
+ * \brief Estimate lux given runtime values
+ * \param[in] exposureTime Exposure time applied to the frame
+ * \param[in] aGain Analogue gain applied to the frame
+ * \param[in] dGain Digital gain applied to the frame
+ * \param[in] yHist Histogram from the ISP statistics
+ *
+ * Estimate the lux given the exposure time, gain, and histogram.
+ *
+ * \return Estimated lux value
+ */
+double Lux::estimateLux(utils::Duration exposureTime,
+			double aGain, double dGain,
+			const Histogram &yHist) const
+{
+	double currentY = yHist.interQuantileMean(0, 1);
+	double exposureTimeRatio = referenceExposureTime_ / exposureTime;
+	double aGainRatio = referenceAnalogueGain_ / aGain;
+	double dGainRatio = referenceDigitalGain_ / dGain;
+	double yRatio = currentY * (binSize_ / yHist.bins()) / referenceY_;
+
+	double estimatedLux = exposureTimeRatio * aGainRatio * dGainRatio *
+			      yRatio * referenceLux_;
+
+	LOG(Lux, Debug) << "Estimated lux " << estimatedLux;
+	return estimatedLux;
+}
+
+} /* namespace ipa */
+
+} /* namespace libcamera */
diff --git a/src/ipa/libipa/lux.h b/src/ipa/libipa/lux.h
new file mode 100644
index 000000000000..93ca64795803
--- /dev/null
+++ b/src/ipa/libipa/lux.h
@@ -0,0 +1,42 @@
+/* SPDX-License-Identifier: BSD-2-Clause */
+/*
+ * Copyright (C) 2019, Raspberry Pi Ltd
+ * Copyright (C) 2024, Paul Elder <paul.elder@ideasonboard.com>
+ *
+ * Helper class that implements lux estimation
+ */
+
+#pragma once
+
+#include <libcamera/base/utils.h>
+
+namespace libcamera {
+
+class YamlObject;
+
+namespace ipa {
+
+class Histogram;
+
+class Lux
+{
+public:
+	Lux(unsigned int binSize);
+
+	int parseTuningData(const YamlObject &tuningData);
+	double estimateLux(utils::Duration exposureTime,
+			   double aGain, double dGain,
+			   const Histogram &yHist) const;
+
+private:
+	unsigned int binSize_;
+	utils::Duration referenceExposureTime_;
+	double referenceAnalogueGain_;
+	double referenceDigitalGain_;
+	double referenceY_;
+	double referenceLux_;
+};
+
+} /* namespace ipa */
+
+} /* namespace libcamera */
diff --git a/src/ipa/libipa/meson.build b/src/ipa/libipa/meson.build
index a7f16ff63079..f2b2f4be50db 100644
--- a/src/ipa/libipa/meson.build
+++ b/src/ipa/libipa/meson.build
@@ -11,6 +11,7 @@ libipa_headers = files([
     'histogram.h',
     'interpolator.h',
     'lsc_polynomial.h',
+    'lux.h',
     'module.h',
     'pwl.h',
     'vector.h',
@@ -27,6 +28,7 @@ libipa_sources = files([
     'histogram.cpp',
     'interpolator.cpp',
     'lsc_polynomial.cpp',
+    'lux.cpp',
     'module.cpp',
     'pwl.cpp',
     'vector.cpp',
