diff --git a/src/ipa/libipa/lsc.cpp b/src/ipa/libipa/lsc.cpp
new file mode 100644
index 000000000000..ae713a776778
--- /dev/null
+++ b/src/ipa/libipa/lsc.cpp
@@ -0,0 +1,322 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026 Ideas on Board Oy
+ *
+ * libIPA Lsc algorithms
+ */
+
+#include "lsc.h"
+
+#include <libcamera/base/log.h>
+
+/**
+ * \file lsc.h
+ * \brief libipa lsc algorithm
+ */
+
+namespace libcamera {
+
+LOG_DEFINE_CATEGORY(Lsc)
+
+namespace ipa {
+
+namespace lsc {
+
+/**
+ * \struct ActiveState
+ * \brief The lsc active state
+ *
+ * \var ActiveState::enabled
+ * \brief Boolean flag for the LscAlgorithm enable status
+ */
+
+/**
+ * \struct FrameContext
+ * \brief The lsc frame context
+ *
+ * \var FrameContext::enabled
+ * \brief Boolean flag for the LscAlgorithm enable status
+ *
+ * \var FrameContext::update
+ * \brief Boolean flag for the LscAlgorithm updated status
+ */
+
+} /* namespace lsc */
+
+#ifndef __DOXYGEN__
+template<>
+void Interpolator<lsc::Components<uint16_t>>::
+	interpolate(const lsc::Components<uint16_t> &a,
+		    const lsc::Components<uint16_t> &b,
+		    lsc::Components<uint16_t> &dest,
+		    double lambda)
+{
+	for (auto const &[k, v] : a)
+		interpolateVector(v, b.at(k), dest[k], lambda);
+}
+#endif
+
+/**
+ * \class LscAlgorithmBase
+ * \brief Base class for LscAlgorithm
+ *
+ * Base class for LscAlgorithm for non-templated functions implementation
+ */
+
+/**
+ * \brief Queue a request to the lsc algorithm
+ * \param[in] state The lsc active state
+ * \param[in] context The lsc frame context
+ * \param[in] controls The list of controls associated with a Request
+ *
+ * Queue a new list of \a controls to the lsc algorithm.
+ * The only supported control is controls::LensShadingCorrectionEnable.
+ */
+void LscAlgorithmBase::queueRequest(lsc::ActiveState &state,
+				    lsc::FrameContext &context,
+				    const ControlList &controls)
+{
+	const auto &lscEnable = controls.get(controls::LensShadingCorrectionEnable);
+	if (lscEnable && *lscEnable != state.enabled) {
+		state.enabled = *lscEnable;
+
+		LOG(Lsc, Debug)
+			<< (state.enabled ? "Enabling" : "Disabling") << " Lsc";
+
+		context.update = true;
+	}
+
+	context.enabled = state.enabled;
+}
+
+/**
+ * \brief Populate the list of lsc metadata
+ * \param[in] context The lsc frame context
+ * \param[in] metadata The list of metadata
+ *
+ * Populates the list of \a metadata with controls handled by the LscAlgorithm
+ * class. The only supported metadata is controls::LensShadingCorrectionEnable.
+ */
+void LscAlgorithmBase::process(lsc::FrameContext &context, ControlList &metadata)
+{
+	metadata.set(controls::LensShadingCorrectionEnable, context.enabled);
+}
+
+/**
+ * \class LscAlgorithm
+ * \brief libIPA lsc algorithm implementation
+ * \tparam T The type used to store the gain values when loaded from tuning file
+ * \tparam U The fixedpoint lsc engine register format
+ *
+ * Due to the optical characteristics of the lens, the light intensity received
+ * by the sensor is not uniform. The Lens Shading Correction algorithm applies
+ * multipliers to all pixels to compensate for the lens shading effect.
+ *
+ * The LscAlgorithm implements the libipa Lens Shading Correction algorithm.
+ * It provides support for parsing the tuning file content and generate tables
+ * of per-colour temperature gains that IPA algorithms can use to program their
+ * lsc engine.
+ *
+ * The init() function parses the tuning file and loads the gain tables either
+ * in tabular form (LscGrid) or as radial polynomial (LscPolynomial). The gain
+ * tables are organized per-colour temperature with per-colour components gain
+ * vectors or polynomial coefficients. The colour components names are
+ * IPA-implementation specific and depend on the ISP lsc engine design. Some lsc
+ * engine support 4 colour components (r, gr, gb, b), some only support 3 colour
+ * components (r, g, b). The name (and number) of the expected colour components
+ * shall be provided to LscAlgorithm::init() using the LscDescriptor::keys
+ * field.
+ *
+ * Example of a tabular lens shading tuning file with 'r', 'g' and 'b' colour
+ * components. The gain table has been here omitted, but the expected number
+ * of entries has be equal to
+ * LscDescriptor::numHCells * LscDescriptor::numVCells.
+ *
+ * \code{.yaml}
+ * - Lsc:
+ *    sets:
+ *      - ct: 2500
+ *        r: [
+ *        	.. gains table omitted..
+ *        ]
+ *        g: [
+ *        	.. gains table omitted..
+ *        ]
+ *        b: [
+ *        	.. gains table omitted..
+ *        ]
+ *      - ct: 6500
+ *        r: [
+ *        	.. gains table omitted..
+ *        ]
+ *        g: [
+ *        	.. gains table omitted..
+ *        ]
+ *        b: [
+ *        	.. gains table omitted..
+ *        ]
+ * \endcode
+ *
+ * Example of a polynomial lens shading tuning file with 'r', 'gr', 'gb' and 'b'
+ * colour components:
+ *
+ * \code{.yaml}
+ * - Lsc:
+ *    type: "polynomial"
+ *    sets:
+ *      - ct: 2500
+ *        r:
+ *          cx: 0.5006571711950275
+ *          cy: 0.510093737499277
+ *          k0: 1.5393282208428813
+ *          k1: -1.1434559757908016
+ *          k2: 4.332602305814554
+ *          k3: 0.0
+ *          k4: 0.0
+ *        gr:
+ *          cx: 0.5009320529087338
+ *          cy: 0.511208038949085
+ *          k0: 1.5634738574805407
+ *          k1: -1.5623484259968348
+ *          k2: 4.846686073656501
+ *          k3: 0.0
+ *          k4: 0.0
+ *        gb:
+ *          cx: 0.5012013290343839
+ *          cy: 0.5128251541578288
+ *          k0: 1.526147944919103
+ *          k1: -1.4316976083689723
+ *          k2: 4.792604063222728
+ *          k3: 0.0
+ *          k4: 0.0
+ *        b:
+ *          cx: 0.49864139511067784
+ *          cy: 0.5162095081739346
+ *          k0: 1.0405245474038738
+ *          k1: 0.05618339879447103
+ *          k2: 1.8792813594001752
+ *          k3: 0.0
+ *          k4: 0.0
+ *      - ct: 6000
+ *        r:
+ *          cx: 0.5006202239353942
+ *          cy: 0.5099531318307661
+ *          k0: 1.4702946023945032
+ *          k1: -0.8893767547927631
+ *          k2: 3.920547732201387
+ *          k3: 0.0
+ *          k4: 0.0
+ *        gr:
+ *          cx: 0.500907874178317
+ *          cy: 0.511084916024106
+ *          k0: 1.5336172760559457
+ *          k1: -1.39964026514435
+ *          k2: 4.565487728954618
+ *          k3: 0.0
+ *          k4: 0.0
+ *        gb:
+ *          cx: 0.5011898608900477
+ *          cy: 0.5126797906745105
+ *          k0: 1.5013145790354843
+ *          k1: -1.2747407173754124
+ *          k2: 4.514682876897286
+ *          k3: 0.0
+ *          k4: 0.0
+ *        b:
+ *          cx: 0.4987561413116136
+ *          cy: 0.5159619420778772
+ *          k0: 1.0102986422191802
+ *          k1: 0.13263449763985727
+ *          k2: 1.686556107316064
+ *          k3: 0.0
+ *          k4: 0.0
+ * \endcode
+ *
+ * The lsc tables or the polynomial definition are generated at tuning time
+ * using an image of known resolution which needs to be specified in
+ * LscDescriptor::sensorSize.
+ *
+ * At LscAlgorithm::configure() time the lsc tables are re-sampled on the
+ * sensor's crop rectangle in use to adapt them to the configuration in use for
+ * a streaming session. Polynomial lsc tables support re-sampling and can be
+ * applied to any sensor configuration. Grid-based lsc tables cannot be
+ * re-sampled and the configuration as parsed from the tuning file is used for
+ * all sensor configurations providing best-effort results.
+ *
+ * When the IPA algorithms wants to get access to the (resampled) tables to
+ * program its lsc engine, it uses LscAlgorithm::interpolateComponents() to get
+ * an lsc table interpolated by the LscAlgorithm class for the specified colour
+ * temperature. If the algorithm wants to access the non-interpolated tables it
+ * can retrieve them using LscAlgorithm::getComponents().
+ */
+
+/**
+ * \fn LscAlgorithm::init()
+ * \param[in] tuningData The tuning data
+ * \param[in] controls The IPA list of supported controls
+ * \param[in] descriptor The lsc engine descriptor
+ *
+ * Parse \a tuningData according to the settings specified in \a descriptor to
+ * populate the lsc data and registers lsc controls in \a controls.
+ *
+ * \return 0 on success, a negative error code otherwise
+ */
+
+/**
+ * \fn LscAlgorithm::configure()
+ * \param[in] state The lsc active state
+ * \param[in] analogCrop The current sensor analog crop rectangle
+ * \param[in] xPos List of horizontal positions of the LSC grid nodes
+ * \param[in] yPos List of vertical positions of the LSC grid nodes
+ *
+ * Re-sample the lsc data for an \a analogCrop.
+ *
+ * Lsc data are generated at tuning time using a known sensor configuration.
+ * When a new streaming session is started, it might use a different sensor
+ * configuration for which the lsc tables need to be adjusted to.
+ *
+ * This function re-generates the lsc tables to adapt them to a new sensor
+ * configuration, specifically it re-samples the lsc data for a new \a
+ * analogCrop on a grid specified by \a xPos and \a yPos. Re-sampling of
+ * lsc data is only supported by polynomial-based lsc tables.
+ *
+ * \sa LscImplementation::resampleLscData
+ *
+ * \return 0 on success, a negative error code otherwise
+ */
+
+/**
+ * \fn LscAlgorithm::interpolateComponents
+ * \brief Interpolate the lsc tables for a given colour temperature
+ * \param[in] ct The colour temperature
+ *
+ * Lsc data are generated using different colour temperatures during the
+ * tuning phase.
+ *
+ * This function returns the interpolated lsc data for a given \a ct
+ * colour temperature.
+ *
+ * IPA algorithm can use this function to obtain a list of gains per-colour
+ * component to program their lsc engines with every time a significant enough
+ * change in colour temperature is detected.
+ *
+ * Calling this function is only valid after LscAlgorithm::configure() has been
+ * called. An empty components list is returned otherwise.
+ *
+ * \return The lsc gains table interpolated for temperature \a ct
+ */
+
+/**
+ * \fn LscAlgorithm::getComponents
+ *
+ * Return the map of lsc data per colour temperature.
+ *
+ * Calling this function is only valid after LscAlgorithm::configure() has been
+ * called. An empty components list is returned otherwise.
+ *
+ * \return The map of lsc gains tables per colour-temperature
+ */
+
+} /* namespace ipa */
+
+} /* namespace libcamera */
diff --git a/src/ipa/libipa/lsc.h b/src/ipa/libipa/lsc.h
new file mode 100644
index 000000000000..8bfa3a7c4ecf
--- /dev/null
+++ b/src/ipa/libipa/lsc.h
@@ -0,0 +1,145 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026 Ideas on Board Oy
+ *
+ * libIPA Lsc algorithms
+ */
+
+#pragma once
+
+#include <memory>
+
+#include <libcamera/control_ids.h>
+
+#include "interpolator.h"
+#include "lsc_grid.h"
+#include "lsc_polynomial.h"
+
+namespace libcamera {
+
+LOG_DECLARE_CATEGORY(Lsc)
+
+namespace ipa {
+
+namespace lsc {
+
+struct ActiveState {
+	bool enabled;
+};
+
+struct FrameContext {
+	bool enabled;
+	bool update;
+};
+
+} /* namespace lsc */
+
+#ifndef __DOXYGEN__
+template<typename T>
+void interpolateVector(const std::vector<T> &a, const std::vector<T> &b,
+		       std::vector<T> &dest, double lambda)
+{
+	ASSERT(a.size() == b.size());
+	dest.resize(a.size());
+	for (size_t i = 0; i < a.size(); i++)
+		dest[i] = a[i] * (1.0 - lambda) + b[i] * lambda;
+}
+
+template<>
+void Interpolator<lsc::Components<uint16_t>>::
+	interpolate(const lsc::Components<uint16_t> &a,
+		    const lsc::Components<uint16_t> &b,
+		    lsc::Components<uint16_t> &dest,
+		    double lambda);
+#endif /* __DOXYGEN__ */
+
+class LscAlgorithmBase
+{
+public:
+	void queueRequest(lsc::ActiveState &state, lsc::FrameContext &context,
+			  const ControlList &controls);
+	void process(lsc::FrameContext &context, ControlList &metadata);
+};
+
+template<typename T, typename U>
+class LscAlgorithm : public LscAlgorithmBase
+{
+public:
+	int init(const ValueNode &tuningData, ControlInfoMap::Map &controls,
+		 LscDescriptor descriptor)
+	{
+		polynomial_ = false;
+
+		std::string type = tuningData["type"].get<std::string>("table");
+		if (type == "table") {
+			impl_ = std::make_unique<LscGrid<T, U>>();
+			LOG(Lsc, Debug) << "Using grid-based Lsc";
+		} else if (type == "polynomial") {
+			impl_ = std::make_unique<LscPolynomial<T, U>>();
+			polynomial_ = true;
+			LOG(Lsc, Debug) << "Using polynomial Lsc";
+		} else {
+			LOG(Lsc, Error) << "Unsupported Lsc algorithm '"
+					<< type << "'";
+			return -EINVAL;
+		}
+
+		const ValueNode &yamlSets = tuningData["sets"];
+		if (!yamlSets.isList()) {
+			LOG(Lsc, Error) << "'sets' parameter not found in tuning file";
+			return -EINVAL;
+		}
+
+		int ret = impl_->parseLscData(yamlSets, descriptor);
+		if (ret)
+			return ret;
+
+		controls[&controls::LensShadingCorrectionEnable] =
+			ControlInfo(false, true, true);
+
+		return 0;
+	}
+
+	int configure(lsc::ActiveState &state, const Rectangle &analogCrop,
+		      const std::vector<double> &xPos,
+		      const std::vector<double> &yPos)
+	{
+		LOG(Lsc, Debug) << "Sample Lsc data for " << analogCrop;
+		lsc::ComponentsMap<T> lscData =
+			impl_->resampleLscData(analogCrop, xPos, yPos);
+
+		/*
+		 * Retain a copy of the components table.
+		 *
+		 * We could avoid a copy here if getComponents() could
+		 * return sets_.data() but I wasn't able to work around the
+		 * compiler refusing it.
+		 */
+		lscData_ = lscData;
+
+		sets_.setData(std::move(lscData));
+		state.enabled = true;
+
+		return 0;
+	}
+
+	const lsc::Components<T> interpolateComponents(unsigned int ct)
+	{
+		return sets_.getInterpolated(ct);
+	}
+
+	const lsc::ComponentsMap<T> getComponents()
+	{
+		return lscData_;
+	}
+
+private:
+	std::unique_ptr<LscImplementation<T, U>> impl_;
+	Interpolator<lsc::Components<T>> sets_;
+	lsc::ComponentsMap<T> lscData_;
+	bool polynomial_;
+};
+
+} /* namespace ipa */
+
+} /* namespace libcamera */
diff --git a/src/ipa/libipa/lsc_base.cpp b/src/ipa/libipa/lsc_base.cpp
new file mode 100644
index 000000000000..fba8d2f00d54
--- /dev/null
+++ b/src/ipa/libipa/lsc_base.cpp
@@ -0,0 +1,152 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026 Ideas on Board Oy
+ *
+ * Base classes and types for LSC algorithms implementations
+ */
+
+#include "lsc_base.h"
+
+/**
+ * \file lsc_base.h
+ * \brief Base types and definitions for LscImplementation class hierarchy
+ *
+ * Split the common classes and types definitions to a dedicated file to avoid
+ * circular inclusions.
+ *
+ * Both LscGrid and LscPolynomial inherit from LscImplementation and they are
+ * instantiated from lsc.h as the LscAlgorithm::init() function is there inlined.
+ *
+ * For this reason lsc.h needs to include lsc_grid.h and lsc_polynomial.h which
+ * need the LscImplementation class definition themselves.
+ *
+ * Split the LscImplementation interface definition to this file so that it can
+ * be included by both lsc_grid.h and lsc_polynomial.h, which are then included
+ * by lsc.h.
+ */
+
+namespace libcamera {
+
+namespace ipa {
+
+namespace lsc {
+
+/**
+ * \class Components
+ * \brief Associate a colour components with a list of gains
+ * \tparam T The type used to store the gain values when loaded from tuning file
+ *
+ * Lsc tables are defined as a list of gain values associated to a colour
+ * component.
+ *
+ * As different ISP support different colour components (usually 'r', 'gr',
+ * 'gb', 'b' or just 'r', 'g', 'b') this class associates a string
+ * identifier for the colour component to a list of gains of type \a T.
+ *
+ * Each key name shall match an entry in the tuning file and the type \a T
+ * shall match the size of the registers where gains are stored.
+ *
+ * The list of keys is provided to the LscAlgorithm class using \a
+ * LscDescriptor::keys.
+ */
+
+/**
+ * \class ComponentsMap
+ * \brief Associate a colour temperature to a lsc table
+ * \tparam T The type used to store the gain values when loaded from tuning file
+ *
+ * An lsc table is generated during the tuning phase for a specific light
+ * temperature, and a tuning file usually contains lsc tables generated for
+ * several different colour temperatures.
+ *
+ * This class associates an lsc table to the colour temperature used when the
+ * table has been generated.
+ */
+
+} /* namespace lsc */
+
+/**
+ * \struct LscDescriptor
+ * \brief Describe the ISP lsc engine
+ *
+ * \var LscDescriptor::keys
+ * \brief The list of colour components to which a list of gains is associated
+ * with in the tuning file. Used for parsing the tuning file
+ *
+ * \var LscDescriptor::numHCells
+ * \brief Number of horizontal cells of the ISP lsc grid. Used for validating
+ * the list of gains parsed from tuning file
+ *
+ * \var LscDescriptor::numVCells
+ * \brief Number of vertical cells of the ISP lsc grid. Used for validating
+ * the list of gains parsed from tuning file
+ *
+ * \var LscDescriptor::sensorSize
+ * \brief The physical sensor size. This is the largest frame size used to
+ * generate the lsc table. Only used by the polynomial lsc algorithm
+ */
+
+/**
+ * \class LscImplementation
+ * \brief Pure virtual base class for lsc algorithm implementations
+ * \tparam T The type used to store the gain values when loaded from tuning file
+ * \tparam U The fixedpoint format used to convert gain values generated by
+ * polynomial expansion to the register format
+ *
+ * Defines the interface for the lsc algorithm implementation. Currently
+ * implemented by LscGrid and LscPolynomial.
+ */
+
+/**
+ * \fn LscImplementation::~LscImplementation
+ * \brief Virtual class destructor
+ */
+
+/**
+ * \fn LscImplementation::parseLscData
+ * \brief Parse \a tuningData using \a descriptor
+ * \param[in] tuningData The tuning data
+ * \param[in] descriptor The lsc engine descriptor
+ *
+ * Parse the tuning file using the \a descriptor to identify the colour
+ * components in the tuning data and validate the size of the loaded gains
+ * tables.
+ *
+ * \return 0 on success, a negative error number otherwise
+ */
+
+/**
+ * \fn LscImplementation::resampleLscData
+ * \brief Re-sample the lsc components for \a cropRectangle
+ * \param[in] cropRectangle The sensor analogue crop rectangle
+ * \param[in] xPos List of horizontal positions of the lsc grid nodes
+ * \param[in] yPos List of vertical positions of the lsc grid nodes
+ *
+ * Lsc tables are expressed in two formats:
+ * - A list of gain values (LscGrid)
+ * - A radial polynomial (LscPolynomial)
+ *
+ * Grid-based lsc data are generated using an image at a fixed resolution and
+ * can't at the moment be re-sampled when a different resolution is used for a
+ * streaming session. Re-sampling a grid lsc table will return the same table
+ * as loaded from the tuning file.
+ *
+ * Polynomial are more flexible and can be re-sampled for a given resolution
+ * using a list of horizontal and vertical nodes that define the lsc grid.
+ * Polynomial lsc tables have to be re-sampled every time a new configuration is
+ * applied, as each streaming session might use a different sensor crop
+ * rectangle.
+ *
+ * \a cropRectangle represents the size of the frame on which the polynomial Lsc
+ * has to be re-sampled on.
+ *
+ * \a xPos and \a yPos represent the position of the grid nodes vertexes in
+ * the [0, 1] interval. In example an equally spaced grid of 16 nodes will have
+ * each segment of size 0.0625 and the list of nodes position will be
+ * [0, 0.0625, 0.125, 0.1875, ... , 1]. It is expected that the first position
+ * is 0 and the last position is 1.
+ */
+
+} /* namespace ipa */
+
+} /* namespace libcamera */
diff --git a/src/ipa/libipa/lsc_base.h b/src/ipa/libipa/lsc_base.h
new file mode 100644
index 000000000000..aecae305aa9c
--- /dev/null
+++ b/src/ipa/libipa/lsc_base.h
@@ -0,0 +1,59 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026 Ideas on Board Oy
+ *
+ * Base classes and types for LSC algorithms implementations
+ */
+
+#pragma once
+
+#include <map>
+#include <string>
+#include <vector>
+
+#include <libcamera/geometry.h>
+
+#include "libcamera/internal/value_node.h"
+
+namespace libcamera {
+
+namespace ipa {
+
+namespace lsc {
+
+template<typename T>
+class Components : public std::map<std::string, std::vector<T>>
+{
+};
+
+template<typename T>
+class ComponentsMap : public std::map<unsigned int, Components<T>>
+{
+};
+
+} /* namespace lsc */
+
+struct LscDescriptor {
+	std::vector<std::string> keys;
+	unsigned int numHCells;
+	unsigned int numVCells;
+	Size sensorSize;
+};
+
+template<typename T, typename U>
+class LscImplementation
+{
+public:
+	virtual ~LscImplementation() = default;
+
+	virtual int parseLscData(const ValueNode &tuningData,
+				 const LscDescriptor &descriptor) = 0;
+
+	virtual lsc::ComponentsMap<T> resampleLscData(const Rectangle &cropRectangle,
+						      const std::vector<double> &xPos,
+						      const std::vector<double> &yPos) = 0;
+};
+
+} /* namespace ipa */
+
+} /* namespace libcamera */
diff --git a/src/ipa/libipa/lsc_grid.cpp b/src/ipa/libipa/lsc_grid.cpp
new file mode 100644
index 000000000000..878994acc8c9
--- /dev/null
+++ b/src/ipa/libipa/lsc_grid.cpp
@@ -0,0 +1,33 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026, Ideas On Board
+ *
+ * Grid based lens shading correction
+ */
+
+#include "lsc_grid.h"
+
+/**
+ * \file lsc_grid.h
+ * \brief LscGrid class
+ */
+
+namespace libcamera {
+
+LOG_DEFINE_CATEGORY(LscGrid)
+
+namespace ipa {
+
+/**
+ * \class LscGrid
+ * \brief Grid based lsc algorithm implementation
+ *
+ * Grid based lsc algorithm implementation. The LscGrid class implements lsc
+ * support using tabular lsc data.
+ *
+ * \sa LscImplementation
+ * \sa LscAlgorithm
+ */
+
+} /* namespace ipa */
+} /* namespace libcamera */
diff --git a/src/ipa/libipa/lsc_grid.h b/src/ipa/libipa/lsc_grid.h
new file mode 100644
index 000000000000..e92cde95d18d
--- /dev/null
+++ b/src/ipa/libipa/lsc_grid.h
@@ -0,0 +1,113 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026, Ideas On Board
+ *
+ * Grid based lens shading correction
+ */
+
+#pragma once
+
+#include <vector>
+
+#include <libcamera/base/log.h>
+
+#include "lsc_base.h"
+
+namespace libcamera {
+
+LOG_DECLARE_CATEGORY(LscGrid)
+
+namespace ipa {
+
+template<typename T, typename U>
+class LscGrid : public LscImplementation<T, U>
+{
+public:
+	~LscGrid() {}
+
+	int parseLscData(const ValueNode &yamlSets,
+			 const LscDescriptor &descriptor) override
+	{
+		const auto &sets = yamlSets.asList();
+
+		for (const auto &yamlSet : sets) {
+			uint32_t ct = yamlSet["ct"].get<uint32_t>(0);
+
+			int ret = parseLscComponent(yamlSet, ct, descriptor);
+			if (ret)
+				return ret;
+		}
+
+		if (lscData_.empty()) {
+			LOG(LscGrid, Error) << "Failed to load any sets";
+			return -EINVAL;
+		}
+
+		return 0;
+	}
+
+	lsc::ComponentsMap<T> resampleLscData([[maybe_unused]] const Rectangle &cropRectangle,
+					      [[maybe_unused]] const std::vector<double> &xPos,
+					      [[maybe_unused]] const std::vector<double> &yPos) override
+	{
+		/* No resampling for grid-based LSC algorithm. */
+		return lscData_;
+	}
+
+private:
+	int parseLscComponent(const ValueNode &yamlSet,
+			      unsigned int ct, const LscDescriptor &descriptor)
+	{
+		lsc::Components<T> component;
+		for (auto &k : descriptor.keys) {
+			auto [it, inserted] = component.emplace(
+				std::piecewise_construct,
+				std::forward_as_tuple(k.c_str()),
+				std::forward_as_tuple(parseTable(yamlSet,
+								 k.c_str(),
+								 descriptor.numHCells,
+								 descriptor.numVCells)));
+			if (!inserted || it->second.empty()) {
+				LOG(LscGrid, Error)
+					<< "Set " << k << " for color temperature "
+					<< ct << " is missing";
+				return -EINVAL;
+			}
+		}
+
+		auto [it, inserted] = lscData_.emplace(ct, component);
+		if (!inserted) {
+			LOG(LscGrid, Error)
+				<< "Multiple sets found for color temperature "
+				<< ct;
+			return -EINVAL;
+		}
+
+		return 0;
+	}
+
+	std::vector<T> parseTable(const ValueNode &tuningData,
+				  const char *prop, unsigned int numHCells,
+				  unsigned int numVCells)
+	{
+		unsigned int kLscNumSamples = numHCells * numVCells;
+
+		std::vector<T> table =
+			tuningData[prop].get<std::vector<T>>().value_or(utils::defopt);
+		if (table.size() != kLscNumSamples) {
+			LOG(LscGrid, Error)
+				<< "Invalid '" << prop << "' values: expected "
+				<< kLscNumSamples
+				<< " elements, got " << table.size();
+			return {};
+		}
+
+		return table;
+	}
+
+	lsc::ComponentsMap<T> lscData_;
+};
+
+} /* namespace ipa */
+
+} /* namespace libcamera */
diff --git a/src/ipa/libipa/lsc_polynomial.cpp b/src/ipa/libipa/lsc_polynomial.cpp
index f607d86c54c3..8d3f1883d6a5 100644
--- a/src/ipa/libipa/lsc_polynomial.cpp
+++ b/src/ipa/libipa/lsc_polynomial.cpp
@@ -1,12 +1,14 @@
 /* SPDX-License-Identifier: LGPL-2.1-or-later */
 /*
- * Copyright (C) 2024, Ideas On Board
+ * Copyright (C) 2026, Ideas On Board
  *
- * Polynomial class to represent lens shading correction
+ * Polynomial based lens shading correction
  */
 
 #include "lsc_polynomial.h"
 
