diff --git a/test/debayer-geometry.cpp b/test/debayer-geometry.cpp
new file mode 100644
index 00000000..27458aa1
--- /dev/null
+++ b/test/debayer-geometry.cpp
@@ -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)
diff --git a/test/meson.build b/test/meson.build
index 363a2829..5027b7b1 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -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']},
