[2/4] tests: Validate rendered GPU crop across output sizes
diff mbox series

Message ID 20261015194335.287960-3-halfline@gmail.com
State Superseded
Headers show
Series
  • software-isp: Align GPU cropping and statistics
Related show

Commit Message

Ray Strode Oct. 15, 2026, 7:43 p.m. UTC
From: Ray Strode <rstrode@redhat.com>

The GPU debayer path centers the sensor image when output dimensions or
aspect ratios change.

The project has no test that inspects rendered pixels, so a projection
error can shift the visible image without failing an automated check.

This commit renders centered synthetic stripes through the GPU path and
compares each output against a centered crop of the native image. It
covers horizontal and vertical positioning, four Bayer formats, and
four output sizes.

Signed-off-by: Ray Strode <rstrode@redhat.com>
---
 test/meson.build               |  16 ++-
 test/software-isp-gpu-crop.cpp | 203 +++++++++++++++++++++++++++++++++
 2 files changed, 218 insertions(+), 1 deletion(-)
 create mode 100644 test/software-isp-gpu-crop.cpp

Comments

Laurent Pinchart Oct. 15, 2026, 8:16 p.m. UTC | #1
On Thu, Oct 15, 2026 at 03:43:33PM -0400, Ray Strode wrote:
> From: Ray Strode <rstrode@redhat.com>
> 
> The GPU debayer path centers the sensor image when output dimensions or
> aspect ratios change.
> 
> The project has no test that inspects rendered pixels, so a projection
> error can shift the visible image without failing an automated check.
> 
> This commit renders centered synthetic stripes through the GPU path and
> compares each output against a centered crop of the native image. It
> covers horizontal and vertical positioning, four Bayer formats, and
> four output sizes.

Doesn't qualify as a unit test.

