[5/7] tests: Validate rendered crops in the integration suite
diff mbox series

Message ID 20261016202916.1405813-6-halfline@gmail.com
State New
Headers show
Series
  • tests: Separate suites and check software ISP crops
Related show

Commit Message

Ray Strode Oct. 16, 2026, 8:29 p.m. UTC
From: Ray Strode <rstrode@redhat.com>

This commit checks rendered software ISP pixels in the opt-in integration
test suite.

The geometry unit tests check crop calculations. They do not exercise
shader uniforms, the viewport or rasterization. The EGL drawing interface
allows those parts of the production path to run with CPU-uploaded textures
and pixel readback, including on software renderers without DMA-BUF
support.

The test checks centered horizontal and vertical stripes and compares
resized output with a centered crop of the native render. Cases cover two
Bayer patterns in 8-bit and packed RAW10 formats at four output sizes.

The llvmpipe test setup selects surfaceless Mesa software rendering. An
unavailable EGL display fails this setup so CI cannot silently skip the
rendering coverage. The normal setup skips when no display is available.

Signed-off-by: Ray Strode <rstrode@redhat.com>
---
 test/integration/meson.build               |   2 +-
 test/integration/software_isp/gpu-crop.cpp | 222 +++++++++++++++++++++
 test/integration/software_isp/meson.build  |  28 +++
 3 files changed, 251 insertions(+), 1 deletion(-)
 create mode 100644 test/integration/software_isp/gpu-crop.cpp
 create mode 100644 test/integration/software_isp/meson.build

Patch
diff mbox series

diff --git a/test/integration/meson.build b/test/integration/meson.build
index 51e97101..8a293951 100644
--- a/test/integration/meson.build
+++ b/test/integration/meson.build
@@ -1,3 +1,3 @@ 
 # SPDX-License-Identifier: CC0-1.0
 
