[4/4] tests: Validate visible GPU statistics across output sizes
diff mbox series

Message ID 20261015194335.287960-5-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 statistics window on the sensor region
visible in the output.

The project lacks a regression check that the measured window follows the
visible region across different output sizes and Bayer patterns.

This commit adds a statistics test for 8-bit and packed RAW10 input. It
checks the expected sample count, finite colour ratios, symmetric metering,
a response to pixels inside crop edges, and exclusion of pixels outside
them. Meson registers the test when software ISP and EGL support are
available.

Signed-off-by: Ray Strode <rstrode@redhat.com>
---
 test/meson.build                |   3 +
 test/software-isp-gpu-stats.cpp | 188 ++++++++++++++++++++++++++++++++
 2 files changed, 191 insertions(+)
 create mode 100644 test/software-isp-gpu-stats.cpp

Comments

Laurent Pinchart Oct. 15, 2026, 8:17 p.m. UTC | #1
On Thu, Oct 15, 2026 at 03:43:35PM -0400, Ray Strode wrote:
> From: Ray Strode <rstrode@redhat.com>
> 
> The GPU debayer path centers the statistics window on the sensor region
> visible in the output.
> 
> The project lacks a regression check that the measured window follows the
> visible region across different output sizes and Bayer patterns.
> 
> This commit adds a statistics test for 8-bit and packed RAW10 input. It
> checks the expected sample count, finite colour ratios, symmetric metering,
> a response to pixels inside crop edges, and exclusion of pixels outside
> them. Meson registers the test when software ISP and EGL support are
> available.

Same as for 2/4.