> Signed-off-by: Ray Strode <rstrode@redhat.com>
> ---
>  test/meson.build               |  16 ++-
>  test/software-isp-gpu-crop.cpp | 203 +++++++++++++++++++++++++++++++++
>  2 files changed, 218 insertions(+), 1 deletion(-)
>  create mode 100644 test/software-isp-gpu-crop.cpp
> 
> diff --git a/test/meson.build b/test/meson.build
> index 363a2829..644a8cba 100644
> --- a/test/meson.build
> +++ b/test/meson.build
> @@ -51,81 +51,95 @@ public_tests = [
>  internal_tests = [
>      {'name': 'bayer-format', 'sources': ['bayer-format.cpp']},
>      {'name': 'byte-stream-buffer', 'sources': ['byte-stream-buffer.cpp']},
>      {'name': 'camera-sensor', 'sources': ['camera-sensor.cpp']},
>      {'name': 'delayed_controls', 'sources': ['delayed_controls.cpp']},
>      {'name': 'event', 'sources': ['event.cpp']},
>      {'name': 'event-dispatcher', 'sources': ['event-dispatcher.cpp']},
>      {'name': 'event-thread', 'sources': ['event-thread.cpp']},
>      {'name': 'file', 'sources': ['file.cpp']},
>      {'name': 'flags', 'sources': ['flags.cpp']},
>      {'name': 'hotplug-cameras', 'sources': ['hotplug-cameras.cpp']},
>      {'name': 'matrix', 'sources': ['matrix.cpp']},
>      {'name': 'message', 'sources': ['message.cpp']},
>      {'name': 'object', 'sources': ['object.cpp']},
>      {'name': 'object-delete', 'sources': ['object-delete.cpp']},
>      {'name': 'object-invoke', 'sources': ['object-invoke.cpp']},
>      {'name': 'pixel-format', 'sources': ['pixel-format.cpp']},
>      {'name': 'shared-fd', 'sources': ['shared-fd.cpp']},
>      {'name': 'signal-threads', 'sources': ['signal-threads.cpp']},
>      {'name': 'threads', 'sources': 'threads.cpp', 'dependencies': [libthreads]},
>      {'name': 'timer', 'sources': ['timer.cpp']},
>      {'name': 'timer-fail', 'sources': ['timer-fail.cpp'], 'should_fail': true},
>      {'name': 'timer-thread', 'sources': ['timer-thread.cpp']},
>      {'name': 'unique-fd', 'sources': ['unique-fd.cpp']},
>      {'name': 'utils', 'sources': ['utils.cpp']},
>      {'name': 'value-node', 'sources': ['value-node.cpp']},
>      {'name': 'vector', 'sources': ['vector.cpp']},
>      {'name': 'yaml-parser', 'sources': ['yaml-parser.cpp']},
>  ]
>  
> +if softisp_enabled and mesa_works
> +    softisp_gpu_includes = include_directories('../src/libcamera/software_isp')
> +    internal_tests += [
> +        {'name': 'software-isp-gpu-crop', 'sources': ['software-isp-gpu-crop.cpp'],
> +         'dependencies': [libegl, libglesv2],
> +         'include_directories': softisp_gpu_includes},
> +    ]
> +endif
> +
>  internal_non_parallel_tests = [
>      {'name': 'fence', 'sources': ['fence.cpp']},
>      {'name': 'mapped-buffer', 'sources': ['mapped-buffer.cpp']},
>  ]
>  
>  foreach test : public_tests
>      deps = [libcamera_public]
>      if 'dependencies' in test
>          deps += test['dependencies']
>      endif
>  
>      exe = executable(test['name'], test['sources'],
>                       dependencies : deps,
>                       implicit_include_directories : false,
>                       link_with : test_libraries,
>                       include_directories : test_includes_public)
>  
>      test(test['name'], exe, should_fail : test.get('should_fail', false))
>  endforeach
>  
>  foreach test : internal_tests
>      deps = [libcamera_private]
>      if 'dependencies' in test
>          deps += test['dependencies']
>      endif
>  
> +    includes = [test_includes_internal]
> +    if 'include_directories' in test
> +        includes += test['include_directories']
> +    endif
> +
>      exe = executable(test['name'], test['sources'],
>                       dependencies : deps,
>                       implicit_include_directories : false,
>                       link_with : test_libraries,
> -                     include_directories : test_includes_internal)
> +                     include_directories : includes)
>  
>      test(test['name'], exe, should_fail : test.get('should_fail', false))
>  endforeach
>  
>  foreach test : internal_non_parallel_tests
>      deps = [libcamera_private]
>      if 'dependencies' in test
>          deps += test['dependencies']
>      endif
>  
>      exe = executable(test['name'], test['sources'],
>                       dependencies : deps,
>                       implicit_include_directories : false,
>                       link_with : test_libraries,
>                       include_directories : test_includes_internal)
>  
>      test(test['name'], exe,
>           is_parallel : false,
>           should_fail : test.get('should_fail', false))
>  endforeach
> diff --git a/test/software-isp-gpu-crop.cpp b/test/software-isp-gpu-crop.cpp
> new file mode 100644
> index 00000000..8d91c0a4
> --- /dev/null
> +++ b/test/software-isp-gpu-crop.cpp
> @@ -0,0 +1,203 @@
> +/* SPDX-License-Identifier: GPL-2.0-or-later */
> +
> +#include <algorithm>
> +#include <cmath>
> +#include <iostream>
> +#include <memory>
> +#include <stdint.h>
> +#include <string>
> +#include <tuple>
> +#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/dma_buf_allocator.h"
> +#include "libcamera/internal/egl.h"
> +#include "libcamera/internal/framebuffer.h"
> +#include "libcamera/internal/mapped_framebuffer.h"
> +#include "libcamera/internal/software_isp/debayer_params.h"
> +#include "libcamera/internal/software_isp/swstats_cpu.h"
> +
> +#include "debayer_egl.h"
> +#include "test.h"
> +
> +using namespace libcamera;
> +
> +class GpuCropTest : public Test
> +{
> +protected:
> +	int run() override
> +	{
> +		EGLDisplay display = eGL::probeDisplay();
> +		if (display == EGL_NO_DISPLAY)
> +			return TestSkip;
> +		utils::scope_exit terminate([&] { eglTerminate(display); });
> +
> +		const char *extensions = eglQueryString(display, EGL_EXTENSIONS);
> +		if (!extensions || std::string(extensions).find("EGL_EXT_image_dma_buf_import") == std::string::npos)
> +			return TestSkip;
> +
> +		DmaBufAllocator allocator(DmaBufAllocator::DmaBufAllocatorFlag::SystemHeap |
> +					  DmaBufAllocator::DmaBufAllocatorFlag::CmaHeap |
> +					  DmaBufAllocator::DmaBufAllocatorFlag::UDmaBuf);
> +		if (!allocator.isValid())
> +			return TestSkip;
> +
> +		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 nativeSize;
> +				for (Size size : { Size(1920, 1080), Size(1280, 720),
> +						   Size(640, 480), Size(512, 768) }) {
> +					std::vector<uint8_t> image;
> +					if (render(manager, allocator, display, format, size,
> +						   vertical, &image, &nativeSize))
> +						return TestFail;
> +
> +					if (!checkCenter(image, size, vertical)) {
> +						std::cerr << format << " " << size
> +							  << " stripe is not centered" << std::endl;
> +						return TestFail;
> +					}
> +
> +					if (reference.empty()) {
> +						if (size != nativeSize)
> +							return TestFail;
> +						reference = std::move(image);
> +						continue;
> +					}
> +
> +					/* Compare against a centered crop of the native render. */
> +					double scale = std::max(double(size.width) / nativeSize.width,
> +								double(size.height) / nativeSize.height);
> +					double error = 0;
> +					for (unsigned int y = 0; y < size.height; y++) {
> +						int ry = std::lround(nativeSize.height / 2.0 +
> +							(y + 0.5 - size.height / 2.0) / scale - 0.5);
> +						for (unsigned int x = 0; x < size.width; x++) {
> +							int rx = std::lround(nativeSize.width / 2.0 +
> +								(x + 0.5 - size.width / 2.0) / scale - 0.5);
> +							unsigned int actual = (y * size.width + x) * 4;
> +							unsigned int expected = (ry * nativeSize.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 the stripe edges. */
> +					if (error > 2.0)
> +						return TestFail;
> +				}
> +			}
> +		}
> +		return TestPass;
> +	}
> +
> +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 synthetic scene is achromatic, independent of RGB ordering. */
> +			if (image[(y * size.width + x) * 4 + 1] > 128) {
> +				sum += i + 0.5;
> +				count++;
> +			}
> +		}
> +		if (!count)
> +			return false;
> +		double center = sum / count;
> +		std::cout << (vertical ? "X" : "Y") << " center " << center
> +			  << ", expected " << length / 2.0 << std::endl;
> +		return std::abs(center - length / 2.0) <= 1.0;
> +	}
> +
> +	int render(CameraManager &manager, DmaBufAllocator &allocator, EGLDisplay display,
> +		   PixelFormat format, const Size &size, bool vertical,
> +		   std::vector<uint8_t> *image, Size *nativeSize)
> +	{
> +		auto stats = std::make_unique<SwStatsCpu>(manager);
> +		if (!stats->isValid())
> +			return TestFail;
> +		DebayerEGL 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;
> +		unsigned int frameSize;
> +		std::tie(output.stride, frameSize) = debayer.strideAndFrameSize(output.pixelFormat, size);
> +		output.bufferCount = 1;
> +		*nativeSize = debayer.sizes(format, input.size).max;
> +		if (debayer.configure(input, { output }, false))
> +			return TestFail;
> +
> +		std::vector<std::unique_ptr<FrameBuffer>> inputs, outputs;
> +		if (allocator.exportBuffers(1, { input.stride * input.size.height }, &inputs) != 1 ||
> +		    allocator.exportBuffers(1, { frameSize }, &outputs) != 1)
> +			return TestFail;
> +		MappedFrameBuffer raw(inputs[0].get(), MappedFrameBuffer::MapFlag::Write);
> +		MappedFrameBuffer rgb(outputs[0].get(), MappedFrameBuffer::MapFlag::Read);
> +		if (!raw.isValid() || !rgb.isValid())
> +			return TestFail;
> +		FrameMetadata &metadata = inputs[0]->_d()->metadata();
> +		metadata.status = FrameMetadata::FrameSuccess;
> +		metadata.sequence = 0;
> +		metadata.timestamp = 0;
> +
> +		{
> +			DmaSyncer sync(inputs[0]->planes()[0].fd, DmaSyncer::SyncType::Write);
> +			std::fill(raw.planes()[0].begin(), raw.planes()[0].end(), 0);
> +			/* A bright stripe centered on the sensor, with dark surroundings. */
> +			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;
> +					uint8_t value = bright ? 224 : 32;
> +					unsigned int offset = packed ? x / 4 * 5 + x % 4 : x;
> +					/* RAW10 stores the eight high bits before each group's low bits. */
> +					raw.planes()[0][y * input.stride + offset] = value;
> +				}
> +			}
> +		}
> +
> +		/* stop() must also release a partially initialized context on failure. */
> +		utils::scope_exit stop([&] { debayer.stop(); });
> +		if (debayer.start())
> +			return TestFail;
> +		bool ready = false;
> +		debayer.outputBufferReady.connect(this, [&](FrameBuffer *) { ready = true; });
> +		debayer.process(0, inputs[0].get(), outputs[0].get(), DebayerParams{});
> +		if (!ready || outputs[0]->metadata().status != FrameMetadata::FrameSuccess)
> +			return TestFail;
> +
> +		DmaSyncer sync(outputs[0]->planes()[0].fd, DmaSyncer::SyncType::Read);
> +		image->resize(size.width * size.height * 4);
> +		for (unsigned int y = 0; y < size.height; y++)
> +			std::copy_n(rgb.planes()[0].data() + y * output.stride, size.width * 4,
> +				    image->data() + y * size.width * 4);
> +		return TestPass;
> +	}
> +};
> +
> +TEST_REGISTER(GpuCropTest)
Hans de Goede Oct. 16, 2026, 2:39 p.m. UTC | #2
Hi,

