[2/2] libcamera: pipeline: virtual: Add raw_frames config and Bayer format support
diff mbox series

Message ID 20260504091623.3354474-3-maxbretschneider@protonmail.com
State New
Headers show
Series
  • libcamera: pipeline: virtual: Add raw Bayer frame support
Related show

Commit Message

Max Bretschneider May 4, 2026, 9:17 a.m. UTC
From: Max Bretschneider <maxbretschneider@protonmail.com>

Extend parseFrameGenerator() to handle the new raw_frames YAML key
alongside the existing test_pattern and frames keys. File collection
logic (e.g. either single file or directory with natural sorting)
follows the existing frames path handling. This raw_frames block
accepts:
- path (single file or directory)
- bayer_order (RGGB, BGGR, GRBG, GBRG)
- bit_depth (8, 10, 12, 14, 16), in accordance to libcamera/formats.yaml

Extend generateConfiguration() to return a Bayer StreamConfiguration
when the StreamRole::Raw is requested on a camera configured with
raw_frames. Extend validate() to allow Bayer formats, also set the correct stride
and frameSize for single plane Bayer buffers. Sensor properties required
for SoftISP integration are also declared in match().

Signed-off-by: Max Bretschneider <maxbretschneider@protonmail.com>
---
 .../pipeline/virtual/config_parser.cpp        | 100 +++++++++++++-
 src/libcamera/pipeline/virtual/virtual.cpp    | 123 +++++++++++++++---
 src/libcamera/pipeline/virtual/virtual.h      |   3 +-
 3 files changed, 203 insertions(+), 23 deletions(-)

Comments

Pőcze Barnabás May 4, 2026, 10:19 a.m. UTC | #1
2026. 05. 04. 11:17 keltezéssel, maxbretschneider@protonmail.com írta:
> From: Max Bretschneider <maxbretschneider@protonmail.com>
> 
> Extend parseFrameGenerator() to handle the new raw_frames YAML key
> alongside the existing test_pattern and frames keys. File collection
> logic (e.g. either single file or directory with natural sorting)
> follows the existing frames path handling. This raw_frames block
> accepts:
> - path (single file or directory)
> - bayer_order (RGGB, BGGR, GRBG, GBRG)
> - bit_depth (8, 10, 12, 14, 16), in accordance to libcamera/formats.yaml
> 
> Extend generateConfiguration() to return a Bayer StreamConfiguration
> when the StreamRole::Raw is requested on a camera configured with
> raw_frames. Extend validate() to allow Bayer formats, also set the correct stride
> and frameSize for single plane Bayer buffers. Sensor properties required
> for SoftISP integration are also declared in match().
> 
> Signed-off-by: Max Bretschneider <maxbretschneider@protonmail.com>
> ---

Given that RGB data is easily convertible, I was wondering if you have
considered the possibility of making such a frame generator that converts
an rgb image (e.g. by a `TestPatternGenerator`).

Ideally the "normal" and "raw" images should come from the same source,
but I see the virtual pipeline handler is not quite set up for that.
(Each stream should have its own custom configuration so that different
types of generators could be used. And possibly raw streams could designate
another rgb stream as the source. Although this goes in kind of the opposite
direction of using softisp here.)