-# Integration test subdirectories register tests in the 'integration' suite.
+subdir('software_isp')
diff --git a/test/integration/software_isp/gpu-crop.cpp b/test/integration/software_isp/gpu-crop.cpp
new file mode 100644
index 00000000..c327f3a9
--- /dev/null
+++ b/test/integration/software_isp/gpu-crop.cpp
@@ -0,0 +1,222 @@ 
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ * Copyright (C) 2026, Red Hat Inc.
+ *
+ * Software ISP rendered crop tests
+ */
+
+#include <algorithm>
+#include <cmath>
+#include <iostream>
+#include <memory>
+#include <stdint.h>
+#include <tuple>
+#include <utility>
+#include <vector>
+
+#include <libcamera/base/utils.h>
+
+#include <libcamera/camera_manager.h>
+#include <libcamera/formats.h>
+#include <libcamera/geometry.h>
+#include <libcamera/stream.h>
+
+#include "libcamera/internal/egl.h"
+#include "libcamera/internal/software_isp/debayer_params.h"
+#include "libcamera/internal/software_isp/swstats_cpu.h"
+
+#include <EGL/egl.h>
+#include <GLES2/gl2.h>
+
+#include "debayer_egl.h"
+#include "test.h"
+
+using namespace libcamera;
+
+class TextureDebayer : public DebayerEGL
+{
+public:
+	using DebayerEGL::DebayerEGL;
+	using DebayerEGL::render;
+};
+
+class GpuCropTest : public Test
+{
+protected:
+	int init() override
+	{
+		display_ = eGL::probeDisplay();
+		if (display_ == EGL_NO_DISPLAY) {
+			std::cout << "No surfaceless EGL display available" << std::endl;
+			return utils::secure_getenv("LIBCAMERA_TEST_REQUIRE_EGL") ? TestFail : TestSkip;
+		}
+
+		return TestPass;
+	}
+
+	int run() override
+	{
+		CameraManager manager;
+		for (PixelFormat format : { formats::SBGGR8, formats::SGRBG8,
+					    formats::SBGGR10_CSI2P, formats::SGRBG10_CSI2P }) {
+			for (bool vertical : { true, false }) {
+				std::vector<uint8_t> reference;
+				Size native;
+				for (Size size : { Size(1920, 1080), Size(1280, 720),
+						   Size(640, 480), Size(512, 768) }) {
+					std::vector<uint8_t> image;
+					if (render(manager, format, size, vertical, &image, &native))
+						return TestFail;
+
+					if (!checkCenter(image, size, vertical)) {
+						std::cerr << format << " " << size
+							  << " stripe is not centered" << std::endl;
+						return TestFail;
+					}
+
+					if (reference.empty()) {
+						if (size != native)
+							return TestFail;
+						reference = std::move(image);
+						continue;
+					}
+
+					/* Compare with a centered crop of the native render. */
+					double scale = std::max(double(size.width) / native.width,
+								double(size.height) / native.height);
+					double error = 0;
+					for (unsigned int y = 0; y < size.height; y++) {
+						double offsetY = (y + 0.5 - size.height / 2.0) / scale;
+						int ry = std::lround(native.height / 2.0 + offsetY - 0.5);
+						for (unsigned int x = 0; x < size.width; x++) {
+							double offsetX = (x + 0.5 - size.width / 2.0) / scale;
+							int rx = std::lround(native.width / 2.0 + offsetX - 0.5);
+							unsigned int actual = (y * size.width + x) * 4;
+							unsigned int expected = (ry * native.width + rx) * 4;
+							for (unsigned int c = 0; c < 3; c++)
+								error += std::abs(int(image[actual + c]) -
+										  reference[expected + c]);
+						}
+					}
+					error /= size.width * size.height * 3;
+					std::cout << format << " " << size << " "
+						  << (vertical ? "vertical" : "horizontal")
+						  << " stripe: mean pixel error " << error << std::endl;
+					/* Allow interpolation and rounding at stripe edges. */
+					if (error > 2.0)
+						return TestFail;
+				}
+			}
+		}
+
+		return TestPass;
+	}
+
+	void cleanup() override
+	{
+		eglTerminate(display_);
+	}
+
+private:
+	bool checkCenter(const std::vector<uint8_t> &image, const Size &size, bool vertical)
+	{
+		unsigned int length = vertical ? size.width : size.height;
+		double sum = 0;
+		unsigned int count = 0;
+		for (unsigned int i = 0; i < length; i++) {
+			unsigned int x = vertical ? i : size.width / 2;
+			unsigned int y = vertical ? size.height / 2 : i;
+			/* The achromatic scene is independent of channel ordering. */
+			if (image[(y * size.width + x) * 4 + 1] > 128) {
+				sum += i + 0.5;
+				count++;
+			}
+		}
+		if (!count)
+			return false;
+
+		double center = sum / count;
+		return std::abs(center - length / 2.0) <= 1.0;
+	}
+
+	bool createTexture(eGLImage &image, const void *data)
+	{
+		glActiveTexture(image.texture_unit_);
+		glBindTexture(GL_TEXTURE_2D, image.texture_);
+		glTexImage2D(GL_TEXTURE_2D, 0, image.format_, image.width_, image.height_,
+			     0, image.format_, GL_UNSIGNED_BYTE, data);
+		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+		return glGetError() == GL_NO_ERROR;
+	}
+
+	int render(CameraManager &manager, PixelFormat format, const Size &size,
+		   bool vertical, std::vector<uint8_t> *image, Size *native)
+	{
+		auto stats = std::make_unique<SwStatsCpu>(manager);
+		if (!stats->isValid())
+			return TestFail;
+
+		TextureDebayer debayer(std::move(stats), manager, display_);
+		bool packed = format == formats::SBGGR10_CSI2P || format == formats::SGRBG10_CSI2P;
+		StreamConfiguration input, output;
+		input.pixelFormat = format;
+		input.size = { packed ? 1928U : 1924U, 1080 };
+		input.stride = debayer.preferredInputStride(format, input.size);
+		input.bufferCount = 1;
+		output.pixelFormat = formats::XBGR8888;
+		output.size = size;
+		std::tie(output.stride, output.frameSize) =
+			debayer.strideAndFrameSize(output.pixelFormat, size);
+		output.bufferCount = 1;
+		*native = debayer.sizes(format, input.size).max;
+		if (debayer.configure(input, { output }, false))
+			return TestFail;
+
+		/* Destroy the textures before releasing their context. */
+		utils::scope_exit stop([&] { debayer.stop(); });
+		if (debayer.start())
+			return TestFail;
+
+		std::cout << "GL renderer: " << glGetString(GL_RENDERER) << std::endl;
+		std::vector<uint8_t> raw(input.stride * input.size.height, 0);
+		for (unsigned int y = 0; y < input.size.height; y++) {
+			for (unsigned int x = 0; x < input.size.width; x++) {
+				unsigned int coordinate = vertical ? x : y;
+				unsigned int middle = (vertical ? input.size.width : input.size.height) / 2;
+				bool bright = coordinate >= middle - 64 && coordinate < middle + 64;
+				unsigned int offset = packed ? x / 4 * 5 + x % 4 : x;
+				/* RAW10 stores the high eight bits before the group's low bits. */
+				raw[y * input.stride + offset] = bright ? 224 : 32;
+			}
+		}
+
+		eGLImage source(GL_LUMINANCE, input.stride, input.size.height,
+				input.stride, GL_TEXTURE0, 0);
+		eGLImage target(GL_RGBA, size.width, size.height, output.stride, GL_TEXTURE1, 1);
+		glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
+		if (!createTexture(source, raw.data()) || !createTexture(target, nullptr)) {
+			std::cerr << "Unable to allocate test textures" << std::endl;
+			return TestFail;
+		}
+
+		if (debayer.render(source, target, DebayerParams{}))
+			return TestFail;
+
+		image->resize(size.width * size.height * 4);
+		glPixelStorei(GL_PACK_ALIGNMENT, 1);
+		glReadPixels(0, 0, size.width, size.height, GL_RGBA, GL_UNSIGNED_BYTE, image->data());
+		if (glGetError() != GL_NO_ERROR) {
+			std::cerr << "Unable to read rendered pixels" << std::endl;
+			return TestFail;
+		}
+
+		return TestPass;
+	}
+
+	EGLDisplay display_ = EGL_NO_DISPLAY;
+};
+
+TEST_REGISTER(GpuCropTest)
diff --git a/test/integration/software_isp/meson.build b/test/integration/software_isp/meson.build
new file mode 100644
index 00000000..2ec17ea0
--- /dev/null
+++ b/test/integration/software_isp/meson.build
@@ -0,0 +1,28 @@ 
+# SPDX-License-Identifier: CC0-1.0
+
+if not softisp_enabled or not mesa_works
+    subdir_done()
+endif
+
+add_test_setup('llvmpipe', env : {
+    'EGL_PLATFORM': 'surfaceless',
+    'GALLIUM_DRIVER': 'llvmpipe',
+    'LIBGL_ALWAYS_SOFTWARE': '1',
+    'LIBCAMERA_TEST_REQUIRE_EGL': '1',
+})
+
+software_isp_integration_tests = [
+    {'name': 'software-isp-gpu-crop', 'sources': ['gpu-crop.cpp']},
+]
+
+foreach test : software_isp_integration_tests
+    exe = executable(test['name'], test['sources'],
+                     dependencies : [libcamera_private, libegl, libglesv2],
+                     implicit_include_directories : false,
+                     link_with : test_libraries,
+                     include_directories : [test_includes_internal,
+                                            '../../../src/libcamera/software_isp/'])
+
+    test(test['name'], exe, suite : ['integration', 'software_isp'],
+         timeout : 120)
+endforeach