Repository: OSGeo/gdal
Pull request #12232: VRTDerivedRasterBand: Add reclassify pixel function
Author: login: dbaston
Created: 2025-04-30T12:30:52Z | Merged: 2025-04-30T19:17:04Z

## What does this PR do?

Adds a "reclassify" pixel function. Can map values from individual values or specified ranges.

Doesn't currently check the ranges to make sure they are non-overlapping. Range checking is via O(n) lookup, could be improved by binary search later.

## What are related issues/pull requests?

## Tasklist

 - [ ] Review
 - [ ] Adjust for comments
 - [ ] All CI builds and checks have passed


Diff:
diff --git a/autotest/gdrivers/vrtderived.py b/autotest/gdrivers/vrtderived.py
index 0764a5854787..e26b2543be77 100755
--- a/autotest/gdrivers/vrtderived.py
+++ b/autotest/gdrivers/vrtderived.py
@@ -1292,3 +1292,151 @@ def test_vrt_pixelfn_constant_factor(tmp_vsimem, fn):
         np.testing.assert_array_equal(dst, src + k)
     elif fn == "mul":
         np.testing.assert_array_equal(dst, src * k)
+
+
+###############################################################################
+# Test reclassification
+
+
+@pytest.mark.parametrize("default", (7, "NO_DATA"))
+def test_vrt_pixelfn_reclassify(tmp_vsimem, default):
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 3
+    ny = 5
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Float32" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping=" (-inf, 1)=8; 2=9 ; (3,5]=4; [11, Inf] = 11 " default="{default}"/>
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>
+        </SimpleSource>
+        <NoDataValue>7</NoDataValue>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    dst = gdal.Open(xml).ReadAsArray()
+
+    np.testing.assert_array_equal(
+        dst, np.array([[8, 7, 9], [7, 4, 4], [7, 7, 7], [7, 7, 11], [11, 11, 11]])
+    )
+
+
+@gdaltest.enable_exceptions()
+def test_vrt_pixelfn_reclassify_no_default(tmp_vsimem):
+
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 2
+    ny = 3
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Float32" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping="1=2;3=4"/>
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>
+        </SimpleSource>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    with pytest.raises(
+        Exception, match="Encountered value .* with no specified mapping"
+    ):
+        gdal.Open(xml).ReadAsArray()
+
+
+@gdaltest.enable_exceptions()
+@pytest.mark.parametrize(
+    "default,error",
+    [
+        ("32k", "Failed to parse"),
+        ("", "Failed to parse"),
+        ("256", "cannot be represented"),
+        ("NO_DATA", "NoData value is not set"),
+    ],
+)
+def test_vrt_pixelfn_reclassify_bad_default(tmp_vsimem, default, error):
+
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 2
+    ny = 3
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Byte" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping="1=2;3=4" default="{default}" />
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>
+        </SimpleSource>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    with pytest.raises(Exception, match=error):
+        gdal.Open(xml).ReadAsArray()
+
+
+@gdaltest.enable_exceptions()
+@pytest.mark.parametrize(
+    "mapping,error",
+    [
+        ("1=2;3", "expected '='"),
+        ("1=2;3=4g", "expected ';' or end"),
+        ("1=2;", "Interval must start with"),
+        ("1=3;3=256", "cannot be represented"),
+        ("(1,}=3;3=4,", "Interval must end with"),
+    ],
+)
+def test_vrt_pixelfn_reclassify_invalid_mapping(tmp_vsimem, mapping, error):
+
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 2
+    ny = 3
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Byte" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping="{mapping}" default="7" />
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
+        </SimpleSource>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    with pytest.raises(Exception, match=error):
+        gdal.Open(xml).ReadAsArray()
diff --git a/doc/source/drivers/raster/vrt.rst b/doc/source/drivers/raster/vrt.rst
index 1135e51928f3..8ce7c1141399 100644
--- a/doc/source/drivers/raster/vrt.rst
+++ b/doc/source/drivers/raster/vrt.rst
@@ -457,6 +457,7 @@ Clamping is performed for input pixel values outside of the range specified by t
 input pixel value is lower than the minimum source value, then the destination
 value corresponding to that minimum source value is used as the output pixel value.
 And similarly for an input pixel value that is greater than the maximum source value.
+To remap values without interpolation, the "reclassify" pixel function can be used. See :ref:`vrt_derived_bands`.
 
 The ComplexSource supports fetching a color component from a source raster
 band that has a color table. The ColorTableComponent value is the index of the