+#include <assert.h>
+
 #include <libcamera/base/log.h>
 
 /**
@@ -21,7 +23,7 @@ LOG_DEFINE_CATEGORY(LscPolynomial)
 namespace ipa {
 
 /**
- * \class LscPolynomial
+ * \class Polynomial
  * \brief Class for handling even polynomials used in lens shading correction
  *
  * Shading artifacts of camera lenses can be modeled using even radial
@@ -31,7 +33,7 @@ namespace ipa {
  */
 
 /**
- * \fn LscPolynomial::LscPolynomial(double cx = 0.0, double cy = 0.0, double k0 = 0.0,
+ * \fn Polynomial::Polynomial(double cx = 0.0, double cy = 0.0, double k0 = 0.0,
 		      double k1 = 0.0, double k2 = 0.0, double k3 = 0.0,
 		      double k4 = 0.0)
  * \brief Construct a polynomial using the given coefficients
@@ -45,7 +47,13 @@ namespace ipa {
  */
 
 /**
- * \fn LscPolynomial::sampleAtNormalizedPixelPos(double x, double y)
+ * \fn Polynomial::Polynomial(const Polynomial &other)
+ * \brief Construct a Polynomial by copy
+ * \param[in] other The Polynomial to copy construct from
+ */
+
+/**
+ * \fn Polynomial::sampleAtNormalizedPixelPos(double x, double y)
  * \brief Sample the polynomial at the given normalized pixel position
  *
  * This functions samples the polynomial at the given pixel position divided by
@@ -55,9 +63,21 @@ namespace ipa {
  * \param y y position in normalized coordinates
  * \return The sampled value
  */
