diff --git a/include/libcamera/internal/software_isp/debayer_params.h b/include/libcamera/internal/software_isp/debayer_params.h
index 2d69bd295..1c0412d75 100644
--- a/include/libcamera/internal/software_isp/debayer_params.h
+++ b/include/libcamera/internal/software_isp/debayer_params.h
@@ -1,6 +1,6 @@
 /* SPDX-License-Identifier: LGPL-2.1-or-later */
 /*
- * Copyright (C) 2023-2025 Red Hat Inc.
+ * Copyright (C) 2023-2026 Red Hat Inc.
  *
  * Authors:
  * Hans de Goede <hdegoede@redhat.com>
@@ -10,7 +10,6 @@
 
 #pragma once
 
-#include <array>
 #include <stdint.h>
 
 #include "libcamera/internal/matrix.h"
@@ -19,47 +18,11 @@
 namespace libcamera {
 
 struct DebayerParams {
-	static constexpr unsigned int kRGBLookupSize = 256;
-
-	struct CcmColumn {
-		int16_t r;
-		int16_t g;
-		int16_t b;
-	};
-
-	using LookupTable = std::array<uint8_t, kRGBLookupSize>;
-	using CcmLookupTable = std::array<CcmColumn, kRGBLookupSize>;
-
-	/*
-	 * Color lookup tables when CCM is not used.
-	 *
-	 * Each color of a debayered pixel is amended by the corresponding
-	 * value in the given table.
-	 */
-	LookupTable red;
-	LookupTable green;
-	LookupTable blue;
-
-	/*
-	 * Color and gamma lookup tables when CCM is used.
-	 *
-	 * Each of the CcmLookupTable's corresponds to a CCM column; together they
-	 * make a complete 3x3 CCM lookup table. The CCM is applied on debayered
-	 * pixels and then the gamma lookup table is used to set the resulting
-	 * values of all the three colors.
-	 */
-	CcmLookupTable redCcm;
-	CcmLookupTable greenCcm;
-	CcmLookupTable blueCcm;
-	LookupTable gammaLut;
-
-	/*
-	 * Per frame corrections as calculated by the IPA
-	 */
-	Matrix<float, 3, 3> ccm;
+	Matrix<float, 3, 3> combinedMatrix;
 	RGB<float> blackLevel;
 	float gamma;
 	float contrastExp;
+	RGB<float> gains;
 };
 
 } /* namespace libcamera */
diff --git a/src/ipa/simple/algorithms/adjust.cpp b/src/ipa/simple/algorithms/adjust.cpp
index 60a191380..8e2230164 100644
--- a/src/ipa/simple/algorithms/adjust.cpp
+++ b/src/ipa/simple/algorithms/adjust.cpp
@@ -1,7 +1,7 @@
 /* SPDX-License-Identifier: LGPL-2.1-or-later */
 /*
  * Copyright (C) 2024, Ideas On Board
- * Copyright (C) 2024-2025, Red Hat Inc.
+ * Copyright (C) 2024-2026, Red Hat Inc.
  *
  * Common image adjustments
  */
@@ -92,22 +92,20 @@ void Adjust::applySaturation(Matrix<float, 3, 3> &matrix, float saturation)
 void Adjust::prepare(IPAContext &context,
 		     [[maybe_unused]] const uint32_t frame,
 		     IPAFrameContext &frameContext,
-		     [[maybe_unused]] DebayerParams *params)
+		     DebayerParams *params)
 {
 	frameContext.contrast = context.activeState.knobs.contrast;
 
-	if (!context.ccmEnabled)
-		return;
-
 	auto &saturation = context.activeState.knobs.saturation;
-	frameContext.saturation = saturation;
-	if (saturation)
+	if (context.ccmEnabled && saturation) {
 		applySaturation(context.activeState.combinedMatrix, saturation.value());
-
-	if (saturation != lastSaturation_) {
-		context.activeState.matrixChanged = true;
-		lastSaturation_ = saturation;
+		frameContext.saturation = saturation;
 	}
+
+	params->gamma = 1.0 / context.activeState.knobs.gamma.value_or(kDefaultGamma);
+	const float contrast = context.activeState.knobs.contrast.value_or(kDefaultContrast);
+	params->contrastExp = tan(std::clamp(contrast * M_PI_4, 0.0, M_PI_2 - 0.00001));
+	params->combinedMatrix = context.activeState.combinedMatrix;
 }
 
 void Adjust::process([[maybe_unused]] IPAContext &context,
diff --git a/src/ipa/simple/algorithms/adjust.h b/src/ipa/simple/algorithms/adjust.h
index 11d8297ca..02e469a50 100644
--- a/src/ipa/simple/algorithms/adjust.h
+++ b/src/ipa/simple/algorithms/adjust.h
@@ -47,8 +47,6 @@ public:
 
 private:
 	void applySaturation(Matrix<float, 3, 3> &ccm, float saturation);
-
-	std::optional<float> lastSaturation_;
 };
 
 } /* namespace ipa::soft::algorithms */