@@ -1221,6 +1222,10 @@ GDAL provides a set of default pixel functions that can be used without writing
      - 1
      - -
      - extract real part from a single raster band (just a copy if the input is non-complex)
+   * - **reclassify**
+     - = 1
+     - ``mapping``, ``default``
+     - reclassify values according to a mapping provided by ``mapping``. The format of the mapping is ``SOURCE=DEST;SOURCE=DEST;..`` where each ``SOURCE`` element is either a single value or an interval (e.g., ``(-inf,30]``). An error will be raised if a pixel value is not covered by any interval, unless the ```default`` argument has been specified. ``default`` may also be set any constant or ``NO_DATA``. A similar functionality, but with interpolation, is offered by the ``ComplexSource``.
    * - **replace_nodata**
      - = 1
      - ``to`` (optional)
diff --git a/frmts/vrt/pixelfunctions.cpp b/frmts/vrt/pixelfunctions.cpp
index ce4886fc6eaa..4b1422033082 100644
--- a/frmts/vrt/pixelfunctions.cpp
+++ b/frmts/vrt/pixelfunctions.cpp
@@ -1731,6 +1731,336 @@ static CPLErr ExprPixelFunc(void **papoSources, int nSources, void *pData,
     return CE_None;
 }  // ExprPixelFunc
 
+struct Interval
+{
+    double dfMin;
+    double dfMax;
+    bool bMinIncluded;
+    bool bMaxIncluded;
+
+    CPLErr Parse(const char *s, char **rest)
+    {
+        const char *start = s;
+
+        while (isspace(*start))
+        {
+            start++;
+        }
+
+        char *end;
+
+        if (*start == '(')
+        {
+            bMinIncluded = false;
+        }
+        else if (*start == '[')
+        {
+            bMinIncluded = true;
+        }
+        else
+        {
+            dfMin = CPLStrtod(start, &end);
+
+            if (end == start)
+            {
+                CPLError(CE_Failure, CPLE_AppDefined,
+                         "Interval must start with '(' or ']'");
+                return CE_Failure;
+            }
+
+            dfMax = dfMin;
+            bMinIncluded = true;
+            bMaxIncluded = true;
+
+            if (rest != nullptr)
+            {
+                *rest = end;
+            }
+
+            return CE_None;
+        }
+        start++;
+
+        while (isspace(*start))
+        {
+            start++;
+        }
+
+        if (STARTS_WITH_CI(start, "-inf"))
+        {
+            dfMin = -std::numeric_limits<double>::infinity();
+            end = const_cast<char *>(start + 4);
+        }
+        else
+        {
+            dfMin = CPLStrtod(start, &end);
+        }
+
+        if (end == start || *end != ',')
+        {
+            CPLError(CE_Failure, CPLE_AppDefined, "Expected a number");
+            return CE_Failure;
+        }
+        start = end + 1;
+
+        while (isspace(*start))
+        {
+            start++;
+        }
+
+        if (STARTS_WITH_CI(start, "inf"))
+        {
+            dfMax = std::numeric_limits<double>::infinity();
+            end = const_cast<char *>(start + 3);
+        }
+        else
+        {
+            dfMax = CPLStrtod(start, &end);
+        }
+
+        if (end == start || (*end != ')' && *end != ']'))
+        {
+            CPLError(CE_Failure, CPLE_AppDefined,
+                     "Interval must end with ')' or ']");
+            return CE_Failure;
+        }
+        if (*end == ')')
+        {
+            bMaxIncluded = false;
+        }
+        else
+        {
+            bMaxIncluded = true;
+        }
+
+        if (rest != nullptr)
+        {
+            *rest = end + 1;
+        }
+
+        return CE_None;
+    }
+
+    bool IsConstant() const
+    {
+        return dfMin == dfMax;
+    }
+
+    bool Contains(double x) const
+    {
+        return (x > dfMin || (bMinIncluded && x == dfMin)) &&
+               (x < dfMax || (bMaxIncluded && x == dfMax));
+    }
+};
+
+static const char pszReclassifyPixelFuncMetadata[] =
+    "<PixelFunctionArgumentsList>"
+    "   <Argument name='mapping' "
+    "             description='Lookup table for mapping, in format "
+    "from=to,from=to' "
+    "             type='string'></Argument>"
+    "   <Argument name='default' "
+    "             description='Default value to assign' "
+    "             type='string'></Argument>"
+    "   <Argument type='builtin' value='NoData' optional='true' />"
+    "</PixelFunctionArgumentsList>";
+
+static CPLErr ReclassifyPixelFunc(void **papoSources, int nSources, void *pData,
+                                  int nXSize, int nYSize, GDALDataType eSrcType,
+                                  GDALDataType eBufType, int nPixelSpace,
+                                  int nLineSpace, CSLConstList papszArgs)
+{
+    if (GDALDataTypeIsComplex(eSrcType))
+    {
+        CPLError(CE_Failure, CPLE_AppDefined,
+                 "reclassify cannot by applied to complex data types");
+        return CE_Failure;
+    }
+
+    if (nSources != 1)
+    {
+        CPLError(CE_Failure, CPLE_AppDefined,
+                 "reclassify only be applied to a single source at a time");
+        return CE_Failure;
+    }
+
+    const char *pszDefault = CSLFetchNameValue(papszArgs, "default");
+
+    bool bHasDefaultValue = false;
+    double dfDefaultValue{};
+    if (pszDefault != nullptr)
+    {
+        bHasDefaultValue = true;
+
+        if (EQUAL(pszDefault, "NO_DATA"))
+        {
+            const char *pszNoData = CSLFetchNameValue(papszArgs, "NoData");
+            if (pszNoData == nullptr)
+            {
+                CPLError(
+                    CE_Failure, CPLE_AppDefined,
+                    "Reclassify default set to NO_DATA, but NoData value is "
+                    "not set");
+                return CE_Failure;
+            }
+
+            dfDefaultValue = CPLAtof(pszNoData);
+        }
+        else
+        {
+            char *end;
+            dfDefaultValue = CPLStrtod(pszDefault, &end);
+            if (end == pszDefault || *end != '\0')
+            {
+                CPLError(CE_Failure, CPLE_AppDefined,
+                         "Failed to parse default");
+                return CE_Failure;
+            }
+
+            if (!GDALIsValueExactAs(dfDefaultValue, eBufType))
+            {
+                CPLError(
+                    CE_Failure, CPLE_AppDefined,
+                    "Default value %g cannot be represented as data type %s",
+                    dfDefaultValue, GDALGetDataTypeName(eBufType));
+                return CE_Failure;
+            }
+        }
+    }
+
+    std::map<double, double> oConstantMappings;
+    std::vector<std::pair<Interval, double>> aoIntervalMappings;
+
+    constexpr char MAPPING_INTERVAL_SEP_CHAR = ';';
+    constexpr char MAPPING_FROMTO_SEP_CHAR = '=';
+
+    const char *pszMappings = CSLFetchNameValue(papszArgs, "mapping");
+    if (pszMappings == nullptr)
+    {
+        CPLError(CE_Failure, CPLE_AppDefined,
+                 "reclassify must be called with 'mapping' argument");
+        return CE_Failure;
+    }
+
+    const char *start = pszMappings;
+    char *end = const_cast<char *>(start);
+    while (*end != '\0')
+    {
+        while (isspace(*start))
+        {
+            start++;
+        }
+
+        Interval sInt;
+
+        if (auto eErr = sInt.Parse(start, &end); eErr != CE_None)
+        {
+            return eErr;
+        }
+
+        while (isspace(*end))
+        {
+            end++;
+        }
+
+        if (*end != MAPPING_FROMTO_SEP_CHAR)
+        {
+            CPLError(CE_Failure, CPLE_AppDefined,
+                     "Failed to parse mapping (expected '%c', got '%c')",
+                     MAPPING_FROMTO_SEP_CHAR, *end);
+            return CE_Failure;
+        }
+
+        double dfDstVal = CPLStrtod(end + 1, &end);
+
+        while (isspace(*end))
+        {
+            end++;
+        }
+
+        if (*end != '\0' && *end != MAPPING_INTERVAL_SEP_CHAR)
+        {
+            CPLError(CE_Failure, CPLE_AppDefined,
+                     "Failed to parse mapping (expected '%c' or end of string, "
+                     "got '%c')",
+                     MAPPING_INTERVAL_SEP_CHAR, *end);
+            return CE_Failure;
+        }
+
+        if (!GDALIsValueExactAs(dfDstVal, eBufType))
+        {
+            CPLError(CE_Failure, CPLE_AppDefined,
+                     "Value %g cannot be represented as data type %s", dfDstVal,
+                     GDALGetDataTypeName(eBufType));
+            return CE_Failure;
+        }
+
+        if (sInt.IsConstant())
+        {
+            oConstantMappings[sInt.dfMin] = dfDstVal;
+        }
+        else
+        {
+            aoIntervalMappings.emplace_back(sInt, dfDstVal);
+        }
+
+        start = end + 1;
+    }
+
+    std::unique_ptr<double, VSIFreeReleaser> padfResults(
+        static_cast<double *>(VSI_MALLOC2_VERBOSE(nXSize, sizeof(double))));
+    if (!padfResults)
+        return CE_Failure;
+
+    size_t ii = 0;
+    for (int iLine = 0; iLine < nYSize; ++iLine)
+    {
+        for (int iCol = 0; iCol < nXSize; ++iCol, ++ii)
+        {
+            double srcVal = GetSrcVal(papoSources[0], eSrcType, ii);
+            auto oDstValIt = oConstantMappings.find(srcVal);
+            if (oDstValIt == oConstantMappings.end())
+            {
+                bool bFoundInterval = false;
+                for (const auto &[sInt, dstVal] : aoIntervalMappings)
+                {
+                    if (sInt.Contains(srcVal))
+                    {
+                        padfResults.get()[iCol] = dstVal;
+                        bFoundInterval = true;
+                        break;
+                    }
+                }
+
+                if (!bFoundInterval)
+                {
+                    if (!bHasDefaultValue)
+                    {
+                        CPLError(
+                            CE_Failure, CPLE_AppDefined,
+                            "Encountered value %g with no specified mapping",
+                            srcVal);
+                        return CE_Failure;
+                    }
+
+                    padfResults.get()[iCol] = dfDefaultValue;
+                }
+            }
+            else
+            {
+                padfResults.get()[iCol] = oDstValIt->second;
+            }
+        }
+
+        GDALCopyWords(padfResults.get(), GDT_Float64, sizeof(double),
+                      static_cast<GByte *>(pData) +
+                          static_cast<GSpacing>(nLineSpace) * iLine,
+                      eBufType, nPixelSpace, nXSize);
+    }
+
+    return CE_None;
+}  // ReclassifyPixelFunc
+
 /************************************************************************/
 /*                     GDALRegisterDefaultPixelFunc()                   */
 /************************************************************************/
@@ -1798,6 +2128,7 @@ static CPLErr ExprPixelFunc(void **papoSources, int nSources, void *pData,
  * - "interpolate_exp": interpolate values between two raster bands using
  *                      exponential interpolation
  * - "scale": Apply the RasterBand metadata values of "offset" and "scale"
+ * - "reclassify": Reclassify values matching ranges in a table
  * - "nan": Convert incoming NoData values to IEEE 754 nan
  *
  * @see GDALAddDerivedBandPixelFunc
@@ -1854,5 +2185,7 @@ CPLErr GDALRegisterDefaultPixelFunc()
                                         pszMinMaxFuncMetadataNodata);
     GDALAddDerivedBandPixelFuncWithArgs("expression", ExprPixelFunc,
                                         pszExprPixelFuncMetadata);
+    GDALAddDerivedBandPixelFuncWithArgs("reclassify", ReclassifyPixelFunc,
+                                        pszReclassifyPixelFuncMetadata);
     return CE_None;
 }


Reviews:
user: login: rouault
body: Isn't that a bit similar to the ``<LUT>`` option of ``<ComplexSource>`` ?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T14:35:35Z
user: login: rouault
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-30T19:16:48Z

Review comments:
diff_hunk: @@ -1292,3 +1292,151 @@ def test_vrt_pixelfn_constant_factor(tmp_vsimem, fn):
         np.testing.assert_array_equal(dst, src + k)
     elif fn == "mul":
         np.testing.assert_array_equal(dst, src * k)
+
+
+###############################################################################
+# Test reclassification
+
+
+@pytest.mark.parametrize("default", (7, "NO_DATA"))
+def test_vrt_pixelfn_reclassify(tmp_vsimem, default):
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 3
+    ny = 5
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Float32" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping=" (-inf, 1)=8; 2=9 ; (3,5]=4; [11, Inf] = 11 " default="{default}"/>
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
path: autotest/gdrivers/vrtderived.py
user: login: rouault
body: ```suggestion
          <SourceBand>1</SourceBand>