On 15-Oct-26 22:16, Laurent Pinchart wrote:
> On Thu, Oct 15, 2026 at 03:43:33PM -0400, Ray Strode wrote:
>> From: Ray Strode <rstrode@redhat.com>
>>
>> The GPU debayer path centers the sensor image when output dimensions or
>> aspect ratios change.
>>
>> The project has no test that inspects rendered pixels, so a projection
>> error can shift the visible image without failing an automated check.
>>
>> This commit renders centered synthetic stripes through the GPU path and
>> compares each output against a centered crop of the native image. It
>> covers horizontal and vertical positioning, four Bayer formats, and
>> four output sizes.
> 
> Doesn't qualify as a unit test.

This still seems like a nice test to have though ?

Can we maybe add some more end2end(ish) tests somewhere ?

Even if users need to manually opt-in / specify some meson
flags to get the extra tests this seems like something good
to have ?

Especially since we should be able to do this in CI, even if
it with using / forcing LLVMpipe in MESA?

Regards,

Hans



> 
>> Signed-off-by: Ray Strode <rstrode@redhat.com>
>> ---
>>  test/meson.build               |  16 ++-
>>  test/software-isp-gpu-crop.cpp | 203 +++++++++++++++++++++++++++++++++
>>  2 files changed, 218 insertions(+), 1 deletion(-)
>>  create mode 100644 test/software-isp-gpu-crop.cpp
>>
>> diff --git a/test/meson.build b/test/meson.build
>> index 363a2829..644a8cba 100644
>> --- a/test/meson.build
>> +++ b/test/meson.build
>> @@ -51,81 +51,95 @@ public_tests = [
>>  internal_tests = [
>>      {'name': 'bayer-format', 'sources': ['bayer-format.cpp']},
>>      {'name': 'byte-stream-buffer', 'sources': ['byte-stream-buffer.cpp']},
>>      {'name': 'camera-sensor', 'sources': ['camera-sensor.cpp']},
>>      {'name': 'delayed_controls', 'sources': ['delayed_controls.cpp']},
>>      {'name': 'event', 'sources': ['event.cpp']},
>>      {'name': 'event-dispatcher', 'sources': ['event-dispatcher.cpp']},
>>      {'name': 'event-thread', 'sources': ['event-thread.cpp']},
>>      {'name': 'file', 'sources': ['file.cpp']},
>>      {'name': 'flags', 'sources': ['flags.cpp']},
>>      {'name': 'hotplug-cameras', 'sources': ['hotplug-cameras.cpp']},
>>      {'name': 'matrix', 'sources': ['matrix.cpp']},
>>      {'name': 'message', 'sources': ['message.cpp']},
>>      {'name': 'object', 'sources': ['object.cpp']},
>>      {'name': 'object-delete', 'sources': ['object-delete.cpp']},
>>      {'name': 'object-invoke', 'sources': ['object-invoke.cpp']},
>>      {'name': 'pixel-format', 'sources': ['pixel-format.cpp']},
>>      {'name': 'shared-fd', 'sources': ['shared-fd.cpp']},
>>      {'name': 'signal-threads', 'sources': ['signal-threads.cpp']},
>>      {'name': 'threads', 'sources': 'threads.cpp', 'dependencies': [libthreads]},
>>      {'name': 'timer', 'sources': ['timer.cpp']},
>>      {'name': 'timer-fail', 'sources': ['timer-fail.cpp'], 'should_fail': true},
>>      {'name': 'timer-thread', 'sources': ['timer-thread.cpp']},
>>      {'name': 'unique-fd', 'sources': ['unique-fd.cpp']},
>>      {'name': 'utils', 'sources': ['utils.cpp']},
>>      {'name': 'value-node', 'sources': ['value-node.cpp']},
>>      {'name': 'vector', 'sources': ['vector.cpp']},
>>      {'name': 'yaml-parser', 'sources': ['yaml-parser.cpp']},
>>  ]
>>  
>> +if softisp_enabled and mesa_works
>> +    softisp_gpu_includes = include_directories('../src/libcamera/software_isp')
>> +    internal_tests += [
>> +        {'name': 'software-isp-gpu-crop', 'sources': ['software-isp-gpu-crop.cpp'],
>> +         'dependencies': [libegl, libglesv2],
>> +         'include_directories': softisp_gpu_includes},
>> +    ]
>> +endif
>> +
>>  internal_non_parallel_tests = [
>>      {'name': 'fence', 'sources': ['fence.cpp']},
>>      {'name': 'mapped-buffer', 'sources': ['mapped-buffer.cpp']},
>>  ]
>>  
>>  foreach test : public_tests
>>      deps = [libcamera_public]
>>      if 'dependencies' in test
>>          deps += test['dependencies']
>>      endif
>>  
>>      exe = executable(test['name'], test['sources'],
>>                       dependencies : deps,
>>                       implicit_include_directories : false,
>>                       link_with : test_libraries,
>>                       include_directories : test_includes_public)
>>  
>>      test(test['name'], exe, should_fail : test.get('should_fail', false))
>>  endforeach
>>  
>>  foreach test : internal_tests
>>      deps = [libcamera_private]
>>      if 'dependencies' in test
>>          deps += test['dependencies']
>>      endif
>>  
>> +    includes = [test_includes_internal]
>> +    if 'include_directories' in test
>> +        includes += test['include_directories']
>> +    endif
>> +
>>      exe = executable(test['name'], test['sources'],
>>                       dependencies : deps,
>>                       implicit_include_directories : false,
>>                       link_with : test_libraries,
>> -                     include_directories : test_includes_internal)
>> +                     include_directories : includes)
>>  
>>      test(test['name'], exe, should_fail : test.get('should_fail', false))
>>  endforeach
>>  
>>  foreach test : internal_non_parallel_tests
>>      deps = [libcamera_private]
>>      if 'dependencies' in test
>>          deps += test['dependencies']
>>      endif
>>  
>>      exe = executable(test['name'], test['sources'],
>>                       dependencies : deps,
>>                       implicit_include_directories : false,
>>                       link_with : test_libraries,
>>                       include_directories : test_includes_internal)
>>  
>>      test(test['name'], exe,
>>           is_parallel : false,
>>           should_fail : test.get('should_fail', false))
>>  endforeach
>> diff --git a/test/software-isp-gpu-crop.cpp b/test/software-isp-gpu-crop.cpp
>> new file mode 100644
>> index 00000000..8d91c0a4
>> --- /dev/null
>> +++ b/test/software-isp-gpu-crop.cpp
>> @@ -0,0 +1,203 @@
>> +/* SPDX-License-Identifier: GPL-2.0-or-later */
>> +
>> +#include <algorithm>
>> +#include <cmath>
>> +#include <iostream>
>> +#include <memory>
>> +#include <stdint.h>
>> +#include <string>
>> +#include <tuple>
>> +#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/dma_buf_allocator.h"
>> +#include "libcamera/internal/egl.h"
>> +#include "libcamera/internal/framebuffer.h"
>> +#include "libcamera/internal/mapped_framebuffer.h"
>> +#include "libcamera/internal/software_isp/debayer_params.h"
>> +#include "libcamera/internal/software_isp/swstats_cpu.h"
>> +
>> +#include "debayer_egl.h"
>> +#include "test.h"
>> +
>> +using namespace libcamera;
>> +
>> +class GpuCropTest : public Test
>> +{
>> +protected:
>> +	int run() override
>> +	{
>> +		EGLDisplay display = eGL::probeDisplay();
>> +		if (display == EGL_NO_DISPLAY)
>> +			return TestSkip;
>> +		utils::scope_exit terminate([&] { eglTerminate(display); });
>> +
>> +		const char *extensions = eglQueryString(display, EGL_EXTENSIONS);
>> +		if (!extensions || std::string(extensions).find("EGL_EXT_image_dma_buf_import") == std::string::npos)
>> +			return TestSkip;
>> +
>> +		DmaBufAllocator allocator(DmaBufAllocator::DmaBufAllocatorFlag::SystemHeap |
>> +					  DmaBufAllocator::DmaBufAllocatorFlag::CmaHeap |
>> +					  DmaBufAllocator::DmaBufAllocatorFlag::UDmaBuf);
>> +		if (!allocator.isValid())
>> +			return TestSkip;
>> +
>> +		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 nativeSize;
>> +				for (Size size : { Size(1920, 1080), Size(1280, 720),
>> +						   Size(640, 480), Size(512, 768) }) {
>> +					std::vector<uint8_t> image;
>> +					if (render(manager, allocator, display, format, size,
>> +						   vertical, &image, &nativeSize))
>> +						return TestFail;
>> +
>> +					if (!checkCenter(image, size, vertical)) {
>> +						std::cerr << format << " " << size
>> +							  << " stripe is not centered" << std::endl;
>> +						return TestFail;
>> +					}
>> +
>> +					if (reference.empty()) {
>> +						if (size != nativeSize)
>> +							return TestFail;
>> +						reference = std::move(image);
>> +						continue;
>> +					}
>> +
>> +					/* Compare against a centered crop of the native render. */
>> +					double scale = std::max(double(size.width) / nativeSize.width,
>> +								double(size.height) / nativeSize.height);
>> +					double error = 0;
>> +					for (unsigned int y = 0; y < size.height; y++) {
>> +						int ry = std::lround(nativeSize.height / 2.0 +
>> +							(y + 0.5 - size.height / 2.0) / scale - 0.5);
>> +						for (unsigned int x = 0; x < size.width; x++) {
>> +							int rx = std::lround(nativeSize.width / 2.0 +
>> +								(x + 0.5 - size.width / 2.0) / scale - 0.5);
>> +							unsigned int actual = (y * size.width + x) * 4;
>> +							unsigned int expected = (ry * nativeSize.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 the stripe edges. */
>> +					if (error > 2.0)
>> +						return TestFail;
>> +				}
>> +			}
>> +		}
>> +		return TestPass;
>> +	}
>> +
>> +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 synthetic scene is achromatic, independent of RGB ordering. */
>> +			if (image[(y * size.width + x) * 4 + 1] > 128) {
>> +				sum += i + 0.5;
>> +				count++;
>> +			}
>> +		}
>> +		if (!count)
>> +			return false;
>> +		double center = sum / count;
>> +		std::cout << (vertical ? "X" : "Y") << " center " << center
>> +			  << ", expected " << length / 2.0 << std::endl;
>> +		return std::abs(center - length / 2.0) <= 1.0;
>> +	}
>> +
>> +	int render(CameraManager &manager, DmaBufAllocator &allocator, EGLDisplay display,
>> +		   PixelFormat format, const Size &size, bool vertical,
>> +		   std::vector<uint8_t> *image, Size *nativeSize)
>> +	{
>> +		auto stats = std::make_unique<SwStatsCpu>(manager);
>> +		if (!stats->isValid())
>> +			return TestFail;
>> +		DebayerEGL 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;
>> +		unsigned int frameSize;
>> +		std::tie(output.stride, frameSize) = debayer.strideAndFrameSize(output.pixelFormat, size);
>> +		output.bufferCount = 1;
>> +		*nativeSize = debayer.sizes(format, input.size).max;
>> +		if (debayer.configure(input, { output }, false))
>> +			return TestFail;
>> +
>> +		std::vector<std::unique_ptr<FrameBuffer>> inputs, outputs;
>> +		if (allocator.exportBuffers(1, { input.stride * input.size.height }, &inputs) != 1 ||
>> +		    allocator.exportBuffers(1, { frameSize }, &outputs) != 1)
>> +			return TestFail;
>> +		MappedFrameBuffer raw(inputs[0].get(), MappedFrameBuffer::MapFlag::Write);
>> +		MappedFrameBuffer rgb(outputs[0].get(), MappedFrameBuffer::MapFlag::Read);
>> +		if (!raw.isValid() || !rgb.isValid())
>> +			return TestFail;
>> +		FrameMetadata &metadata = inputs[0]->_d()->metadata();
>> +		metadata.status = FrameMetadata::FrameSuccess;
>> +		metadata.sequence = 0;
>> +		metadata.timestamp = 0;
>> +
>> +		{
>> +			DmaSyncer sync(inputs[0]->planes()[0].fd, DmaSyncer::SyncType::Write);
>> +			std::fill(raw.planes()[0].begin(), raw.planes()[0].end(), 0);
>> +			/* A bright stripe centered on the sensor, with dark surroundings. */
>> +			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;
>> +					uint8_t value = bright ? 224 : 32;
>> +					unsigned int offset = packed ? x / 4 * 5 + x % 4 : x;
>> +					/* RAW10 stores the eight high bits before each group's low bits. */
>> +					raw.planes()[0][y * input.stride + offset] = value;
>> +				}
>> +			}
>> +		}
>> +
>> +		/* stop() must also release a partially initialized context on failure. */
>> +		utils::scope_exit stop([&] { debayer.stop(); });
>> +		if (debayer.start())
>> +			return TestFail;
>> +		bool ready = false;
>> +		debayer.outputBufferReady.connect(this, [&](FrameBuffer *) { ready = true; });
>> +		debayer.process(0, inputs[0].get(), outputs[0].get(), DebayerParams{});
>> +		if (!ready || outputs[0]->metadata().status != FrameMetadata::FrameSuccess)
>> +			return TestFail;
>> +
>> +		DmaSyncer sync(outputs[0]->planes()[0].fd, DmaSyncer::SyncType::Read);
>> +		image->resize(size.width * size.height * 4);
>> +		for (unsigned int y = 0; y < size.height; y++)
>> +			std::copy_n(rgb.planes()[0].data() + y * output.stride, size.width * 4,
>> +				    image->data() + y * size.width * 4);
>> +		return TestPass;
>> +	}
>> +};
>> +
>> +TEST_REGISTER(GpuCropTest)
>
Laurent Pinchart Oct. 16, 2026, 8:35 p.m. UTC | #3
On Fri, Oct 16, 2026 at 04:39:02PM +0200, johannes.goede@oss.qualcomm.com wrote:
> On 15-Oct-26 22:16, Laurent Pinchart wrote:
> > On Thu, Oct 15, 2026 at 03:43:33PM -0400, Ray Strode wrote:
> >> From: Ray Strode <rstrode@redhat.com>
> >>
> >> The GPU debayer path centers the sensor image when output dimensions or
> >> aspect ratios change.
> >>
> >> The project has no test that inspects rendered pixels, so a projection
> >> error can shift the visible image without failing an automated check.
> >>
> >> This commit renders centered synthetic stripes through the GPU path and
> >> compares each output against a centered crop of the native image. It
> >> covers horizontal and vertical positioning, four Bayer formats, and
> >> four output sizes.
> > 
> > Doesn't qualify as a unit test.
> 
> This still seems like a nice test to have though ?
> 
> Can we maybe add some more end2end(ish) tests somewhere ?

