diff --git a/test/meson.build b/test/meson.build
index 5027b7b1..fc72d105 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -8,60 +8,61 @@ endif
 test_enabled = true
 
 # When ASan is enabled, find the path to the ASan runtime needed by multiple
 # tests. This currently works with gcc only, as clang uses different file names
 # depending on the compiler version and target architecture.
 asan_enabled = false
 asan_runtime_missing = false
 
 if get_option('b_sanitize').contains('address')
     asan_enabled = true
 
     if cc.get_id() == 'gcc'
         asan_runtime = run_command(cc, '-print-file-name=libasan.so', check : true).stdout().strip()
     else
         asan_runtime_missing = true
     endif
 endif
 
 subdir('libtest')
 
 subdir('camera')
 subdir('controls')
 subdir('gstreamer')
 subdir('ipa')
 subdir('ipc')
 subdir('log')
 subdir('media_device')
 subdir('process')
 subdir('py')
 subdir('serialization')
+subdir('software_isp')
 subdir('stream')
 subdir('v4l2_compat')
 subdir('v4l2_subdevice')
 subdir('v4l2_videodevice')
 
 public_tests = [
     {'name': 'color-space', 'sources': ['color-space.cpp']},
     {'name': 'geometry', 'sources': ['geometry.cpp']},
     {'name': 'public-api', 'sources': ['public-api.cpp']},
     {'name': 'signal', 'sources': ['signal.cpp']},
     {'name': 'transform', 'sources': ['transform.cpp']},
 ]
 
 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']},
     {'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']},
diff --git a/test/software_isp/debayer-crop.cpp b/test/software_isp/debayer-crop.cpp
new file mode 100644
index 00000000..4f9f8b9a
--- /dev/null
+++ b/test/software_isp/debayer-crop.cpp
@@ -0,0 +1,102 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ * Copyright (C) 2026, Red Hat Inc.
+ *
+ * CPU debayer crop configuration tests
+ */
+
+#include <functional>
+#include <iostream>
+#include <memory>
+#include <tuple>
+#include <utility>
+#include <vector>
+
+#include <libcamera/camera_manager.h>
+#include <libcamera/formats.h>
+#include <libcamera/stream.h>
+
+#include "libcamera/internal/formats.h"
+#include "libcamera/internal/software_isp/swstats_cpu.h"
+
+#include "debayer_cpu.h"
+#include "test.h"
+
+using namespace libcamera;
+
+class DebayerCropTest : public Test
+{
+protected:
+	int run() override
+	{
+		CameraManager manager;
+		auto stats = std::make_unique<SwStatsCpu>(manager);
+		if (!stats->isValid())
+			return TestFail;
+
+		DebayerCpu debayer(std::move(stats), manager);
+		static const struct {
+			PixelFormat input;
+			int rgbShift;
+			int bgrShift;
+		} cases[] = {
+			{ formats::SBGGR8, 0, 1 },
+			{ formats::SGBRG8, 1, 0 },
+			{ formats::SGRBG8, 0, 1 },
+			{ formats::SRGGB8, 1, 0 },
+			{ formats::SBGGR10, 0, 1 },
+			{ formats::SGBRG10, 1, 0 },
+			{ formats::SGRBG10, 0, 1 },
+			{ formats::SRGGB10, 1, 0 },
+			{ formats::SBGGR12, 0, 1 },
+			{ formats::SGBRG12, 1, 0 },
+			{ formats::SGRBG12, 0, 1 },
+			{ formats::SRGGB12, 1, 0 },
+			/* Packed kernels use each Bayer order without a phase shift. */
+			{ formats::SBGGR10_CSI2P, 0, 0 },
+			{ formats::SGBRG10_CSI2P, 0, 0 },
+			{ formats::SGRBG10_CSI2P, 0, 0 },
+			{ formats::SRGGB10_CSI2P, 0, 0 },
+			{ formats::SBGGR12_CSI2P, 0, 0 },
+			{ formats::SGBRG12_CSI2P, 0, 0 },
+			{ formats::SGRBG12_CSI2P, 0, 0 },
+			{ formats::SRGGB12_CSI2P, 0, 0 },
+		};
+
+		for (const auto &entry : cases) {
+			StreamConfiguration input;
+			input.pixelFormat = entry.input;
+			input.size = { 1928, 1092 };
+			input.stride = PixelFormatInfo::info(entry.input).stride(input.size.width, 0);
+
+			for (PixelFormat format : { formats::RGB888, formats::BGR888,
+						    formats::XRGB8888, formats::XBGR8888 }) {
+				StreamConfiguration output;
+				output.pixelFormat = format;
+				output.size = { 1280, 720 };
+				std::tie(output.stride, output.frameSize) =
+					debayer.strideAndFrameSize(format, output.size);
+				const std::vector<std::reference_wrapper<const StreamConfiguration>> outputs{ output };
+				if (debayer.configure(input, outputs, false, false)) {
+					std::cerr << "Unable to configure " << entry.input << " -> " << format
+						  << std::endl;
+					return TestFail;
+				}
+
+				int shift = (format == formats::RGB888 || format == formats::XRGB8888)
+						    ? entry.rgbShift
+						    : entry.bgrShift;
+				Rectangle expected(324 + shift, 186, 1280, 720);
+				if (debayer.inputCrop() != expected) {
+					std::cerr << "Crop for " << entry.input << " -> " << format << " is "
+						  << debayer.inputCrop() << ", expected " << expected << std::endl;
+					return TestFail;
+				}
+			}
+		}
+
+		return TestPass;
+	}
+};
+
+TEST_REGISTER(DebayerCropTest)
diff --git a/test/software_isp/meson.build b/test/software_isp/meson.build
new file mode 100644
index 00000000..ebabde59
--- /dev/null
+++ b/test/software_isp/meson.build
@@ -0,0 +1,20 @@
+# SPDX-License-Identifier: CC0-1.0
+
+if not softisp_enabled
+    subdir_done()
+endif
+
+software_isp_tests = [
+    {'name': 'debayer-crop', 'sources': ['debayer-crop.cpp']},
+]
+
+foreach test : software_isp_tests
+    exe = executable(test['name'], test['sources'],
+                     dependencies : libcamera_private,
+                     implicit_include_directories : false,
+                     link_with : test_libraries,
+                     include_directories : [test_includes_internal,
+                                            '../../src/libcamera/software_isp/'])
+
+    test(test['name'], exe, suite : 'software_isp')
+endforeach
