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