We certainly need end-to-end tests. We have lc-compliance, but that's
not a great match for this either. We need to start working on an
imaging test suite, the same way Android splits camera tests into CTS
(equivalent of lc-compliance) and ITS.

> Even if users need to manually opt-in / specify some meson
> flags to get the extra tests this seems like something good
> to have ?

I don't want to open to door to AI slop in unit tests.

> Especially since we should be able to do this in CI, even if
> it with using / forcing LLVMpipe in MESA?
> 
> >> Signed-off-by: Ray Strode <rstrode@redhat.com>
> >> ---
> >>  test/meson.build               |  16 ++-
> >>  test/software-isp-gpu-crop.cpp | 203 +++++++++++++++++++++++++++++++++
> >>  2 files changed, 218 insertions(+), 1 deletion(-)
> >>  create mode 100644 test/software-isp-gpu-crop.cpp
> >>
> >> diff --git a/test/meson.build b/test/meson.build
> >> index 363a2829..644a8cba 100644
> >> --- a/test/meson.build
> >> +++ b/test/meson.build
> >> @@ -51,81 +51,95 @@ public_tests = [
> >>  internal_tests = [
> >>      {'name': 'bayer-format', 'sources': ['bayer-format.cpp']},
> >>      {'name': 'byte-stream-buffer', 'sources': ['byte-stream-buffer.cpp']},
> >>      {'name': 'camera-sensor', 'sources': ['camera-sensor.cpp']},
> >>      {'name': 'delayed_controls', 'sources': ['delayed_controls.cpp']},
> >>      {'name': 'event', 'sources': ['event.cpp']},
> >>      {'name': 'event-dispatcher', 'sources': ['event-dispatcher.cpp']},
> >>      {'name': 'event-thread', 'sources': ['event-thread.cpp']},
> >>      {'name': 'file', 'sources': ['file.cpp']},
> >>      {'name': 'flags', 'sources': ['flags.cpp']},
> >>      {'name': 'hotplug-cameras', 'sources': ['hotplug-cameras.cpp']},
> >>      {'name': 'matrix', 'sources': ['matrix.cpp']},
> >>      {'name': 'message', 'sources': ['message.cpp']},
> >>      {'name': 'object', 'sources': ['object.cpp']},
> >>      {'name': 'object-delete', 'sources': ['object-delete.cpp']},
> >>      {'name': 'object-invoke', 'sources': ['object-invoke.cpp']},
> >>      {'name': 'pixel-format', 'sources': ['pixel-format.cpp']},
> >>      {'name': 'shared-fd', 'sources': ['shared-fd.cpp']},
> >>      {'name': 'signal-threads', 'sources': ['signal-threads.cpp']},
> >>      {'name': 'threads', 'sources': 'threads.cpp', 'dependencies': [libthreads]},
> >>      {'name': 'timer', 'sources': ['timer.cpp']},
> >>      {'name': 'timer-fail', 'sources': ['timer-fail.cpp'], 'should_fail': true},
> >>      {'name': 'timer-thread', 'sources': ['timer-thread.cpp']},
> >>      {'name': 'unique-fd', 'sources': ['unique-fd.cpp']},
> >>      {'name': 'utils', 'sources': ['utils.cpp']},
> >>      {'name': 'value-node', 'sources': ['value-node.cpp']},
> >>      {'name': 'vector', 'sources': ['vector.cpp']},
> >>      {'name': 'yaml-parser', 'sources': ['yaml-parser.cpp']},
> >>  ]
> >>  
> >> +if softisp_enabled and mesa_works
> >> +    softisp_gpu_includes = include_directories('../src/libcamera/software_isp')
> >> +    internal_tests += [
> >> +        {'name': 'software-isp-gpu-crop', 'sources': ['software-isp-gpu-crop.cpp'],
> >> +         'dependencies': [libegl, libglesv2],
> >> +         'include_directories': softisp_gpu_includes},
> >> +    ]
> >> +endif
> >> +
> >>  internal_non_parallel_tests = [
> >>      {'name': 'fence', 'sources': ['fence.cpp']},
> >>      {'name': 'mapped-buffer', 'sources': ['mapped-buffer.cpp']},
> >>  ]
> >>  
> >>  foreach test : public_tests
> >>      deps = [libcamera_public]
> >>      if 'dependencies' in test
> >>          deps += test['dependencies']
> >>      endif
> >>  
> >>      exe = executable(test['name'], test['sources'],
> >>                       dependencies : deps,
> >>                       implicit_include_directories : false,
> >>                       link_with : test_libraries,
> >>                       include_directories : test_includes_public)
> >>  
> >>      test(test['name'], exe, should_fail : test.get('should_fail', false))
> >>  endforeach
> >>  
> >>  foreach test : internal_tests
> >>      deps = [libcamera_private]
> >>      if 'dependencies' in test
> >>          deps += test['dependencies']
> >>      endif
> >>  
> >> +    includes = [test_includes_internal]
> >> +    if 'include_directories' in test
> >> +        includes += test['include_directories']
> >> +    endif
> >> +
> >>      exe = executable(test['name'], test['sources'],
> >>                       dependencies : deps,
> >>                       implicit_include_directories : false,
> >>                       link_with : test_libraries,
> >> -                     include_directories : test_includes_internal)
> >> +                     include_directories : includes)
> >>  
> >>      test(test['name'], exe, should_fail : test.get('should_fail', false))
> >>  endforeach
> >>  
> >>  foreach test : internal_non_parallel_tests
> >>      deps = [libcamera_private]
> >>      if 'dependencies' in test
> >>          deps += test['dependencies']
> >>      endif
> >>  
> >>      exe = executable(test['name'], test['sources'],
> >>                       dependencies : deps,
> >>                       implicit_include_directories : false,
> >>                       link_with : test_libraries,
> >>                       include_directories : test_includes_internal)
> >>  
> >>      test(test['name'], exe,
> >>           is_parallel : false,
> >>           should_fail : test.get('should_fail', false))
> >>  endforeach
> >> diff --git a/test/software-isp-gpu-crop.cpp b/test/software-isp-gpu-crop.cpp
> >> new file mode 100644
> >> index 00000000..8d91c0a4
> >> --- /dev/null
> >> +++ b/test/software-isp-gpu-crop.cpp
> >> @@ -0,0 +1,203 @@
> >> +/* SPDX-License-Identifier: GPL-2.0-or-later */
> >> +
> >> +#include <algorithm>
> >> +#include <cmath>
> >> +#include <iostream>
> >> +#include <memory>
> >> +#include <stdint.h>
> >> +#include <string>
> >> +#include <tuple>
> >> +#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/dma_buf_allocator.h"
> >> +#include "libcamera/internal/egl.h"
> >> +#include "libcamera/internal/framebuffer.h"
> >> +#include "libcamera/internal/mapped_framebuffer.h"
> >> +#include "libcamera/internal/software_isp/debayer_params.h"
> >> +#include "libcamera/internal/software_isp/swstats_cpu.h"
> >> +
> >> +#include "debayer_egl.h"
> >> +#include "test.h"
> >> +
> >> +using namespace libcamera;
> >> +
> >> +class GpuCropTest : public Test
> >> +{
> >> +protected:
> >> +	int run() override
> >> +	{
> >> +		EGLDisplay display = eGL::probeDisplay();
> >> +		if (display == EGL_NO_DISPLAY)
> >> +			return TestSkip;
> >> +		utils::scope_exit terminate([&] { eglTerminate(display); });
> >> +
> >> +		const char *extensions = eglQueryString(display, EGL_EXTENSIONS);
> >> +		if (!extensions || std::string(extensions).find("EGL_EXT_image_dma_buf_import") == std::string::npos)
> >> +			return TestSkip;
> >> +
> >> +		DmaBufAllocator allocator(DmaBufAllocator::DmaBufAllocatorFlag::SystemHeap |
> >> +					  DmaBufAllocator::DmaBufAllocatorFlag::CmaHeap |
> >> +					  DmaBufAllocator::DmaBufAllocatorFlag::UDmaBuf);
> >> +		if (!allocator.isValid())
> >> +			return TestSkip;
> >> +
> >> +		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 nativeSize;
> >> +				for (Size size : { Size(1920, 1080), Size(1280, 720),
> >> +						   Size(640, 480), Size(512, 768) }) {
> >> +					std::vector<uint8_t> image;
> >> +					if (render(manager, allocator, display, format, size,
> >> +						   vertical, &image, &nativeSize))
> >> +						return TestFail;
> >> +
> >> +					if (!checkCenter(image, size, vertical)) {
> >> +						std::cerr << format << " " << size
> >> +							  << " stripe is not centered" << std::endl;
> >> +						return TestFail;
> >> +					}
> >> +
> >> +					if (reference.empty()) {
> >> +						if (size != nativeSize)
> >> +							return TestFail;
> >> +						reference = std::move(image);
> >> +						continue;
> >> +					}
> >> +
> >> +					/* Compare against a centered crop of the native render. */
> >> +					double scale = std::max(double(size.width) / nativeSize.width,
> >> +								double(size.height) / nativeSize.height);
> >> +					double error = 0;
> >> +					for (unsigned int y = 0; y < size.height; y++) {
> >> +						int ry = std::lround(nativeSize.height / 2.0 +
> >> +							(y + 0.5 - size.height / 2.0) / scale - 0.5);
> >> +						for (unsigned int x = 0; x < size.width; x++) {
> >> +							int rx = std::lround(nativeSize.width / 2.0 +
> >> +								(x + 0.5 - size.width / 2.0) / scale - 0.5);
> >> +							unsigned int actual = (y * size.width + x) * 4;
> >> +							unsigned int expected = (ry * nativeSize.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 the stripe edges. */
> >> +					if (error > 2.0)
> >> +						return TestFail;
> >> +				}
> >> +			}
> >> +		}
> >> +		return TestPass;
> >> +	}
> >> +
> >> +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 synthetic scene is achromatic, independent of RGB ordering. */
> >> +			if (image[(y * size.width + x) * 4 + 1] > 128) {
> >> +				sum += i + 0.5;
> >> +				count++;
> >> +			}
> >> +		}
> >> +		if (!count)
> >> +			return false;
> >> +		double center = sum / count;
> >> +		std::cout << (vertical ? "X" : "Y") << " center " << center
> >> +			  << ", expected " << length / 2.0 << std::endl;
> >> +		return std::abs(center - length / 2.0) <= 1.0;
> >> +	}
> >> +
> >> +	int render(CameraManager &manager, DmaBufAllocator &allocator, EGLDisplay display,
> >> +		   PixelFormat format, const Size &size, bool vertical,
> >> +		   std::vector<uint8_t> *image, Size *nativeSize)
> >> +	{
> >> +		auto stats = std::make_unique<SwStatsCpu>(manager);
> >> +		if (!stats->isValid())
> >> +			return TestFail;
> >> +		DebayerEGL 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;
> >> +		unsigned int frameSize;
> >> +		std::tie(output.stride, frameSize) = debayer.strideAndFrameSize(output.pixelFormat, size);
> >> +		output.bufferCount = 1;
> >> +		*nativeSize = debayer.sizes(format, input.size).max;
> >> +		if (debayer.configure(input, { output }, false))
> >> +			return TestFail;
> >> +
> >> +		std::vector<std::unique_ptr<FrameBuffer>> inputs, outputs;
> >> +		if (allocator.exportBuffers(1, { input.stride * input.size.height }, &inputs) != 1 ||
> >> +		    allocator.exportBuffers(1, { frameSize }, &outputs) != 1)
> >> +			return TestFail;
> >> +		MappedFrameBuffer raw(inputs[0].get(), MappedFrameBuffer::MapFlag::Write);
> >> +		MappedFrameBuffer rgb(outputs[0].get(), MappedFrameBuffer::MapFlag::Read);
> >> +		if (!raw.isValid() || !rgb.isValid())
> >> +			return TestFail;
> >> +		FrameMetadata &metadata = inputs[0]->_d()->metadata();
> >> +		metadata.status = FrameMetadata::FrameSuccess;
> >> +		metadata.sequence = 0;
> >> +		metadata.timestamp = 0;
> >> +
> >> +		{
> >> +			DmaSyncer sync(inputs[0]->planes()[0].fd, DmaSyncer::SyncType::Write);
> >> +			std::fill(raw.planes()[0].begin(), raw.planes()[0].end(), 0);
> >> +			/* A bright stripe centered on the sensor, with dark surroundings. */
> >> +			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;
> >> +					uint8_t value = bright ? 224 : 32;
> >> +					unsigned int offset = packed ? x / 4 * 5 + x % 4 : x;
> >> +					/* RAW10 stores the eight high bits before each group's low bits. */
> >> +					raw.planes()[0][y * input.stride + offset] = value;
> >> +				}
> >> +			}
> >> +		}
> >> +
> >> +		/* stop() must also release a partially initialized context on failure. */
> >> +		utils::scope_exit stop([&] { debayer.stop(); });
> >> +		if (debayer.start())
> >> +			return TestFail;
> >> +		bool ready = false;
> >> +		debayer.outputBufferReady.connect(this, [&](FrameBuffer *) { ready = true; });
> >> +		debayer.process(0, inputs[0].get(), outputs[0].get(), DebayerParams{});
> >> +		if (!ready || outputs[0]->metadata().status != FrameMetadata::FrameSuccess)
> >> +			return TestFail;
> >> +
> >> +		DmaSyncer sync(outputs[0]->planes()[0].fd, DmaSyncer::SyncType::Read);
> >> +		image->resize(size.width * size.height * 4);
> >> +		for (unsigned int y = 0; y < size.height; y++)
> >> +			std::copy_n(rgb.planes()[0].data() + y * output.stride, size.width * 4,
> >> +				    image->data() + y * size.width * 4);
> >> +		return TestPass;
> >> +	}
> >> +};
> >> +
> >> +TEST_REGISTER(GpuCropTest)

