diff --git a/src/ipa/rpi/controller/rpi/hdr.cpp b/src/ipa/rpi/controller/rpi/hdr.cpp
index 295e4c5f1c0a..fb580548d068 100644
--- a/src/ipa/rpi/controller/rpi/hdr.cpp
+++ b/src/ipa/rpi/controller/rpi/hdr.cpp
@@ -10,6 +10,7 @@
 #include <libcamera/base/log.h>
 
 #include "../agc_status.h"
+#include "../alsc_status.h"
 #include "../stitch_status.h"
 #include "../tonemap_status.h"
 
@@ -37,29 +38,26 @@ void HdrConfig::read(const libcamera::YamlObject &params, const std::string &mod
 	for (const auto &[k, v] : params["channel_map"].asDict())
 		channelMap[v.get<unsigned int>().value()] = k;
 
+	/* Lens shading related parameters. */
+	if (params.contains("spatial_gain")) {
+		spatialGain.read(params["spatial_gain"]);
+		diffusion = params["diffusion"].get<unsigned int>(3);
+		/* Clip to an arbitrary limit just to stop typos from killing the system! */
+		const unsigned int MAX_DIFFUSION = 15;
+		if (diffusion > MAX_DIFFUSION) {
+			diffusion = MAX_DIFFUSION;
+			LOG(RPiHdr, Warning) << "Diffusion value clipped to " << MAX_DIFFUSION;
+		}
+	}
+
 	/* Read any tonemap parameters. */
 	tonemapEnable = params["tonemap_enable"].get<int>(0);
 	detailConstant = params["detail_constant"].get<uint16_t>(50);
 	detailSlope = params["detail_slope"].get<double>(8.0);
 	iirStrength = params["iir_strength"].get<double>(8.0);
 	strength = params["strength"].get<double>(1.5);
-
-	if (tonemapEnable) {
-		/* We need either an explicit tonemap, or the information to build them dynamically. */
-		if (params.contains("tonemap")) {
-			if (tonemap.read(params["tonemap"]))
-				LOG(RPiHdr, Fatal) << "Failed to read tonemap in HDR mode " << name;
-		} else {
-			if (target.read(params["target"]))
-				LOG(RPiHdr, Fatal) << "Failed to read target in HDR mode " << name;
-			if (maxSlope.read(params["max_slope"]))
-				LOG(RPiHdr, Fatal) << "Failed to read max_slope in HDR mode " << name;
-			minSlope = params["min_slope"].get<double>(1.0);
-			maxGain = params["max_gain"].get<double>(64.0);
-			step = params["step"].get<double>(0.05);
-			speed = params["speed"].get<double>(0.5);
-		}
-	}
+	if (tonemapEnable)
+		tonemap.read(params["tonemap"]);
 
 	/* Read any stitch parameters. */
 	stitchEnable = params["stitch_enable"].get<int>(0);
@@ -73,6 +71,10 @@ void HdrConfig::read(const libcamera::YamlObject &params, const std::string &mod
 Hdr::Hdr(Controller *controller)
 	: HdrAlgorithm(controller)
 {
+	regions_ = controller->getHardwareConfig().awbRegions;
+	numRegions_ = regions_.width * regions_.height;
+	gains_[0].resize(numRegions_, 1.0);
+	gains_[1].resize(numRegions_, 1.0);
 }
 
 char const *Hdr::name() const
@@ -143,7 +145,40 @@ void Hdr::switchMode([[maybe_unused]] CameraMode const &cameraMode, Metadata *me
 	delayedStatus_ = status_;
 }
 
-bool Hdr::updateTonemap(StatisticsPtr &stats, HdrConfig &config)
+void Hdr::prepare(Metadata *imageMetadata)
+{
+	AgcStatus agcStatus;
+	if (!imageMetadata->get<AgcStatus>("agc.delayed_status", agcStatus))
+		delayedStatus_ = agcStatus.hdr;
+
+	auto it = config_.find(delayedStatus_.mode);
+	if (it == config_.end()) {
+		/* Shouldn't be possible. There would be nothing we could do. */
+		LOG(RPiHdr, Warning) << "Unexpected HDR mode " << delayedStatus_.mode;
+		return;
+	}
+
+	HdrConfig &config = it->second;
+	if (config.spatialGain.empty())
+		return;
+
+	AlscStatus alscStatus{}; /* some compilers seem to require the braces */
+	if (imageMetadata->get<AlscStatus>("alsc.status", alscStatus)) {
+		LOG(RPiHdr, Warning) << "No ALSC status";
+		return;
+	}
+
+	/* The final gains ended up in the odd or even array, according to diffusion. */
+	std::vector<double> &gains = gains_[config.diffusion & 1];
+	for (unsigned int i = 0; i < numRegions_; i++) {
+		alscStatus.r[i] *= gains[i];
+		alscStatus.g[i] *= gains[i];
+		alscStatus.b[i] *= gains[i];
+	}
+	imageMetadata->set("alsc.status", alscStatus);
+}
+
+bool Hdr::updateTonemap([[maybe_unused]] StatisticsPtr &stats, HdrConfig &config)
 {
 	/* When there's a change of HDR mode we start over with a new tonemap curve. */
 	if (delayedStatus_.mode != previousMode_) {
@@ -162,56 +197,85 @@ bool Hdr::updateTonemap(StatisticsPtr &stats, HdrConfig &config)
 	}
 
 	/*
-	 * We only update the tonemap on short frames when in multi-exposure mode. But
+	 * We wouldn't update the tonemap on short frames when in multi-exposure mode. But
 	 * we still need to output the most recent tonemap. Possibly we should make the
 	 * config indicate the channels for which we should update the tonemap?
 	 */
 	if (delayedStatus_.mode == "MultiExposure" && delayedStatus_.channel != "short")
 		return true;
 
-	/* Build the tonemap dynamically using the image histogram. */
-	Pwl tonemap;
-	tonemap.append(0, 0);
-
-	double prev_input_val = 0;
-	double prev_output_val = 0;
-	const double step2 = config.step / 2;
-	for (double q = config.step; q < 1.0 - step2; q += config.step) {
-		double q_lo = std::max(0.0, q - step2);
-		double q_hi = std::min(1.0, q + step2);
-		double iqm = stats->yHist.interQuantileMean(q_lo, q_hi);
-		double input_val = std::min(iqm * 64, 65535.0);
-
-		if (input_val > prev_input_val + 1) {
-			/* We're going to calcualte a Pwl to map input_val to this output_val. */
-			double want_output_val = config.target.eval(q) * 65535;
-			/* But we must ensure we aren't applying too small or too great a local gain. */
-			double want_slope = (want_output_val - prev_output_val) / (input_val - prev_input_val);
-			double slope = std::clamp(want_slope, config.minSlope,
-						  config.maxSlope.eval(q));
-			double output_val = prev_output_val + slope * (input_val - prev_input_val);
-			output_val = std::min(output_val, config.maxGain * input_val);
-			output_val = std::clamp(output_val, 0.0, 65535.0);
-			/* Let the tonemap adapte slightly more gently from frame to frame. */
-			if (!tonemap_.empty()) {
-				double old_output_val = tonemap_.eval(input_val);
-				output_val = config.speed * output_val +
-					     (1 - config.speed) * old_output_val;
-			}
-			LOG(RPiHdr, Debug) << "q " << q << " input " << input_val
-					   << " output " << want_output_val << " slope " << want_slope
-					   << " slope " << slope << " output " << output_val;
-			tonemap.append(input_val, output_val);
-			prev_input_val = input_val;
-			prev_output_val = output_val;
+	/*
+	 * If we wanted to build or adjust tonemaps dynamically, this would be the place
+	 * to do it. But for now we seem to be getting by without.
+	 */
+
+	return true;
+}
+
+static void averageGains(std::vector<double> &src, std::vector<double> &dst, const Size &size)
+{
+#define IDX(y, x) ((y)*size.width + (x))
+	unsigned int lastCol = size.width - 1; /* index of last column */
+	unsigned int preLastCol = lastCol - 1; /* and the column before that */
+	unsigned int lastRow = size.height - 1; /* index of last row */
+	unsigned int preLastRow = lastRow - 1; /* and the row before that */
+
+	/* Corners first. */
+	dst[IDX(0, 0)] = (src[IDX(0, 0)] + src[IDX(0, 1)] + src[IDX(1, 0)]) / 3;
+	dst[IDX(0, lastCol)] = (src[IDX(0, lastCol)] + src[IDX(0, preLastCol)] + src[IDX(1, lastCol)]) / 3;
+	dst[IDX(lastRow, 0)] = (src[IDX(lastRow, 0)] + src[IDX(lastRow, 1)] + src[IDX(preLastRow, 0)]) / 3;
+	dst[IDX(lastRow, lastCol)] = (src[IDX(lastRow, lastCol)] + src[IDX(lastRow, preLastCol)] +
+				      src[IDX(preLastRow, lastCol)]) /
+				     3;
+
+	/* Now the edges. */
+	for (unsigned int i = 1; i < lastCol; i++) {
+		dst[IDX(0, i)] = (src[IDX(0, i - 1)] + src[IDX(0, i)] + src[IDX(0, i + 1)] + src[IDX(1, i)]) / 4;
+		dst[IDX(lastRow, i)] = (src[IDX(lastRow, i - 1)] + src[IDX(lastRow, i)] +
+					src[IDX(lastRow, i + 1)] + src[IDX(preLastRow, i)]) /
+				       4;
+	}
+
+	for (unsigned int i = 1; i < lastRow; i++) {
+		dst[IDX(i, 0)] = (src[IDX(i - 1, 0)] + src[IDX(i, 0)] + src[IDX(i + 1, 0)] + src[IDX(i, 1)]) / 4;
+		dst[IDX(i, 31)] = (src[IDX(i - 1, lastCol)] + src[IDX(i, lastCol)] +
+				   src[IDX(i + 1, lastCol)] + src[IDX(i, preLastCol)]) /
+				  4;
+	}
+
+	/* Finally the interior. */
+	for (unsigned int j = 1; j < lastRow; j++) {
+		for (unsigned int i = 1; i < lastCol; i++) {
+			dst[IDX(j, i)] = (src[IDX(j - 1, i)] + src[IDX(j, i - 1)] + src[IDX(j, i)] +
+					  src[IDX(j, i + 1)] + src[IDX(j + 1, i)]) /
+					 5;
 		}
 	}
+}
 
-	tonemap.append(65535, 65535);
-	/* tonemap.debug(); */
-	tonemap_ = tonemap;
+void Hdr::updateGains(StatisticsPtr &stats, HdrConfig &config)
+{
+	if (config.spatialGain.empty())
+		return;
 
-	return true;
+	/* When alternating exposures, only compute these gains for the short frame. */
+	if (delayedStatus_.mode == "MultiExposure" && delayedStatus_.channel != "short")
+		return;
+
+	for (unsigned int i = 0; i < numRegions_; i++) {
+		auto &region = stats->awbRegions.get(i);
+		unsigned int counted = region.counted;
+		counted += (counted == 0); /* avoid div by zero */
+		double r = region.val.rSum / counted;
+		double g = region.val.gSum / counted;
+		double b = region.val.bSum / counted;
+		double brightness = std::max({ r, g, b }) / 65535;
+		gains_[0][i] = config.spatialGain.eval(brightness);
+	}
+
+	/* Ping-pong between the two gains_ buffers. */
+	for (unsigned int i = 0; i < config.diffusion; i++)
+		averageGains(gains_[i & 1], gains_[(i & 1) ^ 1], regions_);
 }
 
 void Hdr::process(StatisticsPtr &stats, Metadata *imageMetadata)
@@ -237,6 +301,9 @@ void Hdr::process(StatisticsPtr &stats, Metadata *imageMetadata)
 
 	HdrConfig &config = it->second;
 
+	/* Update the spatially varying gains. They get written in prepare(). */
+	updateGains(stats, config);
+
 	if (updateTonemap(stats, config)) {
 		/* Add tonemap.status metadata. */
 		TonemapStatus tonemapStatus;
diff --git a/src/ipa/rpi/controller/rpi/hdr.h b/src/ipa/rpi/controller/rpi/hdr.h
index 01ba45f1d3dc..980aa3d1850d 100644
--- a/src/ipa/rpi/controller/rpi/hdr.h
+++ b/src/ipa/rpi/controller/rpi/hdr.h
@@ -10,6 +10,8 @@
 #include <string>
 #include <vector>
 
+#include <libcamera/geometry.h>
+
 #include "../hdr_algorithm.h"
 #include "../hdr_status.h"
 #include "../pwl.h"
@@ -23,20 +25,17 @@ struct HdrConfig {
 	std::vector<unsigned int> cadence;
 	std::map<unsigned int, std::string> channelMap;
 
+	/* Lens shading related parameters. */
+	Pwl spatialGain; /* Brightness to gain curve for different image regions. */
+	unsigned int diffusion; /* How much to diffuse the gain spatially. */
+
 	/* Tonemap related parameters. */
 	bool tonemapEnable;
 	uint16_t detailConstant;
 	double detailSlope;
 	double iirStrength;
 	double strength;
-	/* We must have either an explicit tonemap curve, or the other parameters. */
 	Pwl tonemap;
-	Pwl target; /* maps histogram quatile to desired target output value */
-	Pwl maxSlope; /* the maximum slope allowed at each point in the mapping */
-	double minSlope; /* the minimum allowed slope */
-	double maxGain; /* limit to the max absolute gain */
-	double step; /* the histogram granularity for building the mapping */
-	double speed; /* rate at which tonemap is updated */
 
 	/* Stitch related parameters. */
 	bool stitchEnable;
@@ -54,12 +53,14 @@ public:
 	char const *name() const override;
 	void switchMode(CameraMode const &cameraMode, Metadata *metadata) override;
 	int read(const libcamera::YamlObject &params) override;
+	void prepare(Metadata *imageMetadata) override;
 	void process(StatisticsPtr &stats, Metadata *imageMetadata) override;
 	int setMode(std::string const &mode) override;
 	std::vector<unsigned int> getChannels() const override;
 
 private:
 	void updateAgcStatus(Metadata *metadata);
+	void updateGains(StatisticsPtr &stats, HdrConfig &config);
 	bool updateTonemap(StatisticsPtr &stats, HdrConfig &config);
 
 	std::map<std::string, HdrConfig> config_;
@@ -67,6 +68,9 @@ private:
 	HdrStatus delayedStatus_; /* track the delayed HDR mode and channel */
 	std::string previousMode_;
 	Pwl tonemap_;
+	libcamera::Size regions_; /* stats regions */
+	unsigned int numRegions_; /* total number of stats regions */
+	std::vector<double> gains_[2];
 };
 
 } /* namespace RPiController */