```
created_at: 2025-04-30T14:34:27Z
author_association: MEMBER
diff_hunk: @@ -1292,3 +1292,151 @@ def test_vrt_pixelfn_constant_factor(tmp_vsimem, fn):
         np.testing.assert_array_equal(dst, src + k)
     elif fn == "mul":
         np.testing.assert_array_equal(dst, src * k)
+
+
+###############################################################################
+# Test reclassification
+
+
+@pytest.mark.parametrize("default", (7, "NO_DATA"))
+def test_vrt_pixelfn_reclassify(tmp_vsimem, default):
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 3
+    ny = 5
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Float32" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping=" (-inf, 1)=8; 2=9 ; (3,5]=4; [11, Inf] = 11 " default="{default}"/>
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
+        </SimpleSource>
+        <NoDataValue>7</NoDataValue>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    dst = gdal.Open(xml).ReadAsArray()
+
+    np.testing.assert_array_equal(
+        dst, np.array([[8, 7, 9], [7, 4, 4], [7, 7, 7], [7, 7, 11], [11, 11, 11]])
+    )
+
+
+@gdaltest.enable_exceptions()
+def test_vrt_pixelfn_reclassify_no_default(tmp_vsimem):
+
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 2
+    ny = 3
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Float32" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping="1=2;3=4"/>
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
path: autotest/gdrivers/vrtderived.py
user: login: rouault
body: ```suggestion
          <SourceBand>1</SourceBand>
