diff --git a/test/integration/software_isp/gpu-stats.cpp b/test/integration/software_isp/gpu-stats.cpp
new file mode 100644
index 00000000..617f9836
--- /dev/null
+++ b/test/integration/software_isp/gpu-stats.cpp
@@ -0,0 +1,208 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ * Copyright (C) 2026, Red Hat Inc.
+ *
+ * Software ISP statistics crop integration tests
+ */
+
+#include <algorithm>
+#include <cmath>
+#include <iostream>
+#include <memory>
+#include <numeric>
+#include <stdint.h>
+#include <sys/mman.h>
+#include <utility>
+#include <vector>
+
+#include <libcamera/base/utils.h>
+
+#include <libcamera/camera_manager.h>
+#include <libcamera/formats.h>
+#include <libcamera/framebuffer.h>
+#include <libcamera/geometry.h>
+#include <libcamera/stream.h>
+
+#include "libcamera/internal/mapped_framebuffer.h"
+#include "libcamera/internal/shared_mem_object.h"
+#include "libcamera/internal/software_isp/swisp_stats.h"
+#include "libcamera/internal/software_isp/swstats_cpu.h"
+
+#include <EGL/egl.h>
+#include <EGL/eglext.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 utils::secure_getenv("LIBCAMERA_TEST_REQUIRE_EGL") ? TestFail : 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;
+
+				void *mapping = mmap(nullptr, sizeof(SwIspStats), PROT_READ, MAP_SHARED,
+						     stats->getStatsFD().get(), 0);
+				if (mapping == MAP_FAILED)
+					return TestFail;
+				utils::scope_exit unmap([&] {
+					munmap(mapping, sizeof(SwIspStats));
+				});
+				const auto *shared = static_cast<const SwIspStats *>(mapping);
+
+				SharedMem raw("gpu-statistics-test", input.stride * input.size.height);
+				if (!raw)
+					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())
+					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;
+				if (outputSize == Size(640, 480))
+					visible = { 236, 0, 1456, 1092 };
+				else if (packed)
+					visible = { 4, 6, 1920, 1080 };
+				else
+					visible = { 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;
+
+					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;
+
+					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) {
+					return TestFail;
+				}
+
+				int x = visible.x;
+				int y = visible.y;
+				int rightEdge = x + visible.width;
+				int bottomEdge = y + visible.height;
+				const Rectangle insideRegions[] = {
+					{ x, y, 8, visible.height },
+					{ rightEdge - 8, y, 8, visible.height },
+					{ x, y, visible.width, 4 },
+					{ x, bottomEdge - 4, visible.width, 4 },
+				};
+				for (Rectangle target : insideRegions) {
+					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;
+
+						return TestFail;
+					}
+				}
+				const Rectangle outsideRegions[] = {
+					{ 0, 0, static_cast<unsigned int>(x), input.size.height },
+					{ rightEdge, 0, input.size.width - rightEdge, input.size.height },
+					{ 0, 0, input.size.width, static_cast<unsigned int>(y) },
+					{ 0, bottomEdge, input.size.width, input.size.height - bottomEdge },
+				};
+				for (Rectangle target : outsideRegions) {
+					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;
+
+						return TestFail;
+					}
+				}
+			}
+		}
+		return TestPass;
+	}
+};
+
+TEST_REGISTER(GpuStatisticsTest)
diff --git a/test/integration/software_isp/meson.build b/test/integration/software_isp/meson.build
index 2ec17ea0..0d75f9b7 100644
--- a/test/integration/software_isp/meson.build
+++ b/test/integration/software_isp/meson.build
@@ -13,6 +13,7 @@ add_test_setup('llvmpipe', env : {
 
 software_isp_integration_tests = [
     {'name': 'software-isp-gpu-crop', 'sources': ['gpu-crop.cpp']},
+    {'name': 'software-isp-gpu-stats', 'sources': ['gpu-stats.cpp']},
 ]
 
 foreach test : software_isp_integration_tests
