@@ -1,3 +1,3 @@
# SPDX-License-Identifier: CC0-1.0
-# Integration test subdirectories register tests in the 'integration' suite.
+subdir('software_isp')
new file mode 100644
@@ -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)
new file mode 100644
@@ -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