```
created_at: 2025-04-30T14:34:35Z
author_association: MEMBER
diff_hunk: @@ -1292,3 +1292,151 @@ def test_vrt_pixelfn_constant_factor(tmp_vsimem, fn):
         np.testing.assert_array_equal(dst, src + k)
     elif fn == "mul":
         np.testing.assert_array_equal(dst, src * k)
+
+
+###############################################################################
+# Test reclassification
+
+
+@pytest.mark.parametrize("default", (7, "NO_DATA"))
+def test_vrt_pixelfn_reclassify(tmp_vsimem, default):
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 3
+    ny = 5
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Float32" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping=" (-inf, 1)=8; 2=9 ; (3,5]=4; [11, Inf] = 11 " default="{default}"/>
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
+        </SimpleSource>
+        <NoDataValue>7</NoDataValue>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    dst = gdal.Open(xml).ReadAsArray()
+
+    np.testing.assert_array_equal(
+        dst, np.array([[8, 7, 9], [7, 4, 4], [7, 7, 7], [7, 7, 11], [11, 11, 11]])
+    )
+
+
+@gdaltest.enable_exceptions()
+def test_vrt_pixelfn_reclassify_no_default(tmp_vsimem):
+
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 2
+    ny = 3
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Float32" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping="1=2;3=4"/>
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
+        </SimpleSource>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    with pytest.raises(
+        Exception, match="Encountered value .* with no specified mapping"
+    ):
+        gdal.Open(xml).ReadAsArray()
+
+
+@gdaltest.enable_exceptions()
+@pytest.mark.parametrize(
+    "default,error",
+    [
+        ("32k", "Failed to parse"),
+        ("", "Failed to parse"),
+        ("256", "cannot be represented"),
+        ("NO_DATA", "NoData value is not set"),
+    ],
+)
+def test_vrt_pixelfn_reclassify_bad_default(tmp_vsimem, default, error):
+
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 2
+    ny = 3
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Byte" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping="1=2;3=4" default="{default}" />
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
path: autotest/gdrivers/vrtderived.py
user: login: rouault
body: ```suggestion
          <SourceBand>1</SourceBand>