>   .../pipeline/virtual/config_parser.cpp        | 100 +++++++++++++-
>   src/libcamera/pipeline/virtual/virtual.cpp    | 123 +++++++++++++++---
>   src/libcamera/pipeline/virtual/virtual.h      |   3 +-
>   3 files changed, 203 insertions(+), 23 deletions(-)
> 
> diff --git a/src/libcamera/pipeline/virtual/config_parser.cpp b/src/libcamera/pipeline/virtual/config_parser.cpp
> index 5169fd39..fcce70c8 100644
> --- a/src/libcamera/pipeline/virtual/config_parser.cpp
> +++ b/src/libcamera/pipeline/virtual/config_parser.cpp
> @@ -156,13 +156,20 @@ int ConfigParser::parseFrameGenerator(const ValueNode &cameraConfigData, Virtual
>   {
>   	const std::string testPatternKey = "test_pattern";
>   	const std::string framesKey = "frames";
> -	if (cameraConfigData.contains(testPatternKey)) {
> -		if (cameraConfigData.contains(framesKey)) {
> -			LOG(Virtual, Error) << "A camera should use either "
> -					    << testPatternKey << " or " << framesKey;
> -			return -EINVAL;
> -		}
> +	const std::string rawFramesKey = "raw_frames";
> +
> +	/* Ensure only one frame source is specified */
> +	int sourcesSpecified = cameraConfigData.contains(testPatternKey) +
> +			       cameraConfigData.contains(framesKey) +
> +			       cameraConfigData.contains(rawFramesKey);
> +	if (sourcesSpecified > 1) {
> +		LOG(Virtual, Error) << "A camera should use only one of "
> +				    << testPatternKey << ", " << framesKey
> +				    << ", or " << rawFramesKey;
> +		return -EINVAL;
> +	}
> 
> +	if (cameraConfigData.contains(testPatternKey)) {
>   		auto testPattern = cameraConfigData[testPatternKey].get<std::string>("");
> 
>   		if (testPattern == "bars") {
> @@ -178,6 +185,87 @@ int ConfigParser::parseFrameGenerator(const ValueNode &cameraConfigData, Virtual
>   		return 0;
>   	}
> 
> +	if (cameraConfigData.contains(rawFramesKey)) {
> +		const ValueNode &rawFrames = cameraConfigData[rawFramesKey];
> +
> +		if (!rawFrames.isDictionary()) {
> +			LOG(Virtual, Error) << "'raw_frames' is not a dictionary.";
> +			return -EINVAL;
> +		}
> +
> +		auto path = rawFrames["path"].get<std::string>();
> +		if (!path) {
> +			LOG(Virtual, Error) << "raw_frames: path must be specified.";
> +			return -EINVAL;
> +		}
> +
> +		std::vector<std::filesystem::path> files;
> +
> +		switch (std::filesystem::symlink_status(*path).type()) {
> +		case std::filesystem::file_type::regular:
> +			files.push_back(*path);
> +			break;
> +
> +		case std::filesystem::file_type::directory:
> +			for (const auto &dentry : std::filesystem::directory_iterator{ *path })
> +				if (dentry.is_regular_file())
> +					files.push_back(dentry.path());
> +
> +			std::sort(files.begin(), files.end(), [](const auto &a, const auto &b) {
> +				return ::strverscmp(a.c_str(), b.c_str()) < 0;
> +			});
> +
> +			if (files.empty()) {
> +				LOG(Virtual, Error) << "raw_frames directory has no files: " << *path;
> +				return -EINVAL;
> +			}
> +			break;
> +		default:
> +			LOG(Virtual, Error) << "raw_frames path: " << *path << " is not supported";
> +			return -EINVAL;
> +		}

I think you could move the above loop into a separate local function to avoid duplication.


> +
> +		/* Parse bayer_order */
> +		auto bayerOrder = rawFrames["bayer_order"].get<std::string>();
> +		if (!bayerOrder) {
> +			LOG(Virtual, Error) << "raw_frames: bayer_order must be specified.";
> +			return -EINVAL;
> +		}
> +
> +		static const std::map<std::string, uint32_t> bayerOrderMap = {
> +			{ "RGGB", properties::draft::ColorFilterArrangementEnum::RGGB },
> +			{ "BGGR", properties::draft::ColorFilterArrangementEnum::BGGR },
> +			{ "GRBG", properties::draft::ColorFilterArrangementEnum::GRBG },
> +			{ "GBRG", properties::draft::ColorFilterArrangementEnum::GBRG },
> +		};
> +
> +		auto it = bayerOrderMap.find(*bayerOrder);
> +		if (it == bayerOrderMap.end()) {
> +			LOG(Virtual, Error) << "raw_frames: unsupported bayer_order: "
> +					    << *bayerOrder
> +					    << ", must be one of RGGB, BGGR, GRBG, GBRG";
> +			return -EINVAL;
> +		}
> +
> +		/* Parse bit_depth */
> +		auto bitDepth = rawFrames["bit_depth"].get<unsigned int>();
> +		if (!bitDepth) {
> +			LOG(Virtual, Error) << "raw_frames: bit_depth must be specified.";
> +			return -EINVAL;
> +		}
> +
> +		static const std::set<unsigned int> supportedBitDepths = { 8, 10, 12, 14, 16 };
> +		if (supportedBitDepths.find(*bitDepth) == supportedBitDepths.end()) {
> +			LOG(Virtual, Error) << "raw_frames: bit_depth unsupported: " << *bitDepth
> +					    << ", must be one of 8, 10, 12, 14, 16";
> +			return -EINVAL;
> +		}
> +
> +		data->config_.frame = RawFrames{ std::move(files), it->second, *bitDepth };
> +
> +		return 0;
> +	}
> +
>   	const ValueNode &frames = cameraConfigData[framesKey];
> 
>   	/* When there is no frames provided in the config file, use color bar test pattern */
> diff --git a/src/libcamera/pipeline/virtual/virtual.cpp b/src/libcamera/pipeline/virtual/virtual.cpp
> index 81d2ddda..beec2f5a 100644
> --- a/src/libcamera/pipeline/virtual/virtual.cpp
> +++ b/src/libcamera/pipeline/virtual/virtual.cpp
> @@ -19,6 +19,7 @@
>   #include <string>
>   #include <time.h>
>   #include <utility>
> +#include <variant>
>   #include <vector>
> 
>   #include <libcamera/base/flags.h>
> @@ -37,7 +38,6 @@
>   #include "libcamera/internal/framebuffer.h"
>   #include "libcamera/internal/pipeline_handler.h"
>   #include "libcamera/internal/request.h"
> -#include "libcamera/internal/value_node.h"
> 
>   #include "pipeline/virtual/config_parser.h"
> 
> @@ -202,21 +202,28 @@ CameraConfiguration::Status VirtualCameraConfiguration::validate()
>   			adjusted = true;
>   		}
> 
> -		if (cfg.pixelFormat != formats::NV12) {
> -			cfg.pixelFormat = formats::NV12;
> -			status = Adjusted;
> -			adjusted = true;
> -		}
> +		const PixelFormatInfo &fmtInfo = PixelFormatInfo::info(cfg.pixelFormat);
> +		const bool rawStream = fmtInfo.colourEncoding == PixelFormatInfo::ColourEncodingRAW;
> 
> -		if (cfg.colorSpace != ColorSpace::Rec709) {
> -			cfg.colorSpace = ColorSpace::Rec709;
> -			status = Adjusted;
> -			adjusted = true;
> -		}
> +		if (!rawStream) {
> +			if (cfg.pixelFormat != formats::NV12) {
> +				cfg.pixelFormat = formats::NV12;
> +				status = Adjusted;
> +				adjusted = true;
> +			}
> 
> -		if (validateColorSpaces() == Adjusted) {
> -			status = Adjusted;
> -			adjusted = true;
> +			if (cfg.colorSpace != ColorSpace::Rec709) {
> +				cfg.colorSpace = ColorSpace::Rec709;
> +				status = Adjusted;
> +				adjusted = true;
> +			}
> +
> +			if (validateColorSpaces() == Adjusted) {
> +				status = Adjusted;
> +				adjusted = true;
> +			}
> +		} else {
> +			cfg.colorSpace = ColorSpace::Raw;
>   		}
> 
>   		if (adjusted)
> @@ -267,7 +274,64 @@ PipelineHandlerVirtual::generateConfiguration(Camera *camera,
>   		case StreamRole::Viewfinder:
>   			break;
> 
> -		case StreamRole::Raw:
> +		case StreamRole::Raw: {
> +			if (!std::holds_alternative<RawFrames>(data->config_.frame)) {
> +				LOG(Virtual, Error)
> +					<< "StreamRole::Raw requested but camera is not configured with raw_frames";
> +				return {};
> +			}
> +
> +			const auto &rawFrames = std::get<RawFrames>(data->config_.frame);

   const auto *rawFrames = std::get_if<RawFrames>(&data->config_.frame);
   if (!rawFrames)
     ...


> +			PixelFormat rawFormat;
> +
> +			/*
> +			 * \todo Possibly replace with a 2D lookup table to
> +			 * be able to just index by (cfaPatter, bitDepth),
> +			 * might be cleaner.
> +			 */

Have you looked at the `BayerFormat` class? Specifically its `toPixelFormat()` member function?


> +			auto bayerFormat = [&](PixelFormat f8, PixelFormat f10,
> +					       PixelFormat f12, PixelFormat f14,
> +					       PixelFormat f16) {
> +				switch (rawFrames.bitDepth) {
> +				case 8:
> +					return f8;
> +				case 10:
> +					return f10;
> +				case 12:
> +					return f12;
> +				case 14:
> +					return f14;
> +				default:
> +					return f16;
> +				}
> +			};
> +
> +			/* Map bayer order and bit depth to pixel format */
> +			if (rawFrames.cfaPattern == properties::draft::ColorFilterArrangementEnum::RGGB)
> +				rawFormat = bayerFormat(formats::SRGGB8, formats::SRGGB10, formats::SRGGB12, formats::SRGGB14, formats::SRGGB16);
> +			else if (rawFrames.cfaPattern == properties::draft::ColorFilterArrangementEnum::BGGR)
> +				rawFormat = bayerFormat(formats::SBGGR8, formats::SBGGR10, formats::SBGGR12, formats::SBGGR14, formats::SBGGR16);
> +			else if (rawFrames.cfaPattern == properties::draft::ColorFilterArrangementEnum::GRBG)
> +				rawFormat = bayerFormat(formats::SGRBG8, formats::SGRBG10, formats::SGRBG12, formats::SGRBG14, formats::SGRBG16);
> +			else
> +				rawFormat = bayerFormat(formats::SGBRG8, formats::SGBRG10, formats::SGBRG12, formats::SGBRG14, formats::SGBRG16);
> +
> +			/*
> +			 * Use the Bayer format matching the raw frame
> +			 * configuration.
> +			 */
> +			std::map<PixelFormat, std::vector<SizeRange>> rawStreamFormats;
> +			rawStreamFormats[rawFormat] = { { data->config_.minResolutionSize, data->config_.maxResolutionSize } };
> +			StreamFormats rawFormats(rawStreamFormats);
> +			StreamConfiguration rawCfg(rawFormats);
> +			rawCfg.pixelFormat = rawFormat;
> +			rawCfg.size = data->config_.maxResolutionSize;
> +			rawCfg.bufferCount = VirtualCameraConfiguration::kBufferCount;
> +			rawCfg.colorSpace = ColorSpace::Raw;
> +			config->addConfiguration(rawCfg);
> +			continue;
> +		}
> +
>   		default:
>   			LOG(Virtual, Error)
>   				<< "Requested stream role not supported: " << role;
> @@ -401,6 +465,28 @@ bool PipelineHandlerVirtual::match([[maybe_unused]] DeviceEnumerator *enumerator
>   		std::set<Stream *> streams;
>   		for (auto &streamConfig : data->streamConfigs_)
>   			streams.insert(&streamConfig.stream);
> +
> +		/* Add sensor properties and controls required by SoftISP for raw streams */
> +		if (std::holds_alternative<RawFrames>(data->config_.frame)) {

   if (const auto *rawFrames = std::get_if<RawFrames>(&data->config_.frame)) {


> +			const auto &rawFrames = std::get<RawFrames>(data->config_.frame);
> +			data->properties_.set(properties::draft::ColorFilterArrangement, static_cast<int32_t>(rawFrames.cfaPattern));
> +			data->properties_.set(properties::PixelArraySize, data->config_.maxResolutionSize);
> +			data->properties_.set(properties::UnitCellSize, Size(1000, 1000));
> +
> +			/* Extends existing controlInfo_ with SoftISP required controls */
> +			ControlInfoMap::Map controls;
> +			for (const auto &[id, info] : data->controlInfo_)
> +				controls[id] = info;
> +
> +			/* \todo Allow configuration via YAML */
> +			controls[&controls::AnalogueGain] = ControlInfo(1.0f, 16.0f, 1.0f);
> +			controls[&controls::ExposureTime] = ControlInfo(100, 33333, 10000);
> +			controls[&controls::AeEnable] = ControlInfo(false, true, true);
> +			controls[&controls::AwbEnable] = ControlInfo(false, true, true);

I think I would drop these, and any mention of softisp, as they are not applicable at the moment.


> +
> +			data->controlInfo_ = ControlInfoMap(std::move(controls), controls::controls);
> +		}
> +
>   		std::string id = data->config_.id;
>   		std::shared_ptr<Camera> camera = Camera::create(std::move(data), id, streams);
> 
> @@ -434,7 +520,12 @@ bool PipelineHandlerVirtual::initFrameGenerator(Camera *camera)
>   			   [&](ImageFrames &imageFrames) {
>   				   for (auto &streamConfig : data->streamConfigs_)
>   					   streamConfig.frameGenerator = ImageFrameGenerator::create(imageFrames);
> -			   } },
> +			   },
> +			   [&](RawFrames &rawFrames) {
> +				   for (auto &streamConfig : data->streamConfigs_)
> +					   streamConfig.frameGenerator = RawFrameGenerator::create(rawFrames);
> +			   },
> +		   },
>   		   frame);
> 
>   	for (auto &streamConfig : data->streamConfigs_)
> diff --git a/src/libcamera/pipeline/virtual/virtual.h b/src/libcamera/pipeline/virtual/virtual.h
> index 215e56fa..48392808 100644
> --- a/src/libcamera/pipeline/virtual/virtual.h
> +++ b/src/libcamera/pipeline/virtual/virtual.h
> @@ -23,11 +23,12 @@
> 
>   #include "frame_generator.h"
>   #include "image_frame_generator.h"
> +#include "raw_frame_generator.h"
>   #include "test_pattern_generator.h"
> 
>   namespace libcamera {
> 
> -using VirtualFrame = std::variant<TestPattern, ImageFrames>;
> +using VirtualFrame = std::variant<TestPattern, ImageFrames, RawFrames>;
> 
>   class VirtualCameraData : public Camera::Private,
>   			  public Thread,
> --
> 2.43.0
> 
>

Patch
diff mbox series

diff --git a/src/libcamera/pipeline/virtual/config_parser.cpp b/src/libcamera/pipeline/virtual/config_parser.cpp
index 5169fd39..fcce70c8 100644
--- a/src/libcamera/pipeline/virtual/config_parser.cpp
+++ b/src/libcamera/pipeline/virtual/config_parser.cpp
@@ -156,13 +156,20 @@  int ConfigParser::parseFrameGenerator(const ValueNode &cameraConfigData, Virtual
 {
 	const std::string testPatternKey = "test_pattern";
 	const std::string framesKey = "frames";
-	if (cameraConfigData.contains(testPatternKey)) {
-		if (cameraConfigData.contains(framesKey)) {
-			LOG(Virtual, Error) << "A camera should use either "
-					    << testPatternKey << " or " << framesKey;
-			return -EINVAL;
-		}
+	const std::string rawFramesKey = "raw_frames";
+
+	/* Ensure only one frame source is specified */
+	int sourcesSpecified = cameraConfigData.contains(testPatternKey) +
+			       cameraConfigData.contains(framesKey) +
+			       cameraConfigData.contains(rawFramesKey);
+	if (sourcesSpecified > 1) {
+		LOG(Virtual, Error) << "A camera should use only one of "
+				    << testPatternKey << ", " << framesKey
+				    << ", or " << rawFramesKey;
+		return -EINVAL;
+	}
 
+	if (cameraConfigData.contains(testPatternKey)) {
 		auto testPattern = cameraConfigData[testPatternKey].get<std::string>("");
 
 		if (testPattern == "bars") {
@@ -178,6 +185,87 @@  int ConfigParser::parseFrameGenerator(const ValueNode &cameraConfigData, Virtual
 		return 0;
 	}
 
+	if (cameraConfigData.contains(rawFramesKey)) {
+		const ValueNode &rawFrames = cameraConfigData[rawFramesKey];
+
+		if (!rawFrames.isDictionary()) {
+			LOG(Virtual, Error) << "'raw_frames' is not a dictionary.";
+			return -EINVAL;
+		}
+
+		auto path = rawFrames["path"].get<std::string>();
+		if (!path) {
+			LOG(Virtual, Error) << "raw_frames: path must be specified.";
+			return -EINVAL;
+		}
+
+		std::vector<std::filesystem::path> files;
+
+		switch (std::filesystem::symlink_status(*path).type()) {
+		case std::filesystem::file_type::regular:
+			files.push_back(*path);
+			break;
+
+		case std::filesystem::file_type::directory:
+			for (const auto &dentry : std::filesystem::directory_iterator{ *path })
+				if (dentry.is_regular_file())
+					files.push_back(dentry.path());
+
+			std::sort(files.begin(), files.end(), [](const auto &a, const auto &b) {
+				return ::strverscmp(a.c_str(), b.c_str()) < 0;
+			});
+
+			if (files.empty()) {
+				LOG(Virtual, Error) << "raw_frames directory has no files: " << *path;
+				return -EINVAL;
+			}
+			break;
+		default:
+			LOG(Virtual, Error) << "raw_frames path: " << *path << " is not supported";
+			return -EINVAL;
+		}
+
+		/* Parse bayer_order */
+		auto bayerOrder = rawFrames["bayer_order"].get<std::string>();
+		if (!bayerOrder) {
+			LOG(Virtual, Error) << "raw_frames: bayer_order must be specified.";
+			return -EINVAL;
+		}
+
+		static const std::map<std::string, uint32_t> bayerOrderMap = {
+			{ "RGGB", properties::draft::ColorFilterArrangementEnum::RGGB },
+			{ "BGGR", properties::draft::ColorFilterArrangementEnum::BGGR },
+			{ "GRBG", properties::draft::ColorFilterArrangementEnum::GRBG },
+			{ "GBRG", properties::draft::ColorFilterArrangementEnum::GBRG },
+		};
+
+		auto it = bayerOrderMap.find(*bayerOrder);
+		if (it == bayerOrderMap.end()) {
+			LOG(Virtual, Error) << "raw_frames: unsupported bayer_order: "
+					    << *bayerOrder
+					    << ", must be one of RGGB, BGGR, GRBG, GBRG";
+			return -EINVAL;
+		}
+
+		/* Parse bit_depth */
+		auto bitDepth = rawFrames["bit_depth"].get<unsigned int>();
+		if (!bitDepth) {
+			LOG(Virtual, Error) << "raw_frames: bit_depth must be specified.";
+			return -EINVAL;
+		}
+
+		static const std::set<unsigned int> supportedBitDepths = { 8, 10, 12, 14, 16 };
+		if (supportedBitDepths.find(*bitDepth) == supportedBitDepths.end()) {
+			LOG(Virtual, Error) << "raw_frames: bit_depth unsupported: " << *bitDepth
+					    << ", must be one of 8, 10, 12, 14, 16";
+			return -EINVAL;
+		}
+
+		data->config_.frame = RawFrames{ std::move(files), it->second, *bitDepth };
+
+		return 0;
+	}
+
 	const ValueNode &frames = cameraConfigData[framesKey];
 
 	/* When there is no frames provided in the config file, use color bar test pattern */
diff --git a/src/libcamera/pipeline/virtual/virtual.cpp b/src/libcamera/pipeline/virtual/virtual.cpp
index 81d2ddda..beec2f5a 100644
--- a/src/libcamera/pipeline/virtual/virtual.cpp
+++ b/src/libcamera/pipeline/virtual/virtual.cpp
@@ -19,6 +19,7 @@ 
 #include <string>
 #include <time.h>
 #include <utility>
+#include <variant>
 #include <vector>
 
 #include <libcamera/base/flags.h>
@@ -37,7 +38,6 @@ 
 #include "libcamera/internal/framebuffer.h"
 #include "libcamera/internal/pipeline_handler.h"
 #include "libcamera/internal/request.h"
-#include "libcamera/internal/value_node.h"
 
 #include "pipeline/virtual/config_parser.h"
 
@@ -202,21 +202,28 @@  CameraConfiguration::Status VirtualCameraConfiguration::validate()
 			adjusted = true;
 		}
 
-		if (cfg.pixelFormat != formats::NV12) {
-			cfg.pixelFormat = formats::NV12;
-			status = Adjusted;
-			adjusted = true;
-		}
+		const PixelFormatInfo &fmtInfo = PixelFormatInfo::info(cfg.pixelFormat);
+		const bool rawStream = fmtInfo.colourEncoding == PixelFormatInfo::ColourEncodingRAW;
 
-		if (cfg.colorSpace != ColorSpace::Rec709) {
-			cfg.colorSpace = ColorSpace::Rec709;
-			status = Adjusted;
-			adjusted = true;
-		}
+		if (!rawStream) {
+			if (cfg.pixelFormat != formats::NV12) {
+				cfg.pixelFormat = formats::NV12;
+				status = Adjusted;
+				adjusted = true;
+			}
 
-		if (validateColorSpaces() == Adjusted) {
-			status = Adjusted;
-			adjusted = true;
+			if (cfg.colorSpace != ColorSpace::Rec709) {
+				cfg.colorSpace = ColorSpace::Rec709;
+				status = Adjusted;
+				adjusted = true;
+			}
+
+			if (validateColorSpaces() == Adjusted) {
+				status = Adjusted;
+				adjusted = true;
+			}
+		} else {
+			cfg.colorSpace = ColorSpace::Raw;
 		}
 
 		if (adjusted)
@@ -267,7 +274,64 @@  PipelineHandlerVirtual::generateConfiguration(Camera *camera,
 		case StreamRole::Viewfinder:
 			break;
 
-		case StreamRole::Raw:
+		case StreamRole::Raw: {
+			if (!std::holds_alternative<RawFrames>(data->config_.frame)) {
+				LOG(Virtual, Error)
+					<< "StreamRole::Raw requested but camera is not configured with raw_frames";
+				return {};
+			}
+
+			const auto &rawFrames = std::get<RawFrames>(data->config_.frame);
+			PixelFormat rawFormat;
+
+			/*
+			 * \todo Possibly replace with a 2D lookup table to
+			 * be able to just index by (cfaPatter, bitDepth),
+			 * might be cleaner.
+			 */
+			auto bayerFormat = [&](PixelFormat f8, PixelFormat f10,
+					       PixelFormat f12, PixelFormat f14,
+					       PixelFormat f16) {
+				switch (rawFrames.bitDepth) {
+				case 8:
+					return f8;
+				case 10:
+					return f10;
+				case 12:
+					return f12;
+				case 14:
+					return f14;
+				default:
+					return f16;
+				}
+			};
+
+			/* Map bayer order and bit depth to pixel format */
+			if (rawFrames.cfaPattern == properties::draft::ColorFilterArrangementEnum::RGGB)
+				rawFormat = bayerFormat(formats::SRGGB8, formats::SRGGB10, formats::SRGGB12, formats::SRGGB14, formats::SRGGB16);
+			else if (rawFrames.cfaPattern == properties::draft::ColorFilterArrangementEnum::BGGR)
+				rawFormat = bayerFormat(formats::SBGGR8, formats::SBGGR10, formats::SBGGR12, formats::SBGGR14, formats::SBGGR16);
+			else if (rawFrames.cfaPattern == properties::draft::ColorFilterArrangementEnum::GRBG)
+				rawFormat = bayerFormat(formats::SGRBG8, formats::SGRBG10, formats::SGRBG12, formats::SGRBG14, formats::SGRBG16);
+			else
+				rawFormat = bayerFormat(formats::SGBRG8, formats::SGBRG10, formats::SGBRG12, formats::SGBRG14, formats::SGBRG16);
+
+			/*
+			 * Use the Bayer format matching the raw frame
+			 * configuration.
+			 */
+			std::map<PixelFormat, std::vector<SizeRange>> rawStreamFormats;
+			rawStreamFormats[rawFormat] = { { data->config_.minResolutionSize, data->config_.maxResolutionSize } };
+			StreamFormats rawFormats(rawStreamFormats);
+			StreamConfiguration rawCfg(rawFormats);
+			rawCfg.pixelFormat = rawFormat;
+			rawCfg.size = data->config_.maxResolutionSize;
+			rawCfg.bufferCount = VirtualCameraConfiguration::kBufferCount;
+			rawCfg.colorSpace = ColorSpace::Raw;
+			config->addConfiguration(rawCfg);
+			continue;
+		}
+
 		default:
 			LOG(Virtual, Error)
 				<< "Requested stream role not supported: " << role;
@@ -401,6 +465,28 @@  bool PipelineHandlerVirtual::match([[maybe_unused]] DeviceEnumerator *enumerator
 		std::set<Stream *> streams;
 		for (auto &streamConfig : data->streamConfigs_)
 			streams.insert(&streamConfig.stream);
+
+		/* Add sensor properties and controls required by SoftISP for raw streams */
+		if (std::holds_alternative<RawFrames>(data->config_.frame)) {
+			const auto &rawFrames = std::get<RawFrames>(data->config_.frame);
+			data->properties_.set(properties::draft::ColorFilterArrangement, static_cast<int32_t>(rawFrames.cfaPattern));
+			data->properties_.set(properties::PixelArraySize, data->config_.maxResolutionSize);
+			data->properties_.set(properties::UnitCellSize, Size(1000, 1000));
+
+			/* Extends existing controlInfo_ with SoftISP required controls */
+			ControlInfoMap::Map controls;
+			for (const auto &[id, info] : data->controlInfo_)
+				controls[id] = info;
+
+			/* \todo Allow configuration via YAML */
+			controls[&controls::AnalogueGain] = ControlInfo(1.0f, 16.0f, 1.0f);
+			controls[&controls::ExposureTime] = ControlInfo(100, 33333, 10000);
+			controls[&controls::AeEnable] = ControlInfo(false, true, true);
+			controls[&controls::AwbEnable] = ControlInfo(false, true, true);
+
+			data->controlInfo_ = ControlInfoMap(std::move(controls), controls::controls);
+		}
+
 		std::string id = data->config_.id;
 		std::shared_ptr<Camera> camera = Camera::create(std::move(data), id, streams);
 
@@ -434,7 +520,12 @@  bool PipelineHandlerVirtual::initFrameGenerator(Camera *camera)
 			   [&](ImageFrames &imageFrames) {
 				   for (auto &streamConfig : data->streamConfigs_)
 					   streamConfig.frameGenerator = ImageFrameGenerator::create(imageFrames);
-			   } },
+			   },
+			   [&](RawFrames &rawFrames) {
+				   for (auto &streamConfig : data->streamConfigs_)
+					   streamConfig.frameGenerator = RawFrameGenerator::create(rawFrames);
+			   },
+		   },
 		   frame);
 
 	for (auto &streamConfig : data->streamConfigs_)
diff --git a/src/libcamera/pipeline/virtual/virtual.h b/src/libcamera/pipeline/virtual/virtual.h
index 215e56fa..48392808 100644
--- a/src/libcamera/pipeline/virtual/virtual.h
+++ b/src/libcamera/pipeline/virtual/virtual.h
@@ -23,11 +23,12 @@ 
 
 #include "frame_generator.h"
 #include "image_frame_generator.h"
+#include "raw_frame_generator.h"
 #include "test_pattern_generator.h"
 
 namespace libcamera {
 
-using VirtualFrame = std::variant<TestPattern, ImageFrames>;
+using VirtualFrame = std::variant<TestPattern, ImageFrames, RawFrames>;
 
 class VirtualCameraData : public Camera::Private,
 			  public Thread,