Patch
diff mbox series

diff --git a/test/meson.build b/test/meson.build
index 363a2829..644a8cba 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -51,81 +51,95 @@  public_tests = [
 internal_tests = [
     {'name': 'bayer-format', 'sources': ['bayer-format.cpp']},
     {'name': 'byte-stream-buffer', 'sources': ['byte-stream-buffer.cpp']},
     {'name': 'camera-sensor', 'sources': ['camera-sensor.cpp']},
     {'name': 'delayed_controls', 'sources': ['delayed_controls.cpp']},
     {'name': 'event', 'sources': ['event.cpp']},
     {'name': 'event-dispatcher', 'sources': ['event-dispatcher.cpp']},
     {'name': 'event-thread', 'sources': ['event-thread.cpp']},
     {'name': 'file', 'sources': ['file.cpp']},
     {'name': 'flags', 'sources': ['flags.cpp']},
     {'name': 'hotplug-cameras', 'sources': ['hotplug-cameras.cpp']},
     {'name': 'matrix', 'sources': ['matrix.cpp']},
     {'name': 'message', 'sources': ['message.cpp']},
     {'name': 'object', 'sources': ['object.cpp']},
     {'name': 'object-delete', 'sources': ['object-delete.cpp']},
     {'name': 'object-invoke', 'sources': ['object-invoke.cpp']},
     {'name': 'pixel-format', 'sources': ['pixel-format.cpp']},
     {'name': 'shared-fd', 'sources': ['shared-fd.cpp']},
     {'name': 'signal-threads', 'sources': ['signal-threads.cpp']},
     {'name': 'threads', 'sources': 'threads.cpp', 'dependencies': [libthreads]},
     {'name': 'timer', 'sources': ['timer.cpp']},
     {'name': 'timer-fail', 'sources': ['timer-fail.cpp'], 'should_fail': true},
     {'name': 'timer-thread', 'sources': ['timer-thread.cpp']},
     {'name': 'unique-fd', 'sources': ['unique-fd.cpp']},
     {'name': 'utils', 'sources': ['utils.cpp']},
     {'name': 'value-node', 'sources': ['value-node.cpp']},
     {'name': 'vector', 'sources': ['vector.cpp']},
     {'name': 'yaml-parser', 'sources': ['yaml-parser.cpp']},
 ]
 
+if softisp_enabled and mesa_works
+    softisp_gpu_includes = include_directories('../src/libcamera/software_isp')
+    internal_tests += [
+        {'name': 'software-isp-gpu-crop', 'sources': ['software-isp-gpu-crop.cpp'],
+         'dependencies': [libegl, libglesv2],
+         'include_directories': softisp_gpu_includes},
+    ]
+endif
+
 internal_non_parallel_tests = [
     {'name': 'fence', 'sources': ['fence.cpp']},
     {'name': 'mapped-buffer', 'sources': ['mapped-buffer.cpp']},
 ]
 
 foreach test : public_tests
     deps = [libcamera_public]
     if 'dependencies' in test
         deps += test['dependencies']
     endif
 
     exe = executable(test['name'], test['sources'],
                      dependencies : deps,
                      implicit_include_directories : false,
                      link_with : test_libraries,
                      include_directories : test_includes_public)
 
     test(test['name'], exe, should_fail : test.get('should_fail', false))
 endforeach
 
 foreach test : internal_tests
     deps = [libcamera_private]
     if 'dependencies' in test
         deps += test['dependencies']
     endif
 
+    includes = [test_includes_internal]
+    if 'include_directories' in test
+        includes += test['include_directories']
+    endif
+
     exe = executable(test['name'], test['sources'],
                      dependencies : deps,
                      implicit_include_directories : false,
                      link_with : test_libraries,
-                     include_directories : test_includes_internal)
+                     include_directories : includes)
 
     test(test['name'], exe, should_fail : test.get('should_fail', false))
 endforeach
 
 foreach test : internal_non_parallel_tests
     deps = [libcamera_private]
     if 'dependencies' in test
         deps += test['dependencies']
     endif
 
     exe = executable(test['name'], test['sources'],
                      dependencies : deps,
                      implicit_include_directories : false,
                      link_with : test_libraries,
                      include_directories : test_includes_internal)
 
     test(test['name'], exe,
          is_parallel : false,
          should_fail : test.get('should_fail', false))
 endforeach
