new file mode 100644
@@ -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)
@@ -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