diff --git a/test/meson.build b/test/meson.build
index 644a8cba..e7510646 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -57,60 +57,63 @@ internal_tests = [
     {'name': 'event-dispatcher', 'sources': ['event-dispatcher.cpp']},
     {'name': 'event-thread', 'sources': ['event-thread.cpp']},
     {'name': 'file', 'sources': ['file.cpp']},
     {'name': 'flags', 'sources': ['flags.cpp']},
     {'name': 'hotplug-cameras', 'sources': ['hotplug-cameras.cpp']},
     {'name': 'matrix', 'sources': ['matrix.cpp']},
     {'name': 'message', 'sources': ['message.cpp']},
     {'name': 'object', 'sources': ['object.cpp']},
     {'name': 'object-delete', 'sources': ['object-delete.cpp']},
     {'name': 'object-invoke', 'sources': ['object-invoke.cpp']},
     {'name': 'pixel-format', 'sources': ['pixel-format.cpp']},
     {'name': 'shared-fd', 'sources': ['shared-fd.cpp']},
     {'name': 'signal-threads', 'sources': ['signal-threads.cpp']},
     {'name': 'threads', 'sources': 'threads.cpp', 'dependencies': [libthreads]},
     {'name': 'timer', 'sources': ['timer.cpp']},
     {'name': 'timer-fail', 'sources': ['timer-fail.cpp'], 'should_fail': true},
     {'name': 'timer-thread', 'sources': ['timer-thread.cpp']},
     {'name': 'unique-fd', 'sources': ['unique-fd.cpp']},
     {'name': 'utils', 'sources': ['utils.cpp']},
     {'name': 'value-node', 'sources': ['value-node.cpp']},
     {'name': 'vector', 'sources': ['vector.cpp']},
     {'name': 'yaml-parser', 'sources': ['yaml-parser.cpp']},
 ]
 
 if softisp_enabled and mesa_works
     softisp_gpu_includes = include_directories('../src/libcamera/software_isp')
     internal_tests += [
         {'name': 'software-isp-gpu-crop', 'sources': ['software-isp-gpu-crop.cpp'],
          'dependencies': [libegl, libglesv2],
          'include_directories': softisp_gpu_includes},
+        {'name': 'software-isp-gpu-stats', 'sources': ['software-isp-gpu-stats.cpp'],
+         'dependencies': [libegl, libglesv2],
+         'include_directories': softisp_gpu_includes},
     ]
 endif
 
 internal_non_parallel_tests = [
     {'name': 'fence', 'sources': ['fence.cpp']},
     {'name': 'mapped-buffer', 'sources': ['mapped-buffer.cpp']},
 ]
 
 foreach test : public_tests
     deps = [libcamera_public]
     if 'dependencies' in test
         deps += test['dependencies']
     endif
 
     exe = executable(test['name'], test['sources'],
                      dependencies : deps,
                      implicit_include_directories : false,
                      link_with : test_libraries,
                      include_directories : test_includes_public)
 
     test(test['name'], exe, should_fail : test.get('should_fail', false))
 endforeach
 
 foreach test : internal_tests
     deps = [libcamera_private]
     if 'dependencies' in test
         deps += test['dependencies']
     endif
 
     includes = [test_includes_internal]