diff --git a/test/software-isp-gpu-crop.cpp b/test/software-isp-gpu-crop.cpp
new file mode 100644
index 00000000..8d91c0a4
--- /dev/null
+++ b/test/software-isp-gpu-crop.cpp
@@ -0,0 +1,203 @@ 
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+
+#include <algorithm>
+#include <cmath>
+#include <iostream>
+#include <memory>
+#include <stdint.h>
+#include <string>
+#include <tuple>
+#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/dma_buf_allocator.h"
+#include "libcamera/internal/egl.h"
+#include "libcamera/internal/framebuffer.h"
+#include "libcamera/internal/mapped_framebuffer.h"
+#include "libcamera/internal/software_isp/debayer_params.h"
+#include "libcamera/internal/software_isp/swstats_cpu.h"
+
+#include "debayer_egl.h"
+#include "test.h"
+
+using namespace libcamera;
+
+class GpuCropTest : public Test
+{
+protected:
+	int run() override
+	{
+		EGLDisplay display = eGL::probeDisplay();
+		if (display == EGL_NO_DISPLAY)
+			return TestSkip;
+		utils::scope_exit terminate([&] { eglTerminate(display); });
+
+		const char *extensions = eglQueryString(display, EGL_EXTENSIONS);
+		if (!extensions || std::string(extensions).find("EGL_EXT_image_dma_buf_import") == std::string::npos)
+			return TestSkip;
+
+		DmaBufAllocator allocator(DmaBufAllocator::DmaBufAllocatorFlag::SystemHeap |
+					  DmaBufAllocator::DmaBufAllocatorFlag::CmaHeap |
+					  DmaBufAllocator::DmaBufAllocatorFlag::UDmaBuf);
+		if (!allocator.isValid())
+			return TestSkip;
+
+		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 nativeSize;
+				for (Size size : { Size(1920, 1080), Size(1280, 720),
+						   Size(640, 480), Size(512, 768) }) {
+					std::vector<uint8_t> image;
+					if (render(manager, allocator, display, format, size,
+						   vertical, &image, &nativeSize))
+						return TestFail;
+
+					if (!checkCenter(image, size, vertical)) {
+						std::cerr << format << " " << size
+							  << " stripe is not centered" << std::endl;
+						return TestFail;
+					}
+
+					if (reference.empty()) {
+						if (size != nativeSize)
+							return TestFail;
+						reference = std::move(image);
+						continue;
+					}
+
+					/* Compare against a centered crop of the native render. */
+					double scale = std::max(double(size.width) / nativeSize.width,
+								double(size.height) / nativeSize.height);
+					double error = 0;
+					for (unsigned int y = 0; y < size.height; y++) {
+						int ry = std::lround(nativeSize.height / 2.0 +
+							(y + 0.5 - size.height / 2.0) / scale - 0.5);
+						for (unsigned int x = 0; x < size.width; x++) {
+							int rx = std::lround(nativeSize.width / 2.0 +
+								(x + 0.5 - size.width / 2.0) / scale - 0.5);
+							unsigned int actual = (y * size.width + x) * 4;
+							unsigned int expected = (ry * nativeSize.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 the stripe edges. */
+					if (error > 2.0)
+						return TestFail;
+				}
+			}
+		}
+		return TestPass;
+	}
+
+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 synthetic scene is achromatic, independent of RGB ordering. */
+			if (image[(y * size.width + x) * 4 + 1] > 128) {
+				sum += i + 0.5;
+				count++;
+			}
+		}
+		if (!count)
+			return false;
+		double center = sum / count;
+		std::cout << (vertical ? "X" : "Y") << " center " << center
+			  << ", expected " << length / 2.0 << std::endl;
+		return std::abs(center - length / 2.0) <= 1.0;
+	}
+
+	int render(CameraManager &manager, DmaBufAllocator &allocator, EGLDisplay display,
+		   PixelFormat format, const Size &size, bool vertical,
+		   std::vector<uint8_t> *image, Size *nativeSize)
+	{
+		auto stats = std::make_unique<SwStatsCpu>(manager);
+		if (!stats->isValid())
+			return TestFail;
+		DebayerEGL 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;
+		unsigned int frameSize;
+		std::tie(output.stride, frameSize) = debayer.strideAndFrameSize(output.pixelFormat, size);
+		output.bufferCount = 1;
+		*nativeSize = debayer.sizes(format, input.size).max;
+		if (debayer.configure(input, { output }, false))
+			return TestFail;
+
+		std::vector<std::unique_ptr<FrameBuffer>> inputs, outputs;
+		if (allocator.exportBuffers(1, { input.stride * input.size.height }, &inputs) != 1 ||
+		    allocator.exportBuffers(1, { frameSize }, &outputs) != 1)
+			return TestFail;
+		MappedFrameBuffer raw(inputs[0].get(), MappedFrameBuffer::MapFlag::Write);
+		MappedFrameBuffer rgb(outputs[0].get(), MappedFrameBuffer::MapFlag::Read);
+		if (!raw.isValid() || !rgb.isValid())
+			return TestFail;
+		FrameMetadata &metadata = inputs[0]->_d()->metadata();
+		metadata.status = FrameMetadata::FrameSuccess;
+		metadata.sequence = 0;
+		metadata.timestamp = 0;
+
+		{
+			DmaSyncer sync(inputs[0]->planes()[0].fd, DmaSyncer::SyncType::Write);
+			std::fill(raw.planes()[0].begin(), raw.planes()[0].end(), 0);
+			/* A bright stripe centered on the sensor, with dark surroundings. */
+			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;
+					uint8_t value = bright ? 224 : 32;
+					unsigned int offset = packed ? x / 4 * 5 + x % 4 : x;
+					/* RAW10 stores the eight high bits before each group's low bits. */
+					raw.planes()[0][y * input.stride + offset] = value;
+				}
+			}
+		}
+
+		/* stop() must also release a partially initialized context on failure. */
+		utils::scope_exit stop([&] { debayer.stop(); });
+		if (debayer.start())
+			return TestFail;
+		bool ready = false;
+		debayer.outputBufferReady.connect(this, [&](FrameBuffer *) { ready = true; });
+		debayer.process(0, inputs[0].get(), outputs[0].get(), DebayerParams{});
+		if (!ready || outputs[0]->metadata().status != FrameMetadata::FrameSuccess)
+			return TestFail;
+
+		DmaSyncer sync(outputs[0]->planes()[0].fd, DmaSyncer::SyncType::Read);
+		image->resize(size.width * size.height * 4);
+		for (unsigned int y = 0; y < size.height; y++)
+			std::copy_n(rgb.planes()[0].data() + y * output.stride, size.width * 4,
+				    image->data() + y * size.width * 4);
+		return TestPass;
+	}
+};
+
+TEST_REGISTER(GpuCropTest)