```
created_at: 2025-04-30T14:34:42Z
author_association: MEMBER
diff_hunk: @@ -1292,3 +1292,151 @@ def test_vrt_pixelfn_constant_factor(tmp_vsimem, fn):
         np.testing.assert_array_equal(dst, src + k)
     elif fn == "mul":
         np.testing.assert_array_equal(dst, src * k)
+
+
+###############################################################################
+# Test reclassification
+
+
+@pytest.mark.parametrize("default", (7, "NO_DATA"))
+def test_vrt_pixelfn_reclassify(tmp_vsimem, default):
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 3
+    ny = 5
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Float32" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping=" (-inf, 1)=8; 2=9 ; (3,5]=4; [11, Inf] = 11 " default="{default}"/>
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
+        </SimpleSource>
+        <NoDataValue>7</NoDataValue>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    dst = gdal.Open(xml).ReadAsArray()
+
+    np.testing.assert_array_equal(
+        dst, np.array([[8, 7, 9], [7, 4, 4], [7, 7, 7], [7, 7, 11], [11, 11, 11]])
+    )
+
+
+@gdaltest.enable_exceptions()
+def test_vrt_pixelfn_reclassify_no_default(tmp_vsimem):
+
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 2
+    ny = 3
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Float32" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping="1=2;3=4"/>
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
+        </SimpleSource>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    with pytest.raises(
+        Exception, match="Encountered value .* with no specified mapping"
+    ):
+        gdal.Open(xml).ReadAsArray()
+
+
+@gdaltest.enable_exceptions()
+@pytest.mark.parametrize(
+    "default,error",
+    [
+        ("32k", "Failed to parse"),
+        ("", "Failed to parse"),
+        ("256", "cannot be represented"),
+        ("NO_DATA", "NoData value is not set"),
+    ],
+)
+def test_vrt_pixelfn_reclassify_bad_default(tmp_vsimem, default, error):
+
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 2
+    ny = 3
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Byte" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping="1=2;3=4" default="{default}" />
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
+        </SimpleSource>
+      </VRTRasterBand>
+    </VRTDataset>"""
+
+    with pytest.raises(Exception, match=error):
+        gdal.Open(xml).ReadAsArray()
+
+
+@gdaltest.enable_exceptions()
+@pytest.mark.parametrize(
+    "mapping,error",
+    [
+        ("1=2;3", "expected '='"),
+        ("1=2;3=4g", "expected ';' or end"),
+        ("1=2;", "Interval must start with"),
+        ("1=3;3=256", "cannot be represented"),
+        ("(1,}=3;3=4,", "Interval must end with"),
+    ],
+)
+def test_vrt_pixelfn_reclassify_invalid_mapping(tmp_vsimem, mapping, error):
+
+    np = pytest.importorskip("numpy")
+    gdaltest.importorskip_gdal_array()
+
+    nx = 2
+    ny = 3
+
+    data = np.arange(nx * ny).reshape(ny, nx)
+
+    with gdal.GetDriverByName("GTiff").Create(tmp_vsimem / "src.tif", nx, ny, 1) as src:
+        src.WriteArray(data)
+
+    xml = f"""
+    <VRTDataset rasterXSize="{nx}" rasterYSize="{ny}">
+      <VRTRasterBand dataType="Byte" band="1" subclass="VRTDerivedRasterBand">
+        <PixelFunctionType>reclassify</PixelFunctionType>
+        <PixelFunctionArguments mapping="{mapping}" default="7" />
+        <SimpleSource>
+          <SourceFilename>{tmp_vsimem / "src.tif"}</SourceFilename>
+          <SourceBand>1</SourceBand>j
path: autotest/gdrivers/vrtderived.py
user: login: rouault
body: ```suggestion
          <SourceBand>1</SourceBand>