diff --git a/src/ipa/simple/algorithms/awb.cpp b/src/ipa/simple/algorithms/awb.cpp
index 4d2f1df15..d361cd6c5 100644
--- a/src/ipa/simple/algorithms/awb.cpp
+++ b/src/ipa/simple/algorithms/awb.cpp
@@ -37,7 +37,7 @@ int Awb::configure(IPAContext &context,
 void Awb::prepare(IPAContext &context,
 		  [[maybe_unused]] const uint32_t frame,
 		  IPAFrameContext &frameContext,
-		  [[maybe_unused]] DebayerParams *params)
+		  DebayerParams *params)
 {
 	auto &gains = context.activeState.awb.gains;
 	Matrix<float, 3, 3> gainMatrix = { { gains.r(), 0, 0,
@@ -45,9 +45,12 @@ void Awb::prepare(IPAContext &context,
 					     0, 0, gains.b() } };
 	context.activeState.combinedMatrix =
 		context.activeState.combinedMatrix * gainMatrix;
-	/* Just report, the gains are applied in LUT algorithm. */
+
 	frameContext.gains.red = gains.r();
 	frameContext.gains.blue = gains.b();
+
+	params->gains = gains;
+	params->combinedMatrix = context.activeState.combinedMatrix;
 }
 
 void Awb::process(IPAContext &context,
diff --git a/src/ipa/simple/algorithms/ccm.cpp b/src/ipa/simple/algorithms/ccm.cpp
index 1b2c6eb3a..aa06b5274 100644
--- a/src/ipa/simple/algorithms/ccm.cpp
+++ b/src/ipa/simple/algorithms/ccm.cpp
@@ -42,7 +42,7 @@ int Ccm::init([[maybe_unused]] IPAContext &context, const YamlObject &tuningData
 }
 
 void Ccm::prepare(IPAContext &context, [[maybe_unused]] const uint32_t frame,
-		  IPAFrameContext &frameContext, [[maybe_unused]] DebayerParams *params)
+		  IPAFrameContext &frameContext, DebayerParams *params)
 {
 	const unsigned int ct = context.activeState.awb.temperatureK;
 
@@ -52,13 +52,13 @@ void Ccm::prepare(IPAContext &context, [[maybe_unused]] const uint32_t frame,
 		currentCcm_ = ccm_.getInterpolated(ct);
 		ccmAssigned_ = true;
 		lastCt_ = ct;
-		context.activeState.matrixChanged = true;
 	}
 
 	context.activeState.combinedMatrix =
 		currentCcm_ * context.activeState.combinedMatrix;
 	context.activeState.ccm = currentCcm_;
 	frameContext.ccm = currentCcm_;
+	params->combinedMatrix = context.activeState.combinedMatrix;
 }
 
 void Ccm::process([[maybe_unused]] IPAContext &context,
diff --git a/src/ipa/simple/algorithms/lut.cpp b/src/ipa/simple/algorithms/lut.cpp
deleted file mode 100644
index f31a7e631..000000000
--- a/src/ipa/simple/algorithms/lut.cpp
+++ /dev/null
@@ -1,141 +0,0 @@
-/* SPDX-License-Identifier: LGPL-2.1-or-later */
-/*
- * Copyright (C) 2024-2026, Red Hat Inc.
- *
- * Color lookup tables construction
- */
-
-#include "lut.h"
-
-#include <algorithm>
-#include <cmath>
-#include <optional>
-#include <stdint.h>
-
-#include <libcamera/base/log.h>
-
-#include <libcamera/control_ids.h>
-
-#include "simple/ipa_context.h"
-
-#include "adjust.h"
-
-namespace libcamera {
-
-LOG_DEFINE_CATEGORY(IPASoftLut)
-
-namespace ipa::soft::algorithms {
-
-int Lut::configure(IPAContext &context,
-		   [[maybe_unused]] const IPAConfigInfo &configInfo)
-{
-	updateGammaTable(context);
-
-	return 0;
-}
-
-void Lut::updateGammaTable(IPAContext &context)
-{
-	const auto blackLevel = context.activeState.blc.level;
-	const auto gamma =
-		1.0 / context.activeState.knobs.gamma.value_or(kDefaultGamma);
-	const auto contrast = context.activeState.knobs.contrast.value_or(1.0);
-	/* Convert 0..2 to 0..infinity; avoid actual inifinity at tan(pi/2) */
-	float contrastExp = tan(std::clamp(contrast * M_PI_4, 0.0, M_PI_2 - 0.00001));
-
-	if (!context.gpuIspEnabled) {
-		auto &gammaTable = context.activeState.gamma.gammaTable;
-		const unsigned int blackIndex = blackLevel * gammaTable.size() / 256;
-		const float divisor = gammaTable.size() - blackIndex - 1.0;
-		for (unsigned int i = blackIndex; i < gammaTable.size(); i++) {
-			double normalized = (i - blackIndex) / divisor;
-			/* Apply simple S-curve */
-			if (normalized < 0.5)
-				normalized = 0.5 * std::pow(normalized / 0.5, contrastExp);
-			else
-				normalized = 1.0 - 0.5 * std::pow((1.0 - normalized) / 0.5, contrastExp);
-			gammaTable[i] = UINT8_MAX * std::pow(normalized, gamma);
-		}
-		/*
-		 * Due to CCM operations, the table lookup may reach indices below the black
-		 * level. Let's set the table values below black level to the minimum
-		 * non-black value to prevent problems when the minimum value is
-		 * significantly non-zero (for example, when the image should be all grey).
-		 */
-		std::fill(gammaTable.begin(), gammaTable.begin() + blackIndex,
-			  gammaTable[blackIndex]);
-	}
-
-	context.activeState.gamma.gamma = gamma;
-	context.activeState.gamma.blackLevel = blackLevel;
-	context.activeState.gamma.contrastExp = contrastExp;
-}
-
-int16_t Lut::matrixValue(unsigned int i, float ccm) const
-{
-	return std::round(i * ccm);
-}
-
-void Lut::prepare(IPAContext &context,
-		  [[maybe_unused]] const uint32_t frame,
-		  [[maybe_unused]] IPAFrameContext &frameContext,
-		  DebayerParams *params)
-{
-	/*
-	 * Update the gamma table if needed. This means if black level changes
-	 * and since the black level gets updated only if a lower value is
-	 * observed, it's not permanently prone to minor fluctuations or
-	 * rounding errors.
-	 */
-	const bool gammaUpdateNeeded =
-		context.activeState.gamma.blackLevel != context.activeState.blc.level ||
-		context.activeState.gamma.contrast != context.activeState.knobs.contrast;
-	if (gammaUpdateNeeded)
-		updateGammaTable(context);
-
-	auto &gains = context.activeState.awb.gains;
-	auto &gammaTable = context.activeState.gamma.gammaTable;
-	const unsigned int gammaTableSize = gammaTable.size();
-	const double div = static_cast<double>(DebayerParams::kRGBLookupSize) /
-			   gammaTableSize;
-
-	if (!context.ccmEnabled) {
-		for (unsigned int i = 0; i < DebayerParams::kRGBLookupSize; i++) {
-			/* Apply gamma after gain! */
-			const RGB<float> lutGains = (gains * i / div).min(gammaTableSize - 1);
-			params->red[i] = gammaTable[static_cast<unsigned int>(lutGains.r())];
-			params->green[i] = gammaTable[static_cast<unsigned int>(lutGains.g())];
-			params->blue[i] = gammaTable[static_cast<unsigned int>(lutGains.b())];
-		}
-	} else if (context.activeState.matrixChanged || gammaUpdateNeeded) {
-		auto &matrix = context.activeState.combinedMatrix;
-		auto &red = params->redCcm;
-		auto &green = params->greenCcm;
-		auto &blue = params->blueCcm;
-		params->ccm = matrix;
-		if (!context.gpuIspEnabled) {
-			for (unsigned int i = 0; i < DebayerParams::kRGBLookupSize; i++) {
-				red[i].r = matrixValue(i, matrix[0][0]);
-				red[i].g = matrixValue(i, matrix[1][0]);
-				red[i].b = matrixValue(i, matrix[2][0]);
-				green[i].r = matrixValue(i, matrix[0][1]);
-				green[i].g = matrixValue(i, matrix[1][1]);
-				green[i].b = matrixValue(i, matrix[2][1]);
-				blue[i].r = matrixValue(i, matrix[0][2]);
-				blue[i].g = matrixValue(i, matrix[1][2]);
-				blue[i].b = matrixValue(i, matrix[2][2]);
-				params->gammaLut[i] = gammaTable[i / div];
-			}
-		}
-		context.activeState.matrixChanged = false;
-	}
-
-	params->gamma = context.activeState.gamma.gamma;
-	params->contrastExp = context.activeState.gamma.contrastExp;
-}
-
-REGISTER_IPA_ALGORITHM(Lut, "Lut")
-
-} /* namespace ipa::soft::algorithms */
-
-} /* namespace libcamera */
diff --git a/src/ipa/simple/algorithms/lut.h b/src/ipa/simple/algorithms/lut.h
deleted file mode 100644
index ad16d1e8e..000000000
--- a/src/ipa/simple/algorithms/lut.h
+++ /dev/null
@@ -1,35 +0,0 @@
-/* SPDX-License-Identifier: LGPL-2.1-or-later */
-/*
- * Copyright (C) 2024, Red Hat Inc.
- *
- * Color lookup tables construction
- */
-
-#pragma once
-
-#include "algorithm.h"
-
-namespace libcamera {
-
-namespace ipa::soft::algorithms {
-
-class Lut : public Algorithm
-{
-public:
-	Lut() = default;
-	~Lut() = default;
-
-	int configure(IPAContext &context, const IPAConfigInfo &configInfo) override;
-	void prepare(IPAContext &context,
-		     const uint32_t frame,
-		     IPAFrameContext &frameContext,
-		     DebayerParams *params) override;
-
-private:
-	void updateGammaTable(IPAContext &context);
-	int16_t matrixValue(unsigned int i, float ccm) const;
-};
-
-} /* namespace ipa::soft::algorithms */
-
-} /* namespace libcamera */
diff --git a/src/ipa/simple/algorithms/meson.build b/src/ipa/simple/algorithms/meson.build
index ebe9f20dd..73c637220 100644
--- a/src/ipa/simple/algorithms/meson.build
+++ b/src/ipa/simple/algorithms/meson.build
@@ -6,5 +6,4 @@ soft_simple_ipa_algorithms = files([
     'agc.cpp',
     'blc.cpp',
     'ccm.cpp',
-    'lut.cpp',
 ])
diff --git a/src/ipa/simple/data/uncalibrated.yaml b/src/ipa/simple/data/uncalibrated.yaml
index e389e0588..c6feda36d 100644
--- a/src/ipa/simple/data/uncalibrated.yaml
+++ b/src/ipa/simple/data/uncalibrated.yaml
@@ -15,6 +15,5 @@ algorithms:
                  0, 1, 0,
                  0, 0, 1]
   - Adjust:
-  - Lut:
   - Agc:
 ...
diff --git a/src/ipa/simple/ipa_context.h b/src/ipa/simple/ipa_context.h
index 85455bbfa..aec864d02 100644
--- a/src/ipa/simple/ipa_context.h
+++ b/src/ipa/simple/ipa_context.h
@@ -53,18 +53,8 @@ struct IPAActiveState {
 		unsigned int temperatureK;
 	} awb;
 
-	static constexpr unsigned int kGammaLookupSize = 1024;
-	struct {
-		std::array<double, kGammaLookupSize> gammaTable;
-		uint8_t blackLevel;
-		float gamma;
-		float contrast;
-		float contrastExp;
-	} gamma;
-
 	Matrix<float, 3, 3> ccm;
 	Matrix<float, 3, 3> combinedMatrix;
-	bool matrixChanged = false;
 
 	struct {
 		std::optional<float> gamma;
diff --git a/src/ipa/simple/soft_simple.cpp b/src/ipa/simple/soft_simple.cpp
index 732e82510..b85e09501 100644
--- a/src/ipa/simple/soft_simple.cpp
+++ b/src/ipa/simple/soft_simple.cpp
@@ -26,6 +26,7 @@
 #include "libcamera/internal/software_isp/swisp_stats.h"
 #include "libcamera/internal/yaml_parser.h"
 
+#include "algorithms/adjust.h"
 #include "libipa/camera_sensor_helper.h"
 
 #include "module.h"
@@ -161,6 +162,11 @@ int IPASoftSimple::init(const IPASettings &settings,
 		}
 
 		params_ = static_cast<DebayerParams *>(mem);
+		params_->blackLevel = { { 0.0, 0.0, 0.0 } };
+		params_->gamma = 1.0 / algorithms::kDefaultGamma;
+		params_->contrastExp = 1.0;
+		params_->gains = { { 1.0, 1.0, 1.0 } };
+		/* combinedMatrix is reset for each frame. */
 	}
 
 	{
@@ -282,7 +288,9 @@ void IPASoftSimple::queueRequest(const uint32_t frame, const ControlList &contro
 
 void IPASoftSimple::computeParams(const uint32_t frame)
 {
-	context_.activeState.combinedMatrix = Matrix<float, 3, 3>::identity();
+	Matrix<float, 3, 3> combinedMatrix = Matrix<float, 3, 3>::identity();
+	context_.activeState.combinedMatrix = combinedMatrix;
+	params_->combinedMatrix = combinedMatrix;
 
 	IPAFrameContext &frameContext = context_.frameContexts.get(frame);
 	for (auto const &algo : algorithms())
diff --git a/src/libcamera/software_isp/debayer.cpp b/src/libcamera/software_isp/debayer.cpp
index 65a1762dd..dccdd86b4 100644
--- a/src/libcamera/software_isp/debayer.cpp
+++ b/src/libcamera/software_isp/debayer.cpp
@@ -1,7 +1,7 @@
 /* SPDX-License-Identifier: LGPL-2.1-or-later */
 /*
  * Copyright (C) 2023, Linaro Ltd
- * Copyright (C) 2023-2025 Red Hat Inc.
+ * Copyright (C) 2023-2026 Red Hat Inc.
  *
  * Authors:
  * Hans de Goede <hdegoede@redhat.com>
@@ -25,99 +25,28 @@ namespace libcamera {
  */
 
 /**
- * \var DebayerParams::kRGBLookupSize
- * \brief Size of a color lookup table
+ * \var DebayerParams::gains
+ * \brief Colour channel gains
  */
 
 /**
- * \struct DebayerParams::CcmColumn
- * \brief Type of a single column of a color correction matrix (CCM)
- *
- * When multiplying an input pixel, columns in the CCM correspond to the red,
- * green or blue component of input pixel values, while rows correspond to the
- * red, green or blue components of the output pixel values. The members of the
- * CcmColumn structure are named after the colour components of the output pixel
- * values they correspond to.
- */
-
-/**
- * \var DebayerParams::CcmColumn::r
- * \brief Red (first) component of a CCM column
- */
-
-/**
- * \var DebayerParams::CcmColumn::g
- * \brief Green (second) component of a CCM column
- */
-
-/**
- * \var DebayerParams::CcmColumn::b
- * \brief Blue (third) component of a CCM column
- */
-
-/**
- * \typedef DebayerParams::LookupTable
- * \brief Type of the lookup tables for single lookup values
- */
-
-/**
- * \typedef DebayerParams::CcmLookupTable
- * \brief Type of the CCM lookup tables for red, green, blue values
- */
-
-/**
- * \var DebayerParams::red
- * \brief Lookup table for red color, mapping input values to output values
- */
-
-/**
- * \var DebayerParams::green
- * \brief Lookup table for green color, mapping input values to output values
- */
-
-/**
- * \var DebayerParams::blue
- * \brief Lookup table for blue color, mapping input values to output values
- */
-
-/**
- * \var DebayerParams::redCcm
- * \brief Lookup table for the CCM red column, mapping input values to output values
- */
-
-/**
- * \var DebayerParams::greenCcm
- * \brief Lookup table for the CCM green column, mapping input values to output values
- */
-
-/**
- * \var DebayerParams::blueCcm
- * \brief Lookup table for the CCM blue column, mapping input values to output values
- */
-
-/**
- * \var DebayerParams::gammaLut
- * \brief Gamma lookup table used with color correction matrix
- */
-
-/**
- * \var DebayerParams::ccm
- * \brief Per frame colour correction matrix for GPUISP
+ * \var DebayerParams::combinedMatrix
+ * \brief Colour correction matrix, including other adjustments
  */
 
 /**
  * \var DebayerParams::blackLevel
- * \brief Blacklevel gains for the GPUISP
+ * \brief Black level values
  */
 
 /**
  * \var DebayerParams::gamma
- * \brief Gamma value for the GPUISP
+ * \brief Gamma value, e.g. 1/2.2
  */
 
 /**
  * \var DebayerParams::contrastExp
- * \brief Contrast value for GPUISP
+ * \brief Contrast value to be used as an exponent
  */
 
 /**
@@ -131,13 +60,6 @@ LOG_DEFINE_CATEGORY(Debayer)
 
 Debayer::Debayer(const GlobalConfiguration &configuration) : bench_(configuration)
 {
-	/* Initialize color lookup tables */
-	for (unsigned int i = 0; i < DebayerParams::kRGBLookupSize; i++) {
-		red_[i] = green_[i] = blue_[i] = i;
-		redCcm_[i] = { static_cast<int16_t>(i), 0, 0 };
-		greenCcm_[i] = { 0, static_cast<int16_t>(i), 0 };
-		blueCcm_[i] = { 0, 0, static_cast<int16_t>(i) };
-	}
 }
 
 Debayer::~Debayer()
@@ -305,56 +227,6 @@ Debayer::~Debayer()
  * \brief Output size object
  */
 
-/**
- * \var Debayer::red_
- * \brief Lookup table for red channel gain and correction values
- *
- * This table provides precomputed per-pixel or per-intensity
- * correction values for the red color channel used during debayering.
- */
-
-/**
- * \var Debayer::green_
- * \brief Lookup table for green channel gain and correction values
- *
- * This table provides precomputed per-pixel or per-intensity
- * correction values for the green color channel used during debayering.
- */
-
-/**
- * \var Debayer::blue_
- * \brief Lookup table for blue channel gain and correction values
- *
- * This table provides precomputed per-pixel or per-intensity
- * correction values for the blue color channel used during debayering.
- */
-
-/**
- * \var Debayer::redCcm_
- * \brief Red channel Color Correction Matrix (CCM) lookup table
- *
- * Contains coefficients for green channel color correction.
- */
-
-/**
- * \var Debayer::greenCcm_
- * \brief Green channel Color Correction Matrix (CCM) lookup table
- *
- * Contains coefficients for green channel color correction.
- */
-
-/**
- * \var Debayer::blueCcm_
- * \brief Blue channel Color Correction Matrix (CCM) lookup table
- *
- * Contains coefficients for blue channel color correction.
- */
-
-/**
- * \var Debayer::gammaLut_
- * \brief Gamma correction lookup table
- */
-
 /**
  * \var Debayer::swapRedBlueGains_
  * \brief Flag indicating whether red and blue channel gains should be swapped
@@ -396,34 +268,6 @@ Debayer::~Debayer()
  * DebayerEGL::start.
  */
 
-/**
- * \fn void Debayer::setParams(DebayerParams &params)
- * \brief Select the bayer params to use for the next frame debayer
- * \param[in] params The parameters to be used in debayering
- */
-void Debayer::setParams(DebayerParams &params)
-{
-	green_ = params.green;
-	greenCcm_ = params.greenCcm;
-	if (swapRedBlueGains_) {
-		red_ = params.blue;
-		blue_ = params.red;
-		redCcm_ = params.blueCcm;
-		blueCcm_ = params.redCcm;
-		for (unsigned int i = 0; i < 256; i++) {
-			std::swap(redCcm_[i].r, redCcm_[i].b);
-			std::swap(greenCcm_[i].r, greenCcm_[i].b);
-			std::swap(blueCcm_[i].r, blueCcm_[i].b);
-		}
-	} else {
-		red_ = params.red;
-		blue_ = params.blue;
-		redCcm_ = params.redCcm;
-		blueCcm_ = params.blueCcm;
-	}
-	gammaLut_ = params.gammaLut;
-}
-
 /**
  * \fn void Debayer::dmaSyncBegin(DebayerParams &params)
  * \brief Common CPU/GPU Dma Sync Buffer begin
diff --git a/src/libcamera/software_isp/debayer.h b/src/libcamera/software_isp/debayer.h
index cd2db9930..652cff4cc 100644
--- a/src/libcamera/software_isp/debayer.h
+++ b/src/libcamera/software_isp/debayer.h
@@ -78,13 +78,6 @@ public:
 	Size outputSize_;
 	PixelFormat inputPixelFormat_;
 	PixelFormat outputPixelFormat_;
-	DebayerParams::LookupTable red_;
-	DebayerParams::LookupTable green_;
-	DebayerParams::LookupTable blue_;
-	DebayerParams::CcmLookupTable redCcm_;
-	DebayerParams::CcmLookupTable greenCcm_;
-	DebayerParams::CcmLookupTable blueCcm_;
-	DebayerParams::LookupTable gammaLut_;
 	bool swapRedBlueGains_;
 	Benchmark bench_;
 
@@ -92,7 +85,6 @@ private:
 	virtual Size patternSize(PixelFormat inputFormat) = 0;
 
 protected:
-	void setParams(DebayerParams &params);
 	void dmaSyncBegin(std::vector<DmaSyncer> &dmaSyncers, FrameBuffer *input, FrameBuffer *output);
 	static bool isStandardBayerOrder(BayerFormat::Order order);
 };
diff --git a/src/libcamera/software_isp/debayer_cpu.cpp b/src/libcamera/software_isp/debayer_cpu.cpp
index 00738c56b..dcd931d2d 100644
--- a/src/libcamera/software_isp/debayer_cpu.cpp
+++ b/src/libcamera/software_isp/debayer_cpu.cpp
@@ -1,7 +1,7 @@
 /* SPDX-License-Identifier: LGPL-2.1-or-later */
 /*
  * Copyright (C) 2023, Linaro Ltd
- * Copyright (C) 2023-2025 Red Hat Inc.
+ * Copyright (C) 2023-2026 Red Hat Inc.
  *
  * Authors:
  * Hans de Goede <hdegoede@redhat.com>
@@ -68,21 +68,21 @@ DebayerCpu::~DebayerCpu() = default;
 #define GAMMA(value) \
 	*dst++ = gammaLut_[std::clamp(value, 0, static_cast<int>(gammaLut_.size()) - 1)]
 
-#define STORE_PIXEL(b_, g_, r_)                                        \
-	if constexpr (ccmEnabled) {                                    \
-		const DebayerParams::CcmColumn &blue = blueCcm_[b_];   \
-		const DebayerParams::CcmColumn &green = greenCcm_[g_]; \
-		const DebayerParams::CcmColumn &red = redCcm_[r_];     \
-		GAMMA(blue.b + green.b + red.b);                       \
-		GAMMA(blue.g + green.g + red.g);                       \
-		GAMMA(blue.r + green.r + red.r);                       \
-	} else {                                                       \
-		*dst++ = blue_[b_];                                    \
-		*dst++ = green_[g_];                                   \
-		*dst++ = red_[r_];                                     \
-	}                                                              \
-	if constexpr (addAlphaByte)                                    \
-		*dst++ = 255;                                          \
+#define STORE_PIXEL(b_, g_, r_)                         \
+	if constexpr (ccmEnabled) {                     \
+		const CcmColumn &blue = blueCcm_[b_];   \
+		const CcmColumn &green = greenCcm_[g_]; \
+		const CcmColumn &red = redCcm_[r_];     \
+		GAMMA(blue.b + green.b + red.b);        \
+		GAMMA(blue.g + green.g + red.g);        \
+		GAMMA(blue.r + green.r + red.r);        \
+	} else {                                        \
+		*dst++ = blue_[b_];                     \
+		*dst++ = green_[g_];                    \
+		*dst++ = red_[r_];                      \
+	}                                               \
+	if constexpr (addAlphaByte)                     \
+		*dst++ = 255;                           \
 	x++;
 
 /*
@@ -525,6 +525,16 @@ int DebayerCpu::configure(const StreamConfiguration &inputCfg,
 	if (ret != 0)
 		return -EINVAL;
 
+	ccmEnabled_ = ccmEnabled;
+
+	/*
+	 * Lookup tables must be initialized because the initial value is used for
+	 * the first two frames, i.e. until stats processing starts providing its
+	 * own parameters. Let's enforce recomputing lookup tables by setting the
+	 * stored last used gamma to an out-of-range value.
+	 */
+	params_.gamma = 1.0;
+
 	window_.x = ((inputCfg.size.width - outputCfg.size.width) / 2) &
 		    ~(inputConfig_.patternSize.width - 1);
 	window_.y = ((inputCfg.size.height - outputCfg.size.height) / 2) &
@@ -740,6 +750,103 @@ void DebayerCpu::process4(uint32_t frame, const uint8_t *src, uint8_t *dst)
 	}
 }
 
+void DebayerCpu::updateGammaTable(DebayerParams &params)
+{
+	const RGB<float> blackLevel = params.blackLevel;
+	/* Take let's say the green channel black level */
+	const unsigned int blackIndex = blackLevel[1] * gammaTable_.size();
+	const float gamma = params.gamma;
+	const float contrastExp = params.contrastExp;
+
+	const float divisor = gammaTable_.size() - blackIndex - 1.0;
+	for (unsigned int i = blackIndex; i < gammaTable_.size(); i++) {
+		float normalized = (i - blackIndex) / divisor;
+		/* Convert 0..2 to 0..infinity; avoid actual inifinity at tan(pi/2) */
+		/* Apply simple S-curve */
+		if (normalized < 0.5)
+			normalized = 0.5 * std::pow(normalized / 0.5, contrastExp);
+		else
+			normalized = 1.0 - 0.5 * std::pow((1.0 - normalized) / 0.5, contrastExp);
+		gammaTable_[i] = UINT8_MAX *
+				 std::pow(normalized, gamma);
+	}
+	/*
+	 * Due to CCM operations, the table lookup may reach indices below the black
+	 * level. Let's set the table values below black level to the minimum
+	 * non-black value to prevent problems when the minimum value is
+	 * significantly non-zero (for example, when the image should be all grey).
+	 */
+	std::fill(gammaTable_.begin(), gammaTable_.begin() + blackIndex,
+		  gammaTable_[blackIndex]);
+}
+
+void DebayerCpu::updateLookupTables(DebayerParams &params)
+{
+	const bool gammaUpdateNeeded =
+		params.gamma != params_.gamma ||
+		params.blackLevel != params_.blackLevel ||
+		params.contrastExp != params_.contrastExp;
+	if (gammaUpdateNeeded)
+		updateGammaTable(params);
+
+	auto matrixChanged = [](Matrix<float, 3, 3> m1, Matrix<float, 3, 3> m2) -> bool {
+		for (unsigned int i = 0; i < 3; i++)
+			for (unsigned int j = 0; j < 3; j++)
+				if (m1[i][j] != m2[i][j])
+					return true;
+		return false;
+	};
+
+	const unsigned int gammaTableSize = gammaTable_.size();
+	const double div = static_cast<double>(kRGBLookupSize) / gammaTableSize;
+	if (ccmEnabled_) {
+		if (gammaUpdateNeeded ||
+		    matrixChanged(params.combinedMatrix, params_.combinedMatrix)) {
+			auto &red = swapRedBlueGains_ ? blueCcm_ : redCcm_;
+			auto &green = greenCcm_;
+			auto &blue = swapRedBlueGains_ ? redCcm_ : blueCcm_;
+			const unsigned int redIndex = swapRedBlueGains_ ? 2 : 0;
+			const unsigned int greenIndex = 1;
+			const unsigned int blueIndex = swapRedBlueGains_ ? 0 : 2;
+			for (unsigned int i = 0; i < kRGBLookupSize; i++) {
+				red[i].r = std::round(i * params.combinedMatrix[redIndex][0]);
+				red[i].g = std::round(i * params.combinedMatrix[greenIndex][0]);
+				red[i].b = std::round(i * params.combinedMatrix[blueIndex][0]);
+				green[i].r = std::round(i * params.combinedMatrix[redIndex][1]);
+				green[i].g = std::round(i * params.combinedMatrix[greenIndex][1]);
+				green[i].b = std::round(i * params.combinedMatrix[blueIndex][1]);
+				blue[i].r = std::round(i * params.combinedMatrix[redIndex][2]);
+				blue[i].g = std::round(i * params.combinedMatrix[greenIndex][2]);
+				blue[i].b = std::round(i * params.combinedMatrix[blueIndex][2]);
+				gammaLut_[i] = gammaTable_[i / div];
+			}
+		}
+	} else {
+		if (gammaUpdateNeeded || params.gains != params_.gains) {
+			auto &gains = params.gains;
+			auto &red = swapRedBlueGains_ ? blue_ : red_;
+			auto &green = green_;
+			auto &blue = swapRedBlueGains_ ? red_ : blue_;
+			for (unsigned int i = 0; i < kRGBLookupSize; i++) {
+				/* Apply gamma after gain! */
+				const RGB<float> lutGains = (gains * i / div).min(gammaTableSize - 1);
+				red[i] = gammaTable_[static_cast<unsigned int>(lutGains.r())];
+				green[i] = gammaTable_[static_cast<unsigned int>(lutGains.g())];
+				blue[i] = gammaTable_[static_cast<unsigned int>(lutGains.b())];
+			}
+		}
+	}
+
+	LOG(Debayer, Debug)
+		<< "Debayer parameters: blackLevel=" << params.blackLevel
+		<< "; gamma=" << params.gamma
+		<< "; contrastExp=" << params.contrastExp
+		<< "; gains=" << params.gains
+		<< "; matrix=" << params.combinedMatrix;
+
+	params_ = params;
+}
+
 void DebayerCpu::process(uint32_t frame, FrameBuffer *input, FrameBuffer *output, DebayerParams params)
 {
 	bench_.startFrame();
@@ -748,7 +855,7 @@ void DebayerCpu::process(uint32_t frame, FrameBuffer *input, FrameBuffer *output
 
 	dmaSyncBegin(dmaSyncers, input, output);
 
-	setParams(params);
+	updateLookupTables(params);
 
 	/* Copy metadata from the input buffer */
 	FrameMetadata &metadata = output->_d()->metadata();
diff --git a/src/libcamera/software_isp/debayer_cpu.h b/src/libcamera/software_isp/debayer_cpu.h
index 67df2b93a..b5cbb5bd2 100644
--- a/src/libcamera/software_isp/debayer_cpu.h
+++ b/src/libcamera/software_isp/debayer_cpu.h
@@ -1,7 +1,7 @@
 /* SPDX-License-Identifier: LGPL-2.1-or-later */
 /*
  * Copyright (C) 2023, Linaro Ltd
- * Copyright (C) 2023-2025 Red Hat Inc.
+ * Copyright (C) 2023-2026 Red Hat Inc.
  *
  * Authors:
  * Hans de Goede <hdegoede@redhat.com>
@@ -18,6 +18,8 @@
 #include <libcamera/base/object.h>
 
 #include "libcamera/internal/bayer_format.h"
+#include "libcamera/internal/global_configuration.h"
+#include "libcamera/internal/software_isp/debayer_params.h"
 #include "libcamera/internal/software_isp/swstats_cpu.h"
 
 #include "debayer.h"
@@ -108,10 +110,32 @@ private:
 	void memcpyNextLine(const uint8_t *linePointers[]);
 	void process2(uint32_t frame, const uint8_t *src, uint8_t *dst);
 	void process4(uint32_t frame, const uint8_t *src, uint8_t *dst);
+	void updateGammaTable(DebayerParams &params);
+	void updateLookupTables(DebayerParams &params);
 
 	/* Max. supported Bayer pattern height is 4, debayering this requires 5 lines */
 	static constexpr unsigned int kMaxLineBuffers = 5;
 
+	static constexpr unsigned int kRGBLookupSize = 256;
+	static constexpr unsigned int kGammaLookupSize = 1024;
+	struct CcmColumn {
+		int16_t r;
+		int16_t g;
+		int16_t b;
+	};
+	using LookupTable = std::array<uint8_t, kRGBLookupSize>;
+	using CcmLookupTable = std::array<CcmColumn, kRGBLookupSize>;
+	LookupTable red_;
+	LookupTable green_;
+	LookupTable blue_;
+	CcmLookupTable redCcm_;
+	CcmLookupTable greenCcm_;
+	CcmLookupTable blueCcm_;
+	std::array<double, kGammaLookupSize> gammaTable_;
+	LookupTable gammaLut_;
+	bool ccmEnabled_;
+	DebayerParams params_;
+
 	debayerFn debayer0_;
 	debayerFn debayer1_;
 	debayerFn debayer2_;
diff --git a/src/libcamera/software_isp/debayer_egl.cpp b/src/libcamera/software_isp/debayer_egl.cpp
index 8e0890323..ddf03ad4b 100644
--- a/src/libcamera/software_isp/debayer_egl.cpp
+++ b/src/libcamera/software_isp/debayer_egl.cpp
@@ -475,18 +475,18 @@ void DebayerEGL::setShaderVariableValues(DebayerParams &params)
 			    << " textureUniformProjMatrix_ " << textureUniformProjMatrix_;
 
 	GLfloat ccm[9] = {
-		params.ccm[0][0],
-		params.ccm[0][1],
-		params.ccm[0][2],
-		params.ccm[1][0],
-		params.ccm[1][1],
-		params.ccm[1][2],
-		params.ccm[2][0],
-		params.ccm[2][1],
-		params.ccm[2][2],
+		params.combinedMatrix[0][0],
+		params.combinedMatrix[0][1],
+		params.combinedMatrix[0][2],
+		params.combinedMatrix[1][0],
+		params.combinedMatrix[1][1],
+		params.combinedMatrix[1][2],
+		params.combinedMatrix[2][0],
+		params.combinedMatrix[2][1],
+		params.combinedMatrix[2][2],
 	};
 	glUniformMatrix3fv(ccmUniformDataIn_, 1, GL_FALSE, ccm);
-	LOG(Debayer, Debug) << " ccmUniformDataIn_ " << ccmUniformDataIn_ << " data " << params.ccm;
+	LOG(Debayer, Debug) << " ccmUniformDataIn_ " << ccmUniformDataIn_ << " data " << params.combinedMatrix;
 
 	/*
 	 * 0 = Red, 1 = Green, 2 = Blue
@@ -544,8 +544,6 @@ void DebayerEGL::process(uint32_t frame, FrameBuffer *input, FrameBuffer *output
 
 	dmaSyncBegin(dmaSyncers, input, nullptr);
 
-	setParams(params);
-
 	/* Copy metadata from the input buffer */
 	FrameMetadata &metadata = output->_d()->metadata();
 	metadata.status = input->metadata().status;
diff --git a/src/libcamera/software_isp/software_isp.cpp b/src/libcamera/software_isp/software_isp.cpp
index 7ad3511db..ebdf5b2c6 100644
--- a/src/libcamera/software_isp/software_isp.cpp
+++ b/src/libcamera/software_isp/software_isp.cpp
@@ -84,23 +84,6 @@ SoftwareIsp::SoftwareIsp(PipelineHandler *pipe, const CameraSensor *sensor,
 		   DmaBufAllocator::DmaBufAllocatorFlag::SystemHeap |
 		   DmaBufAllocator::DmaBufAllocatorFlag::UDmaBuf)
 {
-	/*
-	 * debayerParams_ must be initialized because the initial value is used for
-	 * the first two frames, i.e. until stats processing starts providing its
-	 * own parameters.
-	 *
-	 * \todo This should be handled in the same place as the related
-	 * operations, in the IPA module.
-	 */
-	std::array<uint8_t, 256> gammaTable;
-	for (unsigned int i = 0; i < 256; i++)
-		gammaTable[i] = UINT8_MAX * std::pow(i / 256.0, 0.5);
-	for (unsigned int i = 0; i < DebayerParams::kRGBLookupSize; i++) {
-		debayerParams_.red[i] = gammaTable[i];
-		debayerParams_.green[i] = gammaTable[i];
-		debayerParams_.blue[i] = gammaTable[i];
-	}
-
 	if (!dmaHeap_.isValid()) {
 		LOG(SoftwareIsp, Error) << "Failed to create DmaBufAllocator object";
 		return;