+double Polynomial::sampleAtNormalizedPixelPos(double x, double y) const
+{
+	double dx = x - cnx_;
+	double dy = y - cny_;
+	double r = sqrt(dx * dx + dy * dy);
+	double res = 1.0;
+
+	for (unsigned int i = 0; i < coefficients_.size(); i++)
+		res += coefficients_[i] * std::pow(r, (i + 1) * 2);
+
+	return res;
+}
 
 /**
- * \fn LscPolynomial::getM()
+ * \fn Polynomial::getM()
  * \brief Get the value m as described in the dng specification
  *
  * Returns m according to dng spec. m represents the Euclidean distance
@@ -66,9 +86,18 @@ namespace ipa {
  *
  * \return The sampled value
  */
+double Polynomial::getM() const
+{
+	double cpx = imageSize_.width * cx_;
+	double cpy = imageSize_.height * cy_;
+	double mx = std::max(cpx, std::fabs(imageSize_.width - cpx));
+	double my = std::max(cpy, std::fabs(imageSize_.height - cpy));
+
+	return sqrt(mx * mx + my * my);
+}
 
 /**
- * \fn LscPolynomial::setReferenceImageSize(const Size &size)
+ * \fn Polynomial::setReferenceImageSize(const Size &size)
  * \brief Set the reference image size
  *
  * Set the reference image size that is used for subsequent calls to getM() and
@@ -76,6 +105,92 @@ namespace ipa {
  *
  * \param size The size of the reference image
  */