```
created_at: 2025-04-30T14:34:54Z
author_association: MEMBER

PR comments:
user: login: coveralls
created_at: 2025-04-30T13:29:47Z
body: 
[![Coverage Status](https://coveralls.io/builds/73452984/badge)](https://coveralls.io/builds/73452984)

coverage: 70.767% (+0.01%) from 70.757%
when pulling **4116deac7b13473bbd68d1886f7165a2247cf7e1 on dbaston:vrt-reclassify**
into **3c0d9011ef325b0d1ef3aa3b4ef66b089f258147 on OSGeo:master**.

author_association: COLLABORATOR
user: login: dbaston
created_at: 2025-04-30T14:43:34Z
body: > Isn't that a bit similar to the <LUT> option of <ComplexSource> ?

Similar, but the LUT is setting a series of points between which linear interpolation is performed. There's no interpolation here, it's just matching values to bins, like https://docs.qgis.org/3.40/en/docs/user_manual/processing_algs/qgis/rasteranalysis.html#reclassify-by-table
author_association: MEMBER
user: login: rouault
created_at: 2025-04-30T14:53:01Z
body: > Similar, but the LUT is setting a series of points between which linear interpolation is performed. There's no interpolation here, it's just matching values to bins, like https://docs.qgis.org/3.40/en/docs/user_manual/processing_algs/qgis/rasteranalysis.html#reclassify-by-table

got it. Could be interesting in the doc to cross-link both functionalities with the mention of their differences
author_association: MEMBER
user: login: dbaston
created_at: 2025-05-01T13:31:48Z
body: @rouault I'm mulling over a couple of tweaks to this, but I know the 3.11 deadline is upon us. When should I have any changes in by?
author_association: MEMBER
user: login: rouault
created_at: 2025-05-01T14:11:09Z
body: > When should I have any changes in by?

I'm intending to cut 3.11 RC1 next Monday
author_association: MEMBER