diff --git a/test/software-isp-gpu-stats.cpp b/test/software-isp-gpu-stats.cpp
new file mode 100644
index 00000000..2575c1c4
--- /dev/null
+++ b/test/software-isp-gpu-stats.cpp
@@ -0,0 +1,188 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+
+#include <algorithm>
+#include <cmath>
+#include <iostream>
+#include <memory>
+#include <numeric>
+#include <stdint.h>
+#include <sys/mman.h>
+#include <vector>
+
+#include <libcamera/camera_manager.h>
+#include <libcamera/formats.h>
+#include <libcamera/stream.h>
+
+#include "libcamera/internal/mapped_framebuffer.h"
+#include "libcamera/internal/shared_mem_object.h"
+
+#include "debayer_egl.h"
+#include "test.h"
+
+using namespace libcamera;
+
+class GpuStatisticsTest : public Test
+{
+protected:
+	int run() override
+	{
+		CameraManager manager;
+		/* configure() stores the display without creating a rendering context. */
+		EGLDisplay display = eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA,
+							  EGL_DEFAULT_DISPLAY, nullptr);
+		if (display == EGL_NO_DISPLAY)
+			return TestSkip;
+		for (PixelFormat format : { formats::SBGGR8, formats::SGRBG8,
+					   formats::SBGGR10_CSI2P, formats::SGRBG10_CSI2P }) {
+			bool packed = format == formats::SBGGR10_CSI2P || format == formats::SGRBG10_CSI2P;
+			bool bggr = format == formats::SBGGR8 || format == formats::SBGGR10_CSI2P;
+			for (Size outputSize : { Size(1280, 720), Size(640, 480), Size(1920, 1080) }) {
+				auto statistics = std::make_unique<SwStatsCpu>(manager);
+				SwStatsCpu *stats = statistics.get();
+				if (!stats->isValid())
+					return TestFail;
+				DebayerEGL debayer(std::move(statistics), manager, display);
+				StreamConfiguration input, output;
+				input.pixelFormat = format;
+				input.size = { 1928, 1092 };
+				input.stride = debayer.preferredInputStride(format, input.size);
+				input.bufferCount = 4;
+				output.pixelFormat = formats::XBGR8888;
+				output.size = outputSize;
+				output.stride = output.size.width * 4;
+				output.bufferCount = 4;
+				if (debayer.configure(input, { output }, false))
+					return TestFail;
+
+				auto *shared = static_cast<const SwIspStats *>(mmap(nullptr, sizeof(SwIspStats),
+					PROT_READ, MAP_SHARED, stats->getStatsFD().get(), 0));
+				if (shared == MAP_FAILED)
+					return TestFail;
+
+				SharedMem raw("gpu-statistics-test", input.stride * input.size.height);
+				if (!raw) {
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+				std::fill(raw.mem().begin(), raw.mem().end(), 0);
+				FrameBuffer::Plane plane;
+				plane.fd = raw.fd();
+				plane.offset = 0;
+				plane.length = raw.mem().size();
+				std::vector<FrameBuffer::Plane> planes = { plane };
+				FrameBuffer buffer(planes);
+				MappedFrameBuffer mapped(&buffer, MappedFrameBuffer::MapFlag::Read);
+				if (!mapped.isValid()) {
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+
+				/* Meter the original input buffer, as the GPU debayer does. */
+				auto measure = [&](const Rectangle &target) {
+					auto *pixels = raw.mem().data();
+					for (unsigned int y = 0; y < input.size.height; y++) {
+						for (unsigned int x = 0; x < input.size.width; x++) {
+							bool green = ((x + y) & 1) == (bggr ? 1 : 0);
+							bool white = x >= static_cast<unsigned int>(target.x) &&
+								     x < target.x + target.width &&
+								     y >= static_cast<unsigned int>(target.y) &&
+								     y < target.y + target.height;
+							unsigned int offset = packed ? x / 4 * 5 + x % 4 : x;
+							/* Packed RAW10 statistics use the eight high bits. */
+							pixels[y * input.stride + offset] = green || white ? 150 : 75;
+						}
+					}
+					stats->processFrame(0, 0, mapped);
+					return *shared;
+				};
+
+				/*
+				 * A 4:3 output sees 1456 sensor columns. A 16:9 output
+				 * sees all native columns, excluding four/eight border
+				 * columns for 8-bit/packed RAW10. The 8-bit top edge at
+				 * row 5 is rounded down to row 4 by the Bayer alignment.
+				 */
+				Rectangle visible = outputSize == Size(640, 480)
+					? Rectangle(236, 0, 1456, 1092)
+					: packed ? Rectangle(4, 6, 1920, 1080)
+						 : Rectangle(2, 4, 1924, 1082);
+				uint64_t expectedSamples = uint64_t((visible.width + 3) / 4) *
+							  ((visible.height + 3) / 4);
+				SwIspStats background = measure({});
+				uint64_t samples = std::accumulate(background.yHistogram.begin(),
+								 background.yHistogram.end(), uint64_t{ 0 });
+				if (!background.valid || !samples || samples != expectedSamples ||
+				    !background.sum_.r() ||
+				    !background.sum_.g() || !background.sum_.b()) {
+					std::cerr << "Invalid background statistics: " << samples
+						  << " samples, expected " << expectedSamples << std::endl;
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+
+				SwIspStats left = measure({ 418, 0, 128, input.size.height });
+				SwIspStats right = measure({ 1382, 0, 128, input.size.height });
+				if (!left.valid || !right.valid || !left.sum_.g() || !right.sum_.g() ||
+				    left.sum_.r() <= background.sum_.r() ||
+				    right.sum_.r() <= background.sum_.r() ||
+				    left.yHistogram == background.yHistogram ||
+				    right.yHistogram == background.yHistogram) {
+					std::cerr << "No response to pixels inside the visible crop" << std::endl;
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+
+				double leftRatio = double(left.sum_.r()) / left.sum_.g();
+				double rightRatio = double(right.sum_.r()) / right.sum_.g();
+				std::cout << format << " " << outputSize << " left R/G=" << leftRatio
+					  << " right R/G=" << rightRatio << std::endl;
+				if (!std::isfinite(leftRatio) || !std::isfinite(rightRatio) ||
+				    std::abs(leftRatio - rightRatio) > 0.001 ||
+				    left.sum_.g() != right.sum_.g() ||
+				    left.sum_.b() != right.sum_.b() ||
+				    left.yHistogram != right.yHistogram) {
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+
+				unsigned int x = visible.x;
+				unsigned int y = visible.y;
+				unsigned int rightEdge = x + visible.width;
+				unsigned int bottomEdge = y + visible.height;
+				for (Rectangle target : { Rectangle(x, y, 8, visible.height),
+							  Rectangle(rightEdge - 8, y, 8, visible.height),
+							  Rectangle(x, y, visible.width, 4),
+							  Rectangle(x, bottomEdge - 4, visible.width, 4) }) {
+					SwIspStats inside = measure(target);
+					if (!inside.valid || inside.sum_.r() <= background.sum_.r() ||
+					    inside.sum_.b() <= background.sum_.b() ||
+					    inside.yHistogram == background.yHistogram) {
+						std::cerr << "No response inside crop edge " << target << std::endl;
+						munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+						return TestFail;
+					}
+				}
+				for (Rectangle target : { Rectangle(0, 0, x, input.size.height),
+							  Rectangle(rightEdge, 0, input.size.width - rightEdge,
+								    input.size.height),
+							  Rectangle(0, 0, input.size.width, y),
+							  Rectangle(0, bottomEdge, input.size.width,
+								    input.size.height - bottomEdge) }) {
+					if (!target.width || !target.height)
+						continue;
+					SwIspStats outside = measure(target);
+					if (!outside.valid || background.sum_ != outside.sum_ ||
+					    background.yHistogram != outside.yHistogram) {
+						std::cerr << "Metered pixels outside crop edge " << target << std::endl;
+						munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+						return TestFail;
+					}
+				}
+				munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+			}
+		}
+		return TestPass;
+	}
+};
+
+TEST_REGISTER(GpuStatisticsTest)
