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

Message ID 20261015194335.287960-3-halfline@gmail.com
State New
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)

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)