diff --git a/include/libcamera/internal/software_isp/debayer_params.h b/include/libcamera/internal/software_isp/debayer_params.h
index 6772b43bced4..b46bbd7a8273 100644
--- a/include/libcamera/internal/software_isp/debayer_params.h
+++ b/include/libcamera/internal/software_isp/debayer_params.h
@@ -24,7 +24,7 @@ struct DebayerParams {
 	RGB<float> blackLevel = RGB<float>({ 0.0, 0.0, 0.0 });
 	float gamma = 1.0;
 	float contrastExp = 1.0;
-	RGB<float> gains = RGB<float>({ 1.0, 1.0, 1.0 });
+	RGB<double> gains = RGB<double>({ 1.0, 1.0, 1.0 });
 };
 
 } /* namespace libcamera */
diff --git a/src/ipa/simple/algorithms/awb.cpp b/src/ipa/simple/algorithms/awb.cpp
index 05155c83d172..01323d9779c5 100644
--- a/src/ipa/simple/algorithms/awb.cpp
+++ b/src/ipa/simple/algorithms/awb.cpp
@@ -15,7 +15,6 @@
 #include <libcamera/control_ids.h>
 
 #include "libipa/colours.h"
-#include "simple/ipa_context.h"
 
 namespace libcamera {
 
@@ -23,41 +22,79 @@ LOG_DEFINE_CATEGORY(IPASoftAwb)
 
 namespace ipa::soft::algorithms {
 
+/*
+ * \todo Replace it with a proper Lux algorithm
+ */
+static constexpr unsigned int kDefaultLux = 500;
+
+class SimpleAwbStats final : public AwbStats
+{
+public:
+	SimpleAwbStats() {}
+	SimpleAwbStats(const RGB<double> &rgbMeans)
+		: AwbStats(rgbMeans)
+	{
+	}
+
+	/* Minimum mean value below which AWB can't operate. */
+	double minColourValue() const override
+	{
+		return 0.2;
+	}
+};
+
+/**
+ * \copydoc libcamera::ipa::Algorithm::init
+ */
+int Awb::init(IPAContext &context, const ValueNode &tuningData)
+{
+	return awbAlgo_.init(tuningData, context.ctrlMap);
+}
+
+/**
+ * \copydoc libcamera::ipa::Algorithm::configure
+ */
 int Awb::configure(IPAContext &context,
 		   [[maybe_unused]] const IPAConfigInfo &configInfo)
 {
-	auto &gains = context.activeState.awb.gains;
-	gains = { { 1.0, 1.0, 1.0 } };
+	return awbAlgo_.configure(context.activeState.awb,
+				  context.configuration.awb);
+}
 
-	return 0;
+/**
+ * \copydoc libcamera::ipa::Algorithm::queueRequest
+ */
+void Awb::queueRequest(IPAContext &context,
+		       const uint32_t frame,
+		       IPAFrameContext &frameContext,
+		       const ControlList &controls)
+{
+	awbAlgo_.queueRequest(context.activeState.awb, frame, frameContext.awb,
+			      controls);
 }
 
+/**
+ * \copydoc libcamera::ipa::Algorithm::prepare
+ */
 void Awb::prepare(IPAContext &context,
 		  [[maybe_unused]] const uint32_t frame,
 		  IPAFrameContext &frameContext,
 		  DebayerParams *params)
 {
-	auto &gains = context.activeState.awb.gains;
+	awbAlgo_.prepare(context.activeState.awb, frameContext.awb);
 
-	frameContext.gains = gains;
-	params->gains = gains;
+	params->gains = frameContext.awb.gains;
 }
 
-void Awb::process(IPAContext &context,
-		  [[maybe_unused]] const uint32_t frame,
-		  IPAFrameContext &frameContext,
-		  const SwIspStats *stats,
-		  ControlList &metadata)
+SimpleAwbStats Awb::calculateRgbMeans(IPAContext &context,
+				      const SwIspStats *stats) const
 {
+	if (!stats->valid)
+		return {};
+
 	const SwIspStats::Histogram &histogram = stats->yHistogram;
 	const uint8_t blackLevel = context.activeState.blc.level;
 
-	metadata.set(controls::ColourGains, { frameContext.gains.r(),
-					      frameContext.gains.b() });
-
-	if (!stats->valid)
-		return;
-
 	/*
 	 * Black level must be subtracted to get the correct AWB ratios, they
 	 * would be off if they were computed from the whole brightness range
@@ -67,30 +104,37 @@ void Awb::process(IPAContext &context,
 		histogram.begin(), histogram.end(), uint64_t(0));
 	const uint64_t offset = blackLevel * nPixels;
 	const uint64_t minValid = 1;
+
 	/*
 	 * Make sure the sums are at least minValid, while preventing unsigned
 	 * integer underflow.
 	 */
 	const RGB<uint64_t> sum = stats->sum_.max(offset + minValid) - offset;
 
+	RGB<double> rgbMeans = { { static_cast<double>(sum.r() / nPixels),
+				   static_cast<double>(sum.g() / nPixels),
+				   static_cast<double>(sum.b() / nPixels) } };
+
 	/*
-	 * Calculate red and blue gains for AWB.
-	 * Clamp max gain at 4.0, this also avoids 0 division.
+	 * \todo Determine the minimum allowed thresholds from the mean
+	 * but we currently have the sum - not the mean value!
+	 *
+	 * Currently set to SimpleAwbStats::minColourValue() = 0.2.
 	 */
-	auto &gains = context.activeState.awb.gains;
-	gains = { {
-		sum.r() <= sum.g() / 4 ? 4.0f : static_cast<float>(sum.g()) / sum.r(),
-		1.0,
-		sum.b() <= sum.g() / 4 ? 4.0f : static_cast<float>(sum.g()) / sum.b(),
-	} };
-
-	RGB<double> rgbGains{ { 1 / gains.r(), 1 / gains.g(), 1 / gains.b() } };
-	context.activeState.awb.temperatureK = estimateCCT(rgbGains);
-	metadata.set(controls::ColourTemperature, context.activeState.awb.temperatureK);
-
-	LOG(IPASoftAwb, Debug)
-		<< "gain R/B: " << gains << "; temperature: "
-		<< context.activeState.awb.temperatureK;
+	return SimpleAwbStats(rgbMeans);
+}
+
+/**
+ * \copydoc libcamera::ipa::Algorithm::process
+ */
+void Awb::process(IPAContext &context, [[maybe_unused]] const uint32_t frame,
+		  IPAFrameContext &frameContext, const SwIspStats *stats,
+		  ControlList &metadata)
+{
+	SimpleAwbStats awbStats = calculateRgbMeans(context, stats);
+
+	awbAlgo_.process(context.activeState.awb, frameContext.awb, awbStats,
+			 kDefaultLux, metadata);
 }
 
 REGISTER_IPA_ALGORITHM(Awb, "Awb")
diff --git a/src/ipa/simple/algorithms/awb.h b/src/ipa/simple/algorithms/awb.h
index ad993f39c180..cb36cd092e51 100644
--- a/src/ipa/simple/algorithms/awb.h
+++ b/src/ipa/simple/algorithms/awb.h
@@ -7,19 +7,37 @@
 
 #pragma once
 
+#include <libcamera/controls.h>
+
+#include "libcamera/internal/software_isp/debayer_params.h"
+#include "libcamera/internal/value_node.h"
+
+#include "libipa/awb.h"
+#include "libipa/fixedpoint.h"
+#include "simple/ipa_context.h"
+
 #include "algorithm.h"
 
 namespace libcamera {
 
 namespace ipa::soft::algorithms {
 
+class SimpleAwbStats;
+
 class Awb : public Algorithm
 {
 public:
 	Awb() = default;
 	~Awb() = default;
 
+	int init(IPAContext &context,
+		 const ValueNode &tuningData) override;
 	int configure(IPAContext &context, const IPAConfigInfo &configInfo) override;
+
+	void queueRequest(IPAContext &context,
+			  [[maybe_unused]] const uint32_t frame,
+			  IPAFrameContext &frameContext,
+			  const ControlList &controls) override;
 	void prepare(IPAContext &context,
 		     const uint32_t frame,
 		     IPAFrameContext &frameContext,
@@ -29,6 +47,17 @@ public:
 		     IPAFrameContext &frameContext,
 		     const SwIspStats *stats,
 		     ControlList &metadata) override;
+
+private:
+	SimpleAwbStats calculateRgbMeans(IPAContext &context,
+					 const SwIspStats *stats) const;
+
+	/*
+	 * There actually is no Q register format for SoftISP, but allow the
+	 * colour gains to range in the [0.0f, 15.999f] interval, which seems
+	 * reasonable.
+	 */
+	AwbAlgorithm<UQ<4, 8>> awbAlgo_;
 };
 
 } /* namespace ipa::soft::algorithms */
diff --git a/src/ipa/simple/algorithms/ccm.cpp b/src/ipa/simple/algorithms/ccm.cpp
index ace9c35dc462..ff37c718c6e4 100644
--- a/src/ipa/simple/algorithms/ccm.cpp
+++ b/src/ipa/simple/algorithms/ccm.cpp
@@ -44,7 +44,7 @@ int Ccm::init([[maybe_unused]] IPAContext &context, const ValueNode &tuningData)
 void Ccm::prepare(IPAContext &context, [[maybe_unused]] const uint32_t frame,
 		  IPAFrameContext &frameContext, [[maybe_unused]] DebayerParams *params)
 {
-	const unsigned int ct = context.activeState.awb.temperatureK;
+	const unsigned int ct = frameContext.awb.temperatureK;
 
 	/* Change CCM only on bigger temperature changes. */
 	if (!currentCcm_ ||
diff --git a/src/ipa/simple/ipa_context.h b/src/ipa/simple/ipa_context.h
index 8ccfacb46a59..0646f42d5618 100644
--- a/src/ipa/simple/ipa_context.h
+++ b/src/ipa/simple/ipa_context.h
@@ -16,6 +16,7 @@
 #include "libcamera/internal/matrix.h"
 #include "libcamera/internal/vector.h"
 
+#include <libipa/awb.h>
 #include <libipa/fc_queue.h>
 
 #include "core_ipa_interface.h"
@@ -25,6 +26,8 @@ namespace libcamera {
 namespace ipa::soft {
 
 struct IPASessionConfiguration {
+	ipa::awb::Session awb;
+
 	struct {
 		int32_t exposureMin, exposureMax;
 		double againMin, againMax, again10, againMinStep;
@@ -36,6 +39,8 @@ struct IPASessionConfiguration {
 };
 
 struct IPAActiveState {
+	ipa::awb::ActiveState awb;
+
 	struct {
 		int32_t exposure;
 		double again;
@@ -48,11 +53,6 @@ struct IPAActiveState {
 		double lastGain;
 	} blc;
 
-	struct {
-		RGB<float> gains;
-		unsigned int temperatureK;
-	} awb;
-
 	Matrix<float, 3, 3> combinedMatrix;
 
 	struct {
@@ -64,6 +64,8 @@ struct IPAActiveState {
 };
 
 struct IPAFrameContext : public FrameContext {
+	ipa::awb::FrameContext awb;
+
 	Matrix<float, 3, 3> ccm;
 
 	struct {
@@ -71,8 +73,6 @@ struct IPAFrameContext : public FrameContext {
 		double gain;
 	} sensor;
 
-	RGB<float> gains;
-
 	float gamma;
 	std::optional<float> contrast;
 	std::optional<float> saturation;
diff --git a/src/libcamera/software_isp/debayer_cpu.cpp b/src/libcamera/software_isp/debayer_cpu.cpp
index d2596d32bbcd..fc171f874833 100644
--- a/src/libcamera/software_isp/debayer_cpu.cpp
+++ b/src/libcamera/software_isp/debayer_cpu.cpp
@@ -1051,7 +1051,7 @@ void DebayerCpu::updateLookupTables(const DebayerParams &params)
 			auto &blue = swapRedBlueGains_ ? red_ : blue_;
 			for (unsigned int i = 0; i < kRGBLookupSize; i++) {
 				/* Apply gamma after gain! */
-				const RGB<float> lutGains = (gains * i / div).min(gammaTableSize - 1);
+				const RGB<double> lutGains = (gains * i / div).min(gammaTableSize - 1);
 				red[i] = gammaTable_[static_cast<unsigned int>(lutGains.r())];
 				green[i] = gammaTable_[static_cast<unsigned int>(lutGains.g())];
 				blue[i] = gammaTable_[static_cast<unsigned int>(lutGains.b())];