> Signed-off-by: Ray Strode <rstrode@redhat.com>
> ---
>  test/meson.build                |   3 +
>  test/software-isp-gpu-stats.cpp | 188 ++++++++++++++++++++++++++++++++
>  2 files changed, 191 insertions(+)
>  create mode 100644 test/software-isp-gpu-stats.cpp
> 
> diff --git a/test/meson.build b/test/meson.build
> index 644a8cba..e7510646 100644
> --- a/test/meson.build
> +++ b/test/meson.build
> @@ -57,60 +57,63 @@ internal_tests = [
>      {'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},
> +        {'name': 'software-isp-gpu-stats', 'sources': ['software-isp-gpu-stats.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]
> diff --git a/test/software-isp-gpu-stats.cpp b/test/software-isp-gpu-stats.cpp
> new file mode 100644
> index 00000000..2575c1c4
> --- /dev/null
> +++ b/test/software-isp-gpu-stats.cpp
> @@ -0,0 +1,188 @@
> +/* SPDX-License-Identifier: GPL-2.0-or-later */
> +
> +#include <algorithm>
> +#include <cmath>
> +#include <iostream>
> +#include <memory>
> +#include <numeric>
> +#include <stdint.h>
> +#include <sys/mman.h>
> +#include <vector>
> +
> +#include <libcamera/camera_manager.h>
> +#include <libcamera/formats.h>
> +#include <libcamera/stream.h>
> +
> +#include "libcamera/internal/mapped_framebuffer.h"
> +#include "libcamera/internal/shared_mem_object.h"
> +
> +#include "debayer_egl.h"
> +#include "test.h"
> +
> +using namespace libcamera;
> +
> +class GpuStatisticsTest : public Test
> +{
> +protected:
> +	int run() override
> +	{
> +		CameraManager manager;
> +		/* configure() stores the display without creating a rendering context. */
> +		EGLDisplay display = eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA,
> +							  EGL_DEFAULT_DISPLAY, nullptr);
> +		if (display == EGL_NO_DISPLAY)
> +			return TestSkip;
> +		for (PixelFormat format : { formats::SBGGR8, formats::SGRBG8,
> +					   formats::SBGGR10_CSI2P, formats::SGRBG10_CSI2P }) {
> +			bool packed = format == formats::SBGGR10_CSI2P || format == formats::SGRBG10_CSI2P;
> +			bool bggr = format == formats::SBGGR8 || format == formats::SBGGR10_CSI2P;
> +			for (Size outputSize : { Size(1280, 720), Size(640, 480), Size(1920, 1080) }) {
> +				auto statistics = std::make_unique<SwStatsCpu>(manager);
> +				SwStatsCpu *stats = statistics.get();
> +				if (!stats->isValid())
> +					return TestFail;
> +				DebayerEGL debayer(std::move(statistics), manager, display);
> +				StreamConfiguration input, output;
> +				input.pixelFormat = format;
> +				input.size = { 1928, 1092 };
> +				input.stride = debayer.preferredInputStride(format, input.size);
> +				input.bufferCount = 4;
> +				output.pixelFormat = formats::XBGR8888;
> +				output.size = outputSize;
> +				output.stride = output.size.width * 4;
> +				output.bufferCount = 4;
> +				if (debayer.configure(input, { output }, false))
> +					return TestFail;
> +
> +				auto *shared = static_cast<const SwIspStats *>(mmap(nullptr, sizeof(SwIspStats),
> +					PROT_READ, MAP_SHARED, stats->getStatsFD().get(), 0));
> +				if (shared == MAP_FAILED)
> +					return TestFail;
> +
> +				SharedMem raw("gpu-statistics-test", input.stride * input.size.height);
> +				if (!raw) {
> +					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
> +					return TestFail;
> +				}
> +				std::fill(raw.mem().begin(), raw.mem().end(), 0);
> +				FrameBuffer::Plane plane;
> +				plane.fd = raw.fd();
> +				plane.offset = 0;
> +				plane.length = raw.mem().size();
> +				std::vector<FrameBuffer::Plane> planes = { plane };
> +				FrameBuffer buffer(planes);
> +				MappedFrameBuffer mapped(&buffer, MappedFrameBuffer::MapFlag::Read);
> +				if (!mapped.isValid()) {
> +					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
> +					return TestFail;
> +				}
> +
> +				/* Meter the original input buffer, as the GPU debayer does. */
> +				auto measure = [&](const Rectangle &target) {
> +					auto *pixels = raw.mem().data();
> +					for (unsigned int y = 0; y < input.size.height; y++) {
> +						for (unsigned int x = 0; x < input.size.width; x++) {
> +							bool green = ((x + y) & 1) == (bggr ? 1 : 0);
> +							bool white = x >= static_cast<unsigned int>(target.x) &&
> +								     x < target.x + target.width &&
> +								     y >= static_cast<unsigned int>(target.y) &&
> +								     y < target.y + target.height;
> +							unsigned int offset = packed ? x / 4 * 5 + x % 4 : x;
> +							/* Packed RAW10 statistics use the eight high bits. */
> +							pixels[y * input.stride + offset] = green || white ? 150 : 75;
> +						}
> +					}
> +					stats->processFrame(0, 0, mapped);
> +					return *shared;
> +				};
> +
> +				/*
> +				 * A 4:3 output sees 1456 sensor columns. A 16:9 output
> +				 * sees all native columns, excluding four/eight border
> +				 * columns for 8-bit/packed RAW10. The 8-bit top edge at
> +				 * row 5 is rounded down to row 4 by the Bayer alignment.
> +				 */
> +				Rectangle visible = outputSize == Size(640, 480)
> +					? Rectangle(236, 0, 1456, 1092)
> +					: packed ? Rectangle(4, 6, 1920, 1080)
> +						 : Rectangle(2, 4, 1924, 1082);
> +				uint64_t expectedSamples = uint64_t((visible.width + 3) / 4) *
> +							  ((visible.height + 3) / 4);
> +				SwIspStats background = measure({});
> +				uint64_t samples = std::accumulate(background.yHistogram.begin(),
> +								 background.yHistogram.end(), uint64_t{ 0 });
> +				if (!background.valid || !samples || samples != expectedSamples ||
> +				    !background.sum_.r() ||
> +				    !background.sum_.g() || !background.sum_.b()) {
> +					std::cerr << "Invalid background statistics: " << samples
> +						  << " samples, expected " << expectedSamples << std::endl;
> +					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
> +					return TestFail;
> +				}
> +
> +				SwIspStats left = measure({ 418, 0, 128, input.size.height });
> +				SwIspStats right = measure({ 1382, 0, 128, input.size.height });
> +				if (!left.valid || !right.valid || !left.sum_.g() || !right.sum_.g() ||
> +				    left.sum_.r() <= background.sum_.r() ||
> +				    right.sum_.r() <= background.sum_.r() ||
> +				    left.yHistogram == background.yHistogram ||
> +				    right.yHistogram == background.yHistogram) {
> +					std::cerr << "No response to pixels inside the visible crop" << std::endl;
> +					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
> +					return TestFail;
> +				}
> +
> +				double leftRatio = double(left.sum_.r()) / left.sum_.g();
> +				double rightRatio = double(right.sum_.r()) / right.sum_.g();
> +				std::cout << format << " " << outputSize << " left R/G=" << leftRatio
> +					  << " right R/G=" << rightRatio << std::endl;
> +				if (!std::isfinite(leftRatio) || !std::isfinite(rightRatio) ||
> +				    std::abs(leftRatio - rightRatio) > 0.001 ||
> +				    left.sum_.g() != right.sum_.g() ||
> +				    left.sum_.b() != right.sum_.b() ||
> +				    left.yHistogram != right.yHistogram) {
> +					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
> +					return TestFail;
> +				}
> +
> +				unsigned int x = visible.x;
> +				unsigned int y = visible.y;
> +				unsigned int rightEdge = x + visible.width;
> +				unsigned int bottomEdge = y + visible.height;
> +				for (Rectangle target : { Rectangle(x, y, 8, visible.height),
> +							  Rectangle(rightEdge - 8, y, 8, visible.height),
> +							  Rectangle(x, y, visible.width, 4),
> +							  Rectangle(x, bottomEdge - 4, visible.width, 4) }) {
> +					SwIspStats inside = measure(target);
> +					if (!inside.valid || inside.sum_.r() <= background.sum_.r() ||
> +					    inside.sum_.b() <= background.sum_.b() ||
> +					    inside.yHistogram == background.yHistogram) {
> +						std::cerr << "No response inside crop edge " << target << std::endl;
> +						munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
> +						return TestFail;
> +					}
> +				}
> +				for (Rectangle target : { Rectangle(0, 0, x, input.size.height),
> +							  Rectangle(rightEdge, 0, input.size.width - rightEdge,
> +								    input.size.height),
> +							  Rectangle(0, 0, input.size.width, y),
> +							  Rectangle(0, bottomEdge, input.size.width,
> +								    input.size.height - bottomEdge) }) {
> +					if (!target.width || !target.height)
> +						continue;
> +					SwIspStats outside = measure(target);
> +					if (!outside.valid || background.sum_ != outside.sum_ ||
> +					    background.yHistogram != outside.yHistogram) {
> +						std::cerr << "Metered pixels outside crop edge " << target << std::endl;
> +						munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
> +						return TestFail;
> +					}
> +				}
> +				munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
> +			}
> +		}
> +		return TestPass;
> +	}
> +};
> +
> +TEST_REGISTER(GpuStatisticsTest)

Patch
diff mbox series

diff --git a/test/meson.build b/test/meson.build
index 644a8cba..e7510646 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -57,60 +57,63 @@  internal_tests = [
     {'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},
+        {'name': 'software-isp-gpu-stats', 'sources': ['software-isp-gpu-stats.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]
diff --git a/test/software-isp-gpu-stats.cpp b/test/software-isp-gpu-stats.cpp
new file mode 100644
index 00000000..2575c1c4
--- /dev/null
+++ b/test/software-isp-gpu-stats.cpp
@@ -0,0 +1,188 @@ 
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+
+#include <algorithm>
+#include <cmath>
+#include <iostream>
+#include <memory>
+#include <numeric>
+#include <stdint.h>
+#include <sys/mman.h>
+#include <vector>
+
+#include <libcamera/camera_manager.h>
+#include <libcamera/formats.h>
+#include <libcamera/stream.h>
+
+#include "libcamera/internal/mapped_framebuffer.h"
+#include "libcamera/internal/shared_mem_object.h"
+
+#include "debayer_egl.h"
+#include "test.h"
+
+using namespace libcamera;
+
+class GpuStatisticsTest : public Test
+{
+protected:
+	int run() override
+	{
+		CameraManager manager;
+		/* configure() stores the display without creating a rendering context. */
+		EGLDisplay display = eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA,
+							  EGL_DEFAULT_DISPLAY, nullptr);
+		if (display == EGL_NO_DISPLAY)
+			return TestSkip;
+		for (PixelFormat format : { formats::SBGGR8, formats::SGRBG8,
+					   formats::SBGGR10_CSI2P, formats::SGRBG10_CSI2P }) {
+			bool packed = format == formats::SBGGR10_CSI2P || format == formats::SGRBG10_CSI2P;
+			bool bggr = format == formats::SBGGR8 || format == formats::SBGGR10_CSI2P;
+			for (Size outputSize : { Size(1280, 720), Size(640, 480), Size(1920, 1080) }) {
+				auto statistics = std::make_unique<SwStatsCpu>(manager);
+				SwStatsCpu *stats = statistics.get();
+				if (!stats->isValid())
+					return TestFail;
+				DebayerEGL debayer(std::move(statistics), manager, display);
+				StreamConfiguration input, output;
+				input.pixelFormat = format;
+				input.size = { 1928, 1092 };
+				input.stride = debayer.preferredInputStride(format, input.size);
+				input.bufferCount = 4;
+				output.pixelFormat = formats::XBGR8888;
+				output.size = outputSize;
+				output.stride = output.size.width * 4;
+				output.bufferCount = 4;
+				if (debayer.configure(input, { output }, false))
+					return TestFail;
+
+				auto *shared = static_cast<const SwIspStats *>(mmap(nullptr, sizeof(SwIspStats),
+					PROT_READ, MAP_SHARED, stats->getStatsFD().get(), 0));
+				if (shared == MAP_FAILED)
+					return TestFail;
+
+				SharedMem raw("gpu-statistics-test", input.stride * input.size.height);
+				if (!raw) {
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+				std::fill(raw.mem().begin(), raw.mem().end(), 0);
+				FrameBuffer::Plane plane;
+				plane.fd = raw.fd();
+				plane.offset = 0;
+				plane.length = raw.mem().size();
+				std::vector<FrameBuffer::Plane> planes = { plane };
+				FrameBuffer buffer(planes);
+				MappedFrameBuffer mapped(&buffer, MappedFrameBuffer::MapFlag::Read);
+				if (!mapped.isValid()) {
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+
+				/* Meter the original input buffer, as the GPU debayer does. */
+				auto measure = [&](const Rectangle &target) {
+					auto *pixels = raw.mem().data();
+					for (unsigned int y = 0; y < input.size.height; y++) {
+						for (unsigned int x = 0; x < input.size.width; x++) {
+							bool green = ((x + y) & 1) == (bggr ? 1 : 0);
+							bool white = x >= static_cast<unsigned int>(target.x) &&
+								     x < target.x + target.width &&
+								     y >= static_cast<unsigned int>(target.y) &&
+								     y < target.y + target.height;
+							unsigned int offset = packed ? x / 4 * 5 + x % 4 : x;
+							/* Packed RAW10 statistics use the eight high bits. */
+							pixels[y * input.stride + offset] = green || white ? 150 : 75;
+						}
+					}
+					stats->processFrame(0, 0, mapped);
+					return *shared;
+				};
+
+				/*
+				 * A 4:3 output sees 1456 sensor columns. A 16:9 output
+				 * sees all native columns, excluding four/eight border
+				 * columns for 8-bit/packed RAW10. The 8-bit top edge at
+				 * row 5 is rounded down to row 4 by the Bayer alignment.
+				 */
+				Rectangle visible = outputSize == Size(640, 480)
+					? Rectangle(236, 0, 1456, 1092)
+					: packed ? Rectangle(4, 6, 1920, 1080)
+						 : Rectangle(2, 4, 1924, 1082);
+				uint64_t expectedSamples = uint64_t((visible.width + 3) / 4) *
+							  ((visible.height + 3) / 4);
+				SwIspStats background = measure({});
+				uint64_t samples = std::accumulate(background.yHistogram.begin(),
+								 background.yHistogram.end(), uint64_t{ 0 });
+				if (!background.valid || !samples || samples != expectedSamples ||
+				    !background.sum_.r() ||
+				    !background.sum_.g() || !background.sum_.b()) {
+					std::cerr << "Invalid background statistics: " << samples
+						  << " samples, expected " << expectedSamples << std::endl;
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+
+				SwIspStats left = measure({ 418, 0, 128, input.size.height });
+				SwIspStats right = measure({ 1382, 0, 128, input.size.height });
+				if (!left.valid || !right.valid || !left.sum_.g() || !right.sum_.g() ||
+				    left.sum_.r() <= background.sum_.r() ||
+				    right.sum_.r() <= background.sum_.r() ||
+				    left.yHistogram == background.yHistogram ||
+				    right.yHistogram == background.yHistogram) {
+					std::cerr << "No response to pixels inside the visible crop" << std::endl;
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+
+				double leftRatio = double(left.sum_.r()) / left.sum_.g();
+				double rightRatio = double(right.sum_.r()) / right.sum_.g();
+				std::cout << format << " " << outputSize << " left R/G=" << leftRatio
+					  << " right R/G=" << rightRatio << std::endl;
+				if (!std::isfinite(leftRatio) || !std::isfinite(rightRatio) ||
+				    std::abs(leftRatio - rightRatio) > 0.001 ||
+				    left.sum_.g() != right.sum_.g() ||
+				    left.sum_.b() != right.sum_.b() ||
+				    left.yHistogram != right.yHistogram) {
+					munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+					return TestFail;
+				}
+
+				unsigned int x = visible.x;
+				unsigned int y = visible.y;
+				unsigned int rightEdge = x + visible.width;
+				unsigned int bottomEdge = y + visible.height;
+				for (Rectangle target : { Rectangle(x, y, 8, visible.height),
+							  Rectangle(rightEdge - 8, y, 8, visible.height),
+							  Rectangle(x, y, visible.width, 4),
+							  Rectangle(x, bottomEdge - 4, visible.width, 4) }) {
+					SwIspStats inside = measure(target);
+					if (!inside.valid || inside.sum_.r() <= background.sum_.r() ||
+					    inside.sum_.b() <= background.sum_.b() ||
+					    inside.yHistogram == background.yHistogram) {
+						std::cerr << "No response inside crop edge " << target << std::endl;
+						munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+						return TestFail;
+					}
+				}
+				for (Rectangle target : { Rectangle(0, 0, x, input.size.height),
+							  Rectangle(rightEdge, 0, input.size.width - rightEdge,
+								    input.size.height),
+							  Rectangle(0, 0, input.size.width, y),
+							  Rectangle(0, bottomEdge, input.size.width,
+								    input.size.height - bottomEdge) }) {
+					if (!target.width || !target.height)
+						continue;
+					SwIspStats outside = measure(target);
+					if (!outside.valid || background.sum_ != outside.sum_ ||
+					    background.yHistogram != outside.yHistogram) {
+						std::cerr << "Metered pixels outside crop edge " << target << std::endl;
+						munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+						return TestFail;
+					}
+				}
+				munmap(const_cast<SwIspStats *>(shared), sizeof(SwIspStats));
+			}
+		}
+		return TestPass;
+	}
+};
+
+TEST_REGISTER(GpuStatisticsTest)