From patchwork Thu Oct 15 19:43:33 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Ray Strode X-Patchwork-Id: 28463 Return-Path: X-Original-To: parsemail@patchwork.libcamera.org Delivered-To: parsemail@patchwork.libcamera.org Received: from lancelot.ideasonboard.com (lancelot.ideasonboard.com [92.243.16.209]) by patchwork.libcamera.org (Postfix) with ESMTPS id 9CCE7C3357 for ; Thu, 15 Oct 2026 19:49:49 +0000 (UTC) Received: from lancelot.ideasonboard.com (localhost [IPv6:::1]) by lancelot.ideasonboard.com (Postfix) with ESMTP id 5EAB168E11; Thu, 15 Oct 2026 21:49:46 +0200 (CEST) Authentication-Results: lancelot.ideasonboard.com; dkim=pass (2048-bit key; unprotected) header.d=gmail.com header.i=@gmail.com header.b="ABNYUkHr"; dkim-atps=neutral Received: from mail-qv1-xf31.google.com (mail-qv1-xf31.google.com [IPv6:2607:f8b0:4864:20::f31]) by lancelot.ideasonboard.com (Postfix) with ESMTPS id D2AA668E06 for ; Thu, 15 Oct 2026 21:43:48 +0200 (CEST) Received: by mail-qv1-xf31.google.com with SMTP id 6a1803df08f44-9196d8543cbso16862726d6.1 for ; Thu, 15 Oct 2026 12:43:48 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1792093428; x=1792698228; darn=lists.libcamera.org; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:to:from:from:to:cc:subject:date:message-id :reply-to:content-type; bh=I2xnkr/NsP46i9rQdxwWnQWmmpo9yYSsaSYJOaVHbgQ=; b=ABNYUkHrR/PRQigHT5o8dZnUCT2xaw2VjQCgxlrNeBKW5VH+YZUJtWyWdtKvU4/O3p nYLBA/IfrIjoR/J0XgOrfX4JpKzHjTAtp0U5VaPAGjEiNZMdFZZ43J1NFuccfDLPf888 1uzzX3mFoqUqFGl+jE+J+VkATSfSMqTalL08749qUbT/dY0wCynviMRu7U+qFSWp0NVy P+jSOUs/VwLbTVye/nnHINoeK4APV7wwmHj3SUc337JS/+BrDwXv+dxOkKbfiyzsiIkY 5EPq6cnAVnEpA/IOgCUt6OjA9PI+OPLIBbjwYzuEsQFKomrkB8CITTQ9gJjAH3hV9m2i FVVQ== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20260707; t=1792093428; x=1792698228; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:to:from:x-gm-gg:x-gm-message-state:from:to :cc:subject:date:message-id:reply-to:content-type; bh=I2xnkr/NsP46i9rQdxwWnQWmmpo9yYSsaSYJOaVHbgQ=; b=dDdly82dtSAJt38dRVdoRPm2hWXuKnYkhG7EcHVc9I7DF0Bh8vNmRoTQt9Tr0+Ifag mOInAJUaQsMcvRZ8SUmx1i4CA5E7Nl4ZF9Ow9/vic7euVZOVHrnNXfOJFGDLvjI6UPYl YmoqNTDzSaTrX6b56IYtULtK1wLhzf1HAgHn0Hl5ZsCCGM3Vn/vGgUFvfqUfOBpdFZ8F Isee672Gk73gXvqws3yhPMnyvcNlAFYpxWF5ahTyPqLIcoibXb2fRZhM6sEjWw3z/WoP FD8w+32tzcOjDfYw6SvgD4dNDt3XaMotzlJHDe7tJ7sRennlboLLD2hE4RM5HOnHsSuE w4KQ== X-Gm-Message-State: AFq9FYKpbdCDVAiQBC0riHIU+7H4bPHiT7dT+U6+8I3u8N5QXu7CKA7y fn7WTO5kPGVjk/pTOJ1+Rnr/roIKwKJ6qZBoV1mMTxHmPks4ogOD0O8wM5NxXRJIWBo= X-Gm-Gg: AYBFou3ylnWqERWMbyppKVJz3yGRNiWW9pLuwmkeL0GVPLq6OTJEMYJyIX+45EZs+IS 27IeoU2jyaTeZHfainR+s1mMLwW2VmrM/Lg4/cJb1+FtbpCc8IcJO9f+N54ZjVMWQAq+wGhf3Fu itMGADgb40xNpJGonIg6lgXYWymLvYlxJvqocVkep3fAL8+QAlUoUtXFyTzQ5QPi+Gts1TfwEon 8gcirSqjmIS5kBDNYjwAc8uR3AkDxFWzV0V9kk8pQRATqmGOE09VUmXdHrpAW279pogY4gC5Zje ozhXiuTQ+BzN2nXj80qNsqrpvkoNgeEn53VW0fDNxIsQD0mdvbZ4Q5Hq8JwgmkPxvlYUYDhcWOb qTgbWYsWF5RrIbpk/9s4ltDJJM+HZrAYGIAKzYywUO/3SDb80k1GeO1iJ8U88L760jc5GS2XESE eSfoQmnUK7f+6p+vPRg0w7AB3I+jZCr6fwmG1hBExVPTfgxD0GylABpobLANz1YeE8oBuTm30XD URPIyK5xXd/K8MZZh4dsocLCw== X-Received: by 2002:a05:620a:90ca:10b0:93f:1936:b206 with SMTP id af79cd13be357-93f1936bae3mr75969585a.83.1792093427383; Thu, 15 Oct 2026 12:43:47 -0700 (PDT) Received: from localhost ([144.121.52.163]) by smtp.gmail.com with ESMTPSA id af79cd13be357-93f1a18176esm15370385a.20.2026.10.15.12.43.46 for (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Thu, 15 Oct 2026 12:43:46 -0700 (PDT) From: Ray Strode To: libcamera-devel@lists.libcamera.org Subject: [PATCH 2/4] tests: Validate rendered GPU crop across output sizes Date: Thu, 15 Oct 2026 15:43:33 -0400 Message-ID: <20261015194335.287960-3-halfline@gmail.com> X-Mailer: git-send-email 2.56.0 In-Reply-To: <20261015194335.287960-1-halfline@gmail.com> References: <20261015194335.287960-1-halfline@gmail.com> MIME-Version: 1.0 X-Mailman-Approved-At: Thu, 15 Oct 2026 21:49:41 +0200 X-BeenThere: libcamera-devel@lists.libcamera.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: libcamera-devel-bounces@lists.libcamera.org Sender: "libcamera-devel" From: Ray Strode 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 --- 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 +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#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 reference; + Size nativeSize; + for (Size size : { Size(1920, 1080), Size(1280, 720), + Size(640, 480), Size(512, 768) }) { + std::vector 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 &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 *image, Size *nativeSize) + { + auto stats = std::make_unique(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> 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)