new file mode 100644
@@ -0,0 +1,96 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ * Copyright (C) 2026, Red Hat Inc.
+ *
+ * Debayer projection tests
+ */
+
+#include <array>
+#include <cmath>
+#include <iostream>
+#include <utility>
+
+#include <libcamera/geometry.h>
+
+#include "libcamera/internal/software_isp/debayer_geometry.h"
+
+#include "test.h"
+
+using namespace libcamera;
+
+class DebayerGeometryTest : public Test
+{
+protected:
+ int run() override
+ {
+ if (testProjection())
+ return TestFail;
+
+ return TestPass;
+ }
+
+private:
+ int testProjection()
+ {
+ static const struct {
+ Size input;
+ Size native;
+ Size output;
+ Rectangle visible;
+ } cases[] = {
+ /* No border or scaling. */
+ { { 1920, 1080 }, { 1920, 1080 }, { 1920, 1080 }, { 0, 0, 1920, 1080 } },
+ /* Native output splits the Bayer border between both sides. */
+ { { 1924, 1080 }, { 1920, 1080 }, { 1920, 1080 }, { 2, 0, 1920, 1080 } },
+ { { 1928, 1092 }, { 1920, 1092 }, { 1920, 1092 }, { 4, 0, 1920, 1092 } },
+ /* Vertical cropping, with and without downscaling. */
+ { { 1928, 1092 }, { 1920, 1092 }, { 1920, 1080 }, { 4, 6, 1920, 1080 } },
+ { { 1928, 1092 }, { 1920, 1092 }, { 1280, 720 }, { 4, 6, 1920, 1080 } },
+ /* Horizontal cropping for 4:3 and portrait outputs. */
+ { { 1928, 1092 }, { 1920, 1092 }, { 640, 480 }, { 236, 0, 1456, 1092 } },
+ { { 1928, 1092 }, { 1920, 1092 }, { 512, 768 }, { 600, 0, 728, 1092 } },
+ /* A wider output removes more rows from each side. */
+ { { 1928, 1092 }, { 1920, 1092 }, { 1280, 640 }, { 4, 66, 1920, 960 } },
+ };
+
+ for (const auto &entry : cases) {
+ const std::array<float, 16> matrix =
+ debayerProjection(entry.input, entry.native, entry.output);
+ int left = entry.visible.x;
+ int top = entry.visible.y;
+ int right = left + entry.visible.width;
+ int bottom = top + entry.visible.height;
+ const std::pair<Point, Point> points[] = {
+ { { left, top }, { 0, 0 } },
+ { { right, top }, { static_cast<int>(entry.output.width), 0 } },
+ { { left, bottom }, { 0, static_cast<int>(entry.output.height) } },
+ { { right, bottom }, { static_cast<int>(entry.output.width), static_cast<int>(entry.output.height) } },
+ { Rectangle(entry.input).center(), Rectangle(entry.output).center() },
+ };
+
+ for (const auto &[sensor, expected] : points) {
+ double x = 2.0 * sensor.x / entry.input.width - 1.0;
+ double y = 2.0 * sensor.y / entry.input.height - 1.0;
+ double w = matrix[3] * x + matrix[7] * y + matrix[15];
+ double clipX = (matrix[0] * x + matrix[4] * y + matrix[12]) / w;
+ double clipY = (matrix[1] * x + matrix[5] * y + matrix[13]) / w;
+ /* The viewport has the input dimensions, even for smaller outputs. */
+ double outputX = (clipX + 1.0) * entry.input.width / 2.0;
+ double outputY = (clipY + 1.0) * entry.input.height / 2.0;
+
+ if (!std::isfinite(outputX) || !std::isfinite(outputY) ||
+ std::abs(outputX - expected.x) > 0.001 ||
+ std::abs(outputY - expected.y) > 0.001) {
+ std::cerr << "Projection " << entry.input << " -> " << entry.output
+ << " maps " << sensor << " to (" << outputX << ", " << outputY
+ << "), expected " << expected << std::endl;
+ return TestFail;
+ }
+ }
+ }
+
+ return TestPass;
+ }
+};
+
+TEST_REGISTER(DebayerGeometryTest)
@@ -52,6 +52,7 @@ 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': 'debayer-geometry', 'sources': ['debayer-geometry.cpp']},
{'name': 'delayed_controls', 'sources': ['delayed_controls.cpp']},
{'name': 'event', 'sources': ['event.cpp']},
{'name': 'event-dispatcher', 'sources': ['event-dispatcher.cpp']},