diff --git a/include/libcamera/internal/software_isp/debayer_geometry.h b/include/libcamera/internal/software_isp/debayer_geometry.h
new file mode 100644
index 00000000..6c10ecb8
--- /dev/null
+++ b/include/libcamera/internal/software_isp/debayer_geometry.h
@@ -0,0 +1,41 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026, Red Hat Inc.
+ *
+ * Geometry for scaling and cropping debayered images
+ */
+
+#pragma once
+
+#include <algorithm>
+#include <array>
+
+#include <libcamera/geometry.h>
+
+namespace libcamera {
+
+/*
+ * Return a column-major projection matrix for an input-sized viewport and an
+ * output-sized framebuffer. Scale to fill the output, centering the crop on
+ * the input. All sizes must be nonzero, with nativeSize contained in inputSize.
+ */
+inline std::array<float, 16> debayerProjection(const Size &inputSize,
+					     const Size &nativeSize,
+					     const Size &outputSize)
+{
+	float scale = std::max(static_cast<float>(outputSize.width) / nativeSize.width,
+			       static_cast<float>(outputSize.height) / nativeSize.height);
+
+	/* The framebuffer center is output/viewport - 1 in normalized coordinates. */
+	float transX = static_cast<float>(outputSize.width) / inputSize.width - 1.0f;
+	float transY = static_cast<float>(outputSize.height) / inputSize.height - 1.0f;
+
+	return {
+		scale, 0, 0, 0,
+		0, scale, 0, 0,
+		0, 0, 1, 0,
+		transX, transY, 0, 1,
+	};
+}
+
+} /* namespace libcamera */
diff --git a/include/libcamera/internal/software_isp/meson.build b/include/libcamera/internal/software_isp/meson.build
index df7c3b97..e18597ed 100644
--- a/include/libcamera/internal/software_isp/meson.build
+++ b/include/libcamera/internal/software_isp/meson.build
@@ -2,6 +2,7 @@
 
 libcamera_internal_headers += files([
     'benchmark.h',
+    'debayer_geometry.h',
     'debayer_params.h',
     'software_isp.h',
     'swisp_stats.h',
diff --git a/src/libcamera/software_isp/debayer_egl.cpp b/src/libcamera/software_isp/debayer_egl.cpp
index 300822f9..1a7ad25c 100644
--- a/src/libcamera/software_isp/debayer_egl.cpp
+++ b/src/libcamera/software_isp/debayer_egl.cpp
@@ -9,7 +9,7 @@
 
 #include "debayer_egl.h"
 
-#include <algorithm>
+#include <array>
 #include <assert.h>
 #include <memory>
 #include <span>
@@ -24,6 +24,7 @@
 
 #include "libcamera/internal/formats.h"
 #include "libcamera/internal/framebuffer.h"
+#include "libcamera/internal/software_isp/debayer_geometry.h"
 
 #include "../shaders/glsl_shaders.h"
 
@@ -388,20 +389,8 @@ void DebayerEGL::setShaderVariableValues(eGLImage &eglImageIn, const DebayerPara
 	GLfloat Step[] = { static_cast<float>(bytesPerPixel_) / (inputConfig_.stride - 1),
 			   1.0f / (height_ - 1) };
 	GLfloat Stride = (GLfloat)width_ / (shaderStridePixels_ / bytesPerPixel_);
-	/*
-	 * Scale the output size from the native size the algorithm produces for
-	 * the input size. Keep the aspect ratio and prefer cropping over black
-	 * bars.
-	 */
-	GLfloat scale = std::max((GLfloat)outputSize_.width / nativeOutputSize_.width,
-				 (GLfloat)outputSize_.height / nativeOutputSize_.height);
-	GLfloat trans = -(1.0f - scale);
-	GLfloat projMatrix[] = {
-		scale, 0, 0, 0,
-		0, scale, 0, 0,
-		0, 0, 1, 0,
-		trans, trans, 0, 1
-	};
+	const std::array<float, 16> projMatrix =
+		debayerProjection({ width_, height_ }, nativeOutputSize_, outputSize_);
 	/* Static const coordinates */
 	static const GLfloat vcoordinates[4][2] = {
 		{ -1.0f, -1.0f },
@@ -445,7 +434,7 @@ void DebayerEGL::setShaderVariableValues(eGLImage &eglImageIn, const DebayerPara
 	glUniform2fv(textureUniformSize_, 1, imgSize);
 	glUniform2fv(textureUniformStep_, 1, Step);
 	glUniform1f(textureUniformStrideFactor_, Stride);
-	glUniformMatrix4fv(textureUniformProjMatrix_, 1, GL_FALSE, projMatrix);
+	glUniformMatrix4fv(textureUniformProjMatrix_, 1, GL_FALSE, projMatrix.data());
 
 	LOG(Debayer, Debug) << "vertexIn " << attributeVertex_ << " textureIn " << attributeTexture_
 			    << " tex_y " << textureUniformBayerDataIn_