+void Polynomial::setReferenceImageSize(const Size &size)
+{
+	assert(!size.isNull());
+	imageSize_ = size;
+
+	/* Calculate normalized centers */
+	double m = getM();
+	cnx_ = (size.width * cx_) / m;
+	cny_ = (size.height * cy_) / m;
+}
+
+/**
+ * \class LscPolynomialBase
+ * \brief Base class for LscPolynomial
+ *
+ * Base class for LscPolynomial for non-templated functions.
+ */
+
+/**
+ * \brief Parse polynomial lsc data
+ * \param[in] yamlSets The tuning file content
+ * \param[in] descriptor The lsc engine descriptor
+ *
+ * Parse the lsc data in polyomial form from the \a yamlSet tuning data.
+ */
+int LscPolynomialBase::parseLscData(const ValueNode &yamlSets,
+				    const LscDescriptor &descriptor)
+{
+	const auto &sets = yamlSets.asList();
+	for (const auto &yamlSet : sets) {
+		uint32_t ct = yamlSet["ct"].get<uint32_t>(0);
+
+		Components components;
+		for (auto &k : descriptor.keys) {
+			auto polynomial = yamlSet[k.c_str()].get<Polynomial>();
+			if (!polynomial) {
+				LOG(LscPolynomial, Error)
+					<< "Missing polynomial for component "
+					<< k;
+				return -EINVAL;
+			}
+
+			auto [it, inserted] =
+				components.emplace(std::piecewise_construct,
+						   std::forward_as_tuple(k.c_str()),
+						   std::forward_as_tuple(*polynomial));
+
+			it->second.setReferenceImageSize(descriptor.sensorSize);
+		}
+
+		auto [it, inserted] = lscData_.emplace(ct, components);
+		if (!inserted) {
+			LOG(LscPolynomial, Error)
+				<< "Multiple sets found for "
+				<< "color temperature " << ct;
+			return -EINVAL;
+		}
+	}
+
+	if (lscData_.empty()) {
+		LOG(LscPolynomial, Error) << "Failed to load any sets";
+		return -EINVAL;
+	}
+
+	return 0;
+}
+
+/**
+ * \var LscPolynomialBase::lscData_
+ * \brief The polynomial lsc data
+ *
+ * Maps colour temperatures to per-colour radial polynomial definitions.
+ */
+
+/**
+ * \class LscPolynomial
+ * \brief Radial Polynomial lsc algorithm implementation
+ *
+ * Polynomial-based lsc algorithm implementation. The LscPolynomial class
+ * implements lsc support using Polynomial to represent the shading artifacts
+ * map.
+ *
+ * \sa LscImplementation
+ * \sa LscAlgorithm
+ *
+ */
 
-} // namespace ipa
-} // namespace libcamera
+} /* namespace ipa */
+} /* namespace libcamera */
diff --git a/src/ipa/libipa/lsc_polynomial.h b/src/ipa/libipa/lsc_polynomial.h
index d7d9ae42e360..72cca591f260 100644
--- a/src/ipa/libipa/lsc_polynomial.h
+++ b/src/ipa/libipa/lsc_polynomial.h
@@ -1,15 +1,17 @@
 /* SPDX-License-Identifier: LGPL-2.1-or-later */
 /*
- * Copyright (C) 2024, Ideas On Board
+ * Copyright (C) 2026, Ideas On Board
  *
- * Helper for radial polynomial used in lens shading correction.
+ * Polynomial based lens shading correction
  */
+
 #pragma once
 
 #include <algorithm>
 #include <array>
-#include <assert.h>
 #include <cmath>
+#include <optional>
+#include <tuple>
 
 #include <libcamera/base/log.h>
 #include <libcamera/base/span.h>
@@ -18,64 +20,117 @@
 
 #include "libcamera/internal/value_node.h"
 
+#include "lsc_base.h"
+
 namespace libcamera {
 
 LOG_DECLARE_CATEGORY(LscPolynomial)
 
 namespace ipa {
 
-class LscPolynomial
+class Polynomial
 {
 public:
-	LscPolynomial(double cx = 0.0, double cy = 0.0, double k0 = 0.0,
-		      double k1 = 0.0, double k2 = 0.0, double k3 = 0.0,
-		      double k4 = 0.0)
+	Polynomial(double cx = 0.0, double cy = 0.0, double k0 = 0.0,
+		   double k1 = 0.0, double k2 = 0.0, double k3 = 0.0,
+		   double k4 = 0.0)
 		: cx_(cx), cy_(cy), cnx_(0), cny_(0),
 		  coefficients_({ k0, k1, k2, k3, k4 })
 	{
 	}
 
-	double sampleAtNormalizedPixelPos(double x, double y) const
+	Polynomial(const Polynomial &other) = default;
+
+	double sampleAtNormalizedPixelPos(double x, double y) const;
+	double getM() const;
+	void setReferenceImageSize(const Size &size);
+
+private:
+	double cx_;
+	double cy_;
+	double cnx_;
+	double cny_;
+	std::array<double, 5> coefficients_;
+	Size imageSize_;
+};
+
+class LscPolynomialBase
+{
+private:
+	using Components = std::map<std::string, Polynomial>;
+	using ComponentsMap = std::map<unsigned int, Components>;
+
+public:
+	int parseLscData(const ValueNode &yamlSets,
+			 const LscDescriptor &descriptor);
+
+protected:
+	ComponentsMap lscData_;
+};
+
+template<typename T, typename U>
+class LscPolynomial : public LscPolynomialBase, public LscImplementation<T, U>
+{
+public:
+	~LscPolynomial() {}
+
+	int parseLscData(const ValueNode &yamlSets,
+			 const LscDescriptor &descriptor) override
 	{
-		double dx = x - cnx_;
-		double dy = y - cny_;
-		double r = sqrt(dx * dx + dy * dy);
-		double res = 1.0;
-		for (unsigned int i = 0; i < coefficients_.size(); i++) {
-			res += coefficients_[i] * std::pow(r, (i + 1) * 2);
-		}
-		return res;
+		return LscPolynomialBase::parseLscData(yamlSets, descriptor);
 	}
 
-	double getM() const
+	lsc::ComponentsMap<T> resampleLscData(const Rectangle &cropRectangle,
+					      const std::vector<double> &xPos,
+					      const std::vector<double> &yPos) override
 	{
-		double cpx = imageSize_.width * cx_;
-		double cpy = imageSize_.height * cy_;
-		double mx = std::max(cpx, std::fabs(imageSize_.width - cpx));
-		double my = std::max(cpy, std::fabs(imageSize_.height - cpy));
+		lsc::ComponentsMap<T> components;
 
-		return sqrt(mx * mx + my * my);
-	}
+		for (auto &[t, c] : lscData_) {
+			lsc::Components<T> comp;
 
-	void setReferenceImageSize(const Size &size)
-	{
-		assert(!size.isNull());
-		imageSize_ = size;
+			for (auto &[k, p] : c) {
+				comp.emplace(std::piecewise_construct,
+					     std::forward_as_tuple(k),
+					     std::forward_as_tuple(samplePolynomial(p, xPos, yPos,
+										    cropRectangle)));
+			}
 
-		/* Calculate normalized centers */
-		double m = getM();
-		cnx_ = (size.width * cx_) / m;
-		cny_ = (size.height * cy_) / m;
+			components[t] = comp;
+		}
+
+		return components;
 	}
 
 private:
-	double cx_;
-	double cy_;
-	double cnx_;
-	double cny_;
-	std::array<double, 5> coefficients_;
+	std::vector<T> samplePolynomial(const Polynomial &poly,
+					Span<const double> xPositions,
+					Span<const double> yPositions,
+					const Rectangle &cropRectangle)
 
-	Size imageSize_;
+	{
+		double m = poly.getM();
+		double x0 = cropRectangle.x / m;
+		double y0 = cropRectangle.y / m;
+		double w = cropRectangle.width / m;
+		double h = cropRectangle.height / m;
+		std::vector<T> samples;
+
+		samples.reserve(xPositions.size() * yPositions.size());
+
+		for (double y : yPositions) {
+			for (double x : xPositions) {
+				double xp = x0 + x * w;
+				double yp = y0 + y * h;
+				float sample = static_cast<float>
+						(poly.sampleAtNormalizedPixelPos(xp, yp));
+
+				samples.push_back(U(sample).quantized());
+			}
+		}
+
+		return samples;
+	}
 };
 
 } /* namespace ipa */
@@ -83,8 +138,8 @@ private:
 #ifndef __DOXYGEN__
 
 template<>
-struct ValueNode::Accessor<ipa::LscPolynomial> {
-	std::optional<ipa::LscPolynomial> get(const ValueNode &obj) const
+struct ValueNode::Accessor<ipa::Polynomial> {
+	std::optional<ipa::Polynomial> get(const ValueNode &obj) const
 	{
 		std::optional<double> cx = obj["cx"].get<double>();
 		std::optional<double> cy = obj["cy"].get<double>();
@@ -98,7 +153,7 @@ struct ValueNode::Accessor<ipa::LscPolynomial> {
 			LOG(LscPolynomial, Error)
 				<< "Polynomial is missing a parameter";
 
-		return ipa::LscPolynomial(*cx, *cy, *k0, *k1, *k2, *k3, *k4);
+		return ipa::Polynomial(*cx, *cy, *k0, *k1, *k2, *k3, *k4);
 	}
 };
 
diff --git a/src/ipa/libipa/meson.build b/src/ipa/libipa/meson.build
index edf8eabd8b78..da6ea0c5e130 100644
--- a/src/ipa/libipa/meson.build
+++ b/src/ipa/libipa/meson.build
@@ -14,6 +14,9 @@ libipa_headers = files([
     'fixedpoint.h',
     'histogram.h',
     'interpolator.h',
+    'lsc.h',
+    'lsc_base.h',
+    'lsc_grid.h',
     'lsc_polynomial.h',
     'lux.h',
     'module.h',
@@ -36,6 +39,8 @@ libipa_sources = files([
     'fixedpoint.cpp',
     'histogram.cpp',
     'interpolator.cpp',
+    'lsc.cpp',
+    'lsc_grid.cpp',
     'lsc_polynomial.cpp',
     'lux.cpp',
     'module.cpp',
diff --git a/src/ipa/rkisp1/algorithms/lsc.cpp b/src/ipa/rkisp1/algorithms/lsc.cpp
index 25f7c512b35c..51076737ffa5 100644
--- a/src/ipa/rkisp1/algorithms/lsc.cpp
+++ b/src/ipa/rkisp1/algorithms/lsc.cpp
@@ -2,7 +2,7 @@
 /*
  * Copyright (C) 2021-2022, Ideas On Board
  *
- * RkISP1 Lens Shading Correction control
+ * RkISP1 Lens Shading Correction algorithm
  */
 
 #include "lsc.h"
@@ -14,56 +14,17 @@
 #include <libcamera/base/log.h>
 #include <libcamera/base/utils.h>
 
-#include "libcamera/internal/value_node.h"
-
-#include "libipa/lsc_polynomial.h"
-#include "linux/rkisp1-config.h"
-
 /**
  * \file lsc.h
  */
 
 namespace libcamera {
 
-namespace ipa {
-
-template<typename T>
-void interpolateVector(const std::vector<T> &a, const std::vector<T> &b,
-		       std::vector<T> &dest, double lambda)
-{
-	ASSERT(a.size() == b.size());
-	dest.resize(a.size());
-	for (size_t i = 0; i < a.size(); i++)
-		dest[i] = a[i] * (1.0 - lambda) + b[i] * lambda;
-}
-
-template<>
-void Interpolator<rkisp1::algorithms::LensShadingCorrection::Components>::
-	interpolate(const rkisp1::algorithms::LensShadingCorrection::Components &a,
-		    const rkisp1::algorithms::LensShadingCorrection::Components &b,
-		    rkisp1::algorithms::LensShadingCorrection::Components &dest,
-		    double lambda)
-{
-	interpolateVector(a.r, b.r, dest.r, lambda);
-	interpolateVector(a.gr, b.gr, dest.gr, lambda);
-	interpolateVector(a.gb, b.gb, dest.gb, lambda);
-	interpolateVector(a.b, b.b, dest.b, lambda);
-}
-
-} /* namespace ipa */
-
 namespace ipa::rkisp1::algorithms {
 
 /**
  * \class LensShadingCorrection
  * \brief RkISP1 Lens Shading Correction control
- *
- * Due to the optical characteristics of the lens, the light intensity received
- * by the sensor is not uniform.
- *
- * The Lens Shading Correction algorithm applies multipliers to all pixels
- * to compensate for the lens shading effect. The coefficients are
- * specified in a downscaled table in the YAML tuning file.
  */
 
 LOG_DEFINE_CATEGORY(RkISP1Lsc)
@@ -72,265 +33,20 @@ namespace {
 
 constexpr int kColourTemperatureQuantization = 10;
 
-class LscPolynomialShadingDescriptor : public LensShadingCorrection::ShadingDescriptor
-{
-public:
-	LscPolynomialShadingDescriptor(const LscPolynomial &pr, const LscPolynomial &pgr,
-				       const LscPolynomial &pgb, const LscPolynomial &pb)
-		: pr_(pr), pgr_(pgr), pgb_(pgb), pb_(pb)
-	{
-	}
-
-	LensShadingCorrection::Components sampleForCrop(const Rectangle &cropRectangle,
-							Span<const double> xSizes,
-							Span<const double> ySizes) override;
-
-private:
-	std::vector<uint16_t> samplePolynomial(const LscPolynomial &poly,
-					       Span<const double> xPositions,
-					       Span<const double> yPositions,
-					       const Rectangle &cropRectangle);
-
-	std::vector<double> sizesListToPositions(Span<const double> sizes);
-
-	LscPolynomial pr_;
-	LscPolynomial pgr_;
-	LscPolynomial pgb_;
-	LscPolynomial pb_;
-};
-
-LensShadingCorrection::Components
-LscPolynomialShadingDescriptor::sampleForCrop(const Rectangle &cropRectangle,
-					      Span<const double> xSizes,
-					      Span<const double> ySizes)
-{
-	std::vector<double> xPos = sizesListToPositions(xSizes);
-	std::vector<double> yPos = sizesListToPositions(ySizes);
-
-	return {
-		.r = samplePolynomial(pr_, xPos, yPos, cropRectangle),
-		.gr = samplePolynomial(pgr_, xPos, yPos, cropRectangle),
-		.gb = samplePolynomial(pgb_, xPos, yPos, cropRectangle),
-		.b = samplePolynomial(pb_, xPos, yPos, cropRectangle)
-	};
-}
-
-std::vector<uint16_t>
-LscPolynomialShadingDescriptor::samplePolynomial(const LscPolynomial &poly,
-						 Span<const double> xPositions,
-						 Span<const double> yPositions,
-						 const Rectangle &cropRectangle)
-{
-	double m = poly.getM();
-	double x0 = cropRectangle.x / m;
-	double y0 = cropRectangle.y / m;
-	double w = cropRectangle.width / m;
-	double h = cropRectangle.height / m;
-	std::vector<uint16_t> samples;
-
-	samples.reserve(xPositions.size() * yPositions.size());
-
-	for (double y : yPositions) {
-		for (double x : xPositions) {
-			double xp = x0 + x * w;
-			double yp = y0 + y * h;
-			/*
-			 * The hardware uses 2.10 fixed point format and limits
-			 * the legal values to [1..3.999]. Scale and clamp the
-			 * sampled value accordingly.
-			 */
-			int v = static_cast<int>(
-				poly.sampleAtNormalizedPixelPos(xp, yp) *
-				1024);
-			v = std::clamp(v, 1024, 4095);
-			samples.push_back(v);
-		}
-	}
-	return samples;
-}
-
-/*
- * The rkisp1 LSC grid spacing is defined by the cell sizes on the top-left
- * quadrant of the grid. This is then mirrored in hardware to the other
- * quadrants. See parseSizes() for further details. For easier handling, this
- * function converts the cell sizes of half the grid to a list of position of
- * the whole grid (on one axis). Example:
- *
- * input:   | 0.2 | 0.3 |
- * output: 0.0   0.2   0.5   0.8   1.0
- */
-std::vector<double>
-LscPolynomialShadingDescriptor::sizesListToPositions(Span<const double> sizes)
-{
-	const int half = sizes.size();
-	std::vector<double> positions(half * 2 + 1);
-	double x = 0.0;
-
-	positions[half] = 0.5;
-	for (int i = 1; i <= half; i++) {
-		x += sizes[half - i];
-		positions[half - i] = 0.5 - x;
-		positions[half + i] = 0.5 + x;
-	}
-
-	return positions;
-}
-
-class LscPolynomialLoader
-{
-public:
-	LscPolynomialLoader(const Size &sensorSize)
-		: sensorSize_(sensorSize)
-	{
-	}
-
-	int parseLscData(const ValueNode &yamlSets,
-			 LensShadingCorrection::ShadingDescriptorMap &lscData);
-
-private:
-	Size sensorSize_;
-};
-
-int LscPolynomialLoader::parseLscData(const ValueNode &yamlSets,
-				      LensShadingCorrection::ShadingDescriptorMap &lscData)
+unsigned int quantize(unsigned int value, unsigned int step)
 {
-	const auto &sets = yamlSets.asList();
-	for (const auto &yamlSet : sets) {
-		std::optional<LscPolynomial> pr, pgr, pgb, pb;
-		uint32_t ct = yamlSet["ct"].get<uint32_t>(0);
-
-		if (lscData.count(ct)) {
-			LOG(RkISP1Lsc, Error)
-				<< "Multiple sets found for "
-				<< "color temperature " << ct;
-			return -EINVAL;
-		}
-
-		pr = yamlSet["r"].get<LscPolynomial>();
-		pgr = yamlSet["gr"].get<LscPolynomial>();
-		pgb = yamlSet["gb"].get<LscPolynomial>();
-		pb = yamlSet["b"].get<LscPolynomial>();
-
-		if (!(pr || pgr || pgb || pb)) {
-			LOG(RkISP1Lsc, Error)
-				<< "Failed to parse polynomial for "
-				<< "colour temperature " << ct;
-			return -EINVAL;
-		}
-
-		pr->setReferenceImageSize(sensorSize_);
-		pgr->setReferenceImageSize(sensorSize_);
-		pgb->setReferenceImageSize(sensorSize_);
-		pb->setReferenceImageSize(sensorSize_);
-
-		lscData.emplace(
-			ct, std::make_unique<LscPolynomialShadingDescriptor>(
-				    *pr, *pgr, *pgb, *pb));
-	}
-
-	if (lscData.empty()) {
-		LOG(RkISP1Lsc, Error) << "Failed to load any sets";
-		return -EINVAL;
-	}
-
-	return 0;
+	return std::lround(value / static_cast<double>(step)) * step;
 }
 
-class LscTableShadingDescriptor : public LensShadingCorrection::ShadingDescriptor
-{
-public:
-	LscTableShadingDescriptor(LensShadingCorrection::Components components)
-		: lscData_(std::move(components))
-	{
-	}
-
-	LensShadingCorrection::Components
-	sampleForCrop([[maybe_unused]] const Rectangle &cropRectangle,
-		      [[maybe_unused]] Span<const double> xSizes,
-		      [[maybe_unused]] Span<const double> ySizes) override
-	{
-		LOG(RkISP1Lsc, Warning)
-			<< "Tabular LSC data doesn't support resampling";
-		return lscData_;
-	}
-
-private:
-	LensShadingCorrection::Components lscData_;
-};
-
-class LscTableLoader
-{
-public:
-	int parseLscData(const ValueNode &yamlSets,
-			 LensShadingCorrection::ShadingDescriptorMap &lscData);
-
-private:
-	std::vector<uint16_t> parseTable(const ValueNode &tuningData,
-					 const char *prop);
-};
-
-int LscTableLoader::parseLscData(const ValueNode &yamlSets,
-				 LensShadingCorrection::ShadingDescriptorMap &lscData)
-{
-	const auto &sets = yamlSets.asList();
-
-	for (const auto &yamlSet : sets) {
-		uint32_t ct = yamlSet["ct"].get<uint32_t>(0);
-
-		if (lscData.count(ct)) {
-			LOG(RkISP1Lsc, Error)
-				<< "Multiple sets found for color temperature "
-				<< ct;
-			return -EINVAL;
-		}
-
-		LensShadingCorrection::Components set;
-		set.r = parseTable(yamlSet, "r");
-		set.gr = parseTable(yamlSet, "gr");
-		set.gb = parseTable(yamlSet, "gb");
-		set.b = parseTable(yamlSet, "b");
-
-		if (set.r.empty() || set.gr.empty() ||
-		    set.gb.empty() || set.b.empty()) {
-			LOG(RkISP1Lsc, Error)
-				<< "Set for color temperature " << ct
-				<< " is missing tables";
-			return -EINVAL;
-		}
-
-		lscData.emplace(
-			ct, std::make_unique<LscTableShadingDescriptor>(std::move(set)));
-	}
-
-	if (lscData.empty()) {
-		LOG(RkISP1Lsc, Error) << "Failed to load any sets";
-		return -EINVAL;
-	}
-
-	return 0;
-}
+} /* namespace */
 
-std::vector<uint16_t> LscTableLoader::parseTable(const ValueNode &tuningData,
-						 const char *prop)
+LensShadingCorrection::LensShadingCorrection()
+	: lastAppliedCt_(0), lastAppliedQuantizedCt_(0)
 {
-	static constexpr unsigned int kLscNumSamples =
-		RKISP1_CIF_ISP_LSC_SAMPLES_MAX * RKISP1_CIF_ISP_LSC_SAMPLES_MAX;
-
-	std::vector<uint16_t> table =
-		tuningData[prop].get<std::vector<uint16_t>>().value_or(utils::defopt);
-	if (table.size() != kLscNumSamples) {
-		LOG(RkISP1Lsc, Error)
-			<< "Invalid '" << prop << "' values: expected "
-			<< kLscNumSamples
-			<< " elements, got " << table.size();
-		return {};
-	}
-
-	return table;
 }
 
-std::vector<double> parseSizes(const ValueNode &tuningData,
-			       const char *prop)
+std::vector<double> LensShadingCorrection::parseSizes(const ValueNode &tuningData,
+						      const char *prop)
 {
 	std::vector<double> sizes =
 		tuningData[prop].get<std::vector<double>>().value_or(utils::defopt);
@@ -359,22 +75,36 @@ std::vector<double> parseSizes(const ValueNode &tuningData,
 	return sizes;
 }
 
-unsigned int quantize(unsigned int value, unsigned int step)
+/*
+ * The rkisp1 LSC grid spacing is defined by the cell sizes on the top-left
+ * quadrant of the grid. This is then mirrored in hardware to the other
+ * quadrants. See parseSizes() for further details. For easier handling, this
+ * function converts the cell sizes of half the grid to a list of position of
+ * the whole grid (on one axis). Example:
+ *
+ * input:   | 0.2 | 0.3 |
+ * output: 0.0   0.2   0.5   0.8   1.0
+ */
+std::vector<double> LensShadingCorrection::sizesToPositions(Span<const double> sizes)
 {
-	return std::lround(value / static_cast<double>(step)) * step;
-}
+	const int half = sizes.size();
+	std::vector<double> positions(half * 2 + 1);
+	double x = 0.0;
 
-} /* namespace */
+	positions[half] = 0.5;
+	for (int i = 1; i <= half; i++) {
+		x += sizes[half - i];
+		positions[half - i] = 0.5 - x;
+		positions[half + i] = 0.5 + x;
+	}
 
-LensShadingCorrection::LensShadingCorrection()
-	: lastAppliedCt_(0), lastAppliedQuantizedCt_(0)
-{
+	return positions;
 }
 
 /**
  * \copydoc libcamera::ipa::Algorithm::init
  */
-int LensShadingCorrection::init([[maybe_unused]] IPAContext &context,
+int LensShadingCorrection::init(IPAContext &context,
 				const ValueNode &tuningData)
 {
 	xSize_ = parseSizes(tuningData, "x-size");
@@ -383,56 +113,27 @@ int LensShadingCorrection::init([[maybe_unused]] IPAContext &context,
 	if (xSize_.empty() || ySize_.empty())
 		return -EINVAL;
 
-	/* Get all defined sets to apply. */
-	const ValueNode &yamlSets = tuningData["sets"];
-	if (!yamlSets.isList()) {
-		LOG(RkISP1Lsc, Error)
-			<< "'sets' parameter not found in tuning file";
-		return -EINVAL;
-	}
-
-	ShadingDescriptorMap lscData;
-	int ret = 0;
-
-	std::string type = tuningData["type"].get<std::string>("table");
-	if (type == "table") {
-		LOG(RkISP1Lsc, Debug) << "Loading tabular LSC data.";
-		auto loader = LscTableLoader();
-		ret = loader.parseLscData(yamlSets, lscData);
-	} else if (type == "polynomial") {
-		LOG(RkISP1Lsc, Debug) << "Loading polynomial LSC data.";
-		/*
-		 * \todo: Most likely the reference frame should be native_size.
-		 * Let's wait how the internal discussions progress.
-		 */
-		auto loader = LscPolynomialLoader(context.sensorInfo.activeAreaSize);
-		ret = loader.parseLscData(yamlSets, lscData);
-	} else {
-		LOG(RkISP1Lsc, Error) << "Unsupported LSC data type '"
-				      << type << "'";
-		ret = -EINVAL;
-	}
-
-	if (ret)
-		return ret;
+	xPos_ = sizesToPositions(xSize_);
+	yPos_ = sizesToPositions(ySize_);
 
-	context.ctrlMap[&controls::LensShadingCorrectionEnable] =
-		ControlInfo(false, true, true);
-
-	shadingDescriptors_ = std::move(lscData);
-
-	return 0;
+	return lscAlgo_.init(tuningData, context.ctrlMap, {
+				.keys = { "r", "gr", "gb", "b" },
+				.numHCells = RKISP1_CIF_ISP_LSC_SAMPLES_MAX,
+				.numVCells = RKISP1_CIF_ISP_LSC_SAMPLES_MAX,
+				.sensorSize = context.sensorInfo.activeAreaSize
+			     });
 }
 
 /**
  * \copydoc libcamera::ipa::Algorithm::configure
  */
 int LensShadingCorrection::configure(IPAContext &context,
-				     [[maybe_unused]] const IPACameraSensorInfo &configInfo)
+				     const IPACameraSensorInfo &configInfo)
 {
 	const Size &size = context.configuration.sensor.size;
 	Size totalSize{};
 
+	/* Calculate gradients. */
 	for (unsigned int i = 0; i < RKISP1_CIF_ISP_LSC_SECTORS_TBL_SIZE; ++i) {
 		xSizes_[i] = xSize_[i] * size.width;
 		ySizes_[i] = ySize_[i] * size.height;
@@ -455,16 +156,8 @@ int LensShadingCorrection::configure(IPAContext &context,
 		yGrad_[i] = std::round(32768 / ySizes_[i]);
 	}
 
-	LOG(RkISP1Lsc, Debug) << "Sample LSC data for " << configInfo.analogCrop;
-	std::map<unsigned int, LensShadingCorrection::Components> shadingData;
-	for (const auto &[t, descriptor] : shadingDescriptors_)
-		shadingData[t] = descriptor->sampleForCrop(configInfo.analogCrop,
-							   xSize_, ySize_);
-
-	sets_.setData(std::move(shadingData));
-
-	context.activeState.lsc.enabled = true;
-	return 0;
+	return lscAlgo_.configure(context.activeState.lsc, configInfo.analogCrop,
+				  xPos_, yPos_);
 }
 
 void LensShadingCorrection::setParameters(rkisp1_cif_isp_lsc_config &config)
@@ -476,12 +169,16 @@ void LensShadingCorrection::setParameters(rkisp1_cif_isp_lsc_config &config)
 }
 
 void LensShadingCorrection::copyTable(rkisp1_cif_isp_lsc_config &config,
-				      const Components &set)
+				      const ipa::lsc::Components<uint16_t> &set)
 {
-	std::copy(set.r.begin(), set.r.end(), &config.r_data_tbl[0][0]);
-	std::copy(set.gr.begin(), set.gr.end(), &config.gr_data_tbl[0][0]);
-	std::copy(set.gb.begin(), set.gb.end(), &config.gb_data_tbl[0][0]);
-	std::copy(set.b.begin(), set.b.end(), &config.b_data_tbl[0][0]);
+	const auto &r = set.at("r");
+	std::copy(r.begin(), r.end(), &config.r_data_tbl[0][0]);
+	const auto &gr = set.at("gr");
+	std::copy(gr.begin(), gr.end(), &config.gr_data_tbl[0][0]);
+	const auto &gb = set.at("gb");
+	std::copy(gb.begin(), gb.end(), &config.gb_data_tbl[0][0]);
+	const auto &b = set.at("b");
+	std::copy(b.begin(), b.end(), &config.b_data_tbl[0][0]);
 }
 
 /**
@@ -492,19 +189,8 @@ void LensShadingCorrection::queueRequest(IPAContext &context,
 					 IPAFrameContext &frameContext,
 					 const ControlList &controls)
 {
-	auto &lsc = context.activeState.lsc;
-
-	const auto &lscEnable = controls.get(controls::LensShadingCorrectionEnable);
-	if (lscEnable && *lscEnable != lsc.enabled) {
-		lsc.enabled = *lscEnable;
-
-		LOG(RkISP1Lsc, Debug)
-			<< (lsc.enabled ? "Enabling" : "Disabling") << " Lsc";
-
-		frameContext.lsc.update = true;
-	}
-
-	frameContext.lsc.enabled = lsc.enabled;
+	lscAlgo_.queueRequest(context.activeState.lsc, frameContext.lsc,
+			      controls);
 }
 
 /**
@@ -542,7 +228,7 @@ void LensShadingCorrection::prepare([[maybe_unused]] IPAContext &context,
 
 	setParameters(*config);
 
-	const Components &set = sets_.getInterpolated(quantizedCt);
+	const auto &set = lscAlgo_.interpolateComponents(quantizedCt);
 	copyTable(*config, set);
 
 	lastAppliedCt_ = ct;
@@ -562,7 +248,7 @@ void LensShadingCorrection::process([[maybe_unused]] IPAContext &context,
 				    [[maybe_unused]] const rkisp1_stat_buffer *stats,
 				    ControlList &metadata)
 {
-	metadata.set(controls::LensShadingCorrectionEnable, frameContext.lsc.enabled);
+	lscAlgo_.process(frameContext.lsc, metadata);
 }
 
 REGISTER_IPA_ALGORITHM(LensShadingCorrection, "LensShadingCorrection")
diff --git a/src/ipa/rkisp1/algorithms/lsc.h b/src/ipa/rkisp1/algorithms/lsc.h
index 0a256e225327..7c656d95acfb 100644
--- a/src/ipa/rkisp1/algorithms/lsc.h
+++ b/src/ipa/rkisp1/algorithms/lsc.h
@@ -2,17 +2,23 @@
 /*
  * Copyright (C) 2021-2022, Ideas On Board
  *
- * RkISP1 Lens Shading Correction control
+ * RkISP1 Lens Shading Correction algorithm
  */
 
 #pragma once
 
-#include <map>
-#include <memory>
+#include <vector>
 
-#include "libipa/interpolator.h"
+#include <linux/rkisp1-config.h>
+
+#include "libcamera/internal/value_node.h"
+
+#include "libipa/fixedpoint.h"
+#include "libipa/lsc.h"
 
 #include "algorithm.h"
+#include "ipa_context.h"
+#include "params.h"
 
 namespace libcamera {
 
@@ -37,39 +43,28 @@ public:
 		     const rkisp1_stat_buffer *stats,
 		     ControlList &metadata) override;
 
-	struct Components {
-		std::vector<uint16_t> r;
-		std::vector<uint16_t> gr;
-		std::vector<uint16_t> gb;
-		std::vector<uint16_t> b;
-	};
-
-	class ShadingDescriptor
-	{
-	public:
-		virtual ~ShadingDescriptor() = default;
-		virtual Components sampleForCrop(const Rectangle &cropRectangle,
-						 Span<const double> xSizes,
-						 Span<const double> ySizes) = 0;
-	};
-
-	using ShadingDescriptorMap = std::map<unsigned int, std::unique_ptr<ShadingDescriptor>>;
-
 private:
+	std::vector<double> parseSizes(const ValueNode &tuningData,
+				       const char *prop);
+	std::vector<double> sizesToPositions(Span<const double> sizes);
+
 	void setParameters(rkisp1_cif_isp_lsc_config &config);
-	void copyTable(rkisp1_cif_isp_lsc_config &config, const Components &set0);
+	void copyTable(rkisp1_cif_isp_lsc_config &config,
+		       const ipa::lsc::Components<uint16_t> &set0);
 
-	ShadingDescriptorMap shadingDescriptors_;
-	ipa::Interpolator<Components> sets_;
 	std::vector<double> xSize_;
 	std::vector<double> ySize_;
 	uint16_t xGrad_[RKISP1_CIF_ISP_LSC_SECTORS_TBL_SIZE];
 	uint16_t yGrad_[RKISP1_CIF_ISP_LSC_SECTORS_TBL_SIZE];
 	uint16_t xSizes_[RKISP1_CIF_ISP_LSC_SECTORS_TBL_SIZE];
 	uint16_t ySizes_[RKISP1_CIF_ISP_LSC_SECTORS_TBL_SIZE];
+	std::vector<double> xPos_;
+	std::vector<double> yPos_;
 
 	unsigned int lastAppliedCt_;
 	unsigned int lastAppliedQuantizedCt_;
+
+	LscAlgorithm<uint16_t, UQ<2, 10>> lscAlgo_;
 };
 
 } /* namespace ipa::rkisp1::algorithms */
diff --git a/src/ipa/rkisp1/ipa_context.h b/src/ipa/rkisp1/ipa_context.h
index cd97e10bcf2b..005f4102b4f6 100644
--- a/src/ipa/rkisp1/ipa_context.h
+++ b/src/ipa/rkisp1/ipa_context.h
@@ -30,6 +30,7 @@
 #include "libipa/ccm.h"
 #include "libipa/fc_queue.h"
 #include "libipa/fixedpoint.h"
+#include "libipa/lsc.h"
 
 namespace libcamera {
 
@@ -138,9 +139,7 @@ struct IPAActiveState {
 		double strength;
 	} wdr;
 
-	struct {
-		bool enabled;
-	} lsc;
+	ipa::lsc::ActiveState lsc;
 };
 
 struct IPAFrameContext : public FrameContext {
@@ -213,10 +212,7 @@ struct IPAFrameContext : public FrameContext {
 		double gain;
 	} wdr;
 
-	struct {
-		bool enabled;
-		bool update;
-	} lsc;
+	ipa::lsc::FrameContext lsc;
 };
 
 struct IPAContext {
