Add an avifApplyGainMap function. (#1682)

Applies a gain map to a base image based on the display's hdr capacity.

In internal.h only for now.
diff --git a/include/avif/avif.h b/include/avif/avif.h
index 7b1c795..4a4e249 100644
--- a/include/avif/avif.h
+++ b/include/avif/avif.h
@@ -515,6 +515,10 @@
     // 'clli' from ISO/IEC 23000-22:2019 (MIAF) 7.4.4.2.2. The SEI message semantics written above
     //  each entry were originally described in ISO/IEC 23008-2.
 
+    // Given the red, green, and blue colour primary intensities in the linear light domain for the
+    // location of a luma sample in a corresponding 4:4:4 representation, denoted as E_R, E_G, and E_B,
+    // the maximum component intensity is defined as E_Max = Max(E_R, Max(E_G, E_B)).
+
     // max_content_light_level, when not equal to 0, indicates an upper bound on the maximum light
     // level among all individual samples in a 4:4:4 representation of red, green, and blue colour
     // primary intensities (in the linear light domain) for the pictures of the CLVS, in units of
diff --git a/include/avif/internal.h b/include/avif/internal.h
index b7cd0d9..32ddb29 100644
--- a/include/avif/internal.h
+++ b/include/avif/internal.h
@@ -647,6 +647,32 @@
 
 AVIF_NODISCARD avifBool avifSequenceHeaderParse(avifSequenceHeader * header, const avifROData * sample, avifCodecType codecType);
 
+#if defined(AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP)
+// Performs tone mapping on a base image using the provided gain map.
+// The HDR capacity (also known as HDR headroom or ratio) is the ratio of HDR to
+// SDR white brightness of the display to tone map for, in linear space.
+// 'toneMappedImage' should have the 'format', 'depth', and 'isFloat' fields set to the desired values.
+// If non NULL, 'clli' will be filled with the light level information of the tone mapped image.
+// NOTE: only used in tests for now, might be added to the public API at some point.
+struct avifRGBImage;
+avifResult avifImageApplyGainMap(const avifImage * baseImage,
+                                 const avifGainMap * gainMap,
+                                 float hdrCapacity,
+                                 avifTransferCharacteristics outputTransferCharacteristics,
+                                 avifRGBImage * toneMappedImage,
+                                 avifContentLightLevelInformationBox * clli,
+                                 avifDiagnostics * diag);
+// Same as above but takes an avifRGBImage as input instead of avifImage.
+avifResult avifRGBImageApplyGainMap(const avifRGBImage * baseImage,
+                                    avifTransferCharacteristics transferCharacteristics,
+                                    const avifGainMap * gainMap,
+                                    float hdrCapacity,
+                                    avifTransferCharacteristics outputTransferCharacteristics,
+                                    avifRGBImage * toneMappedImage,
+                                    avifContentLightLevelInformationBox * clli,
+                                    avifDiagnostics * diag);
+#endif // AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP
+
 #define AVIF_INDEFINITE_DURATION64 UINT64_MAX
 #define AVIF_INDEFINITE_DURATION32 UINT32_MAX
 
diff --git a/src/gainmap.c b/src/gainmap.c
index fb9ee8b..6fdf78a 100644
--- a/src/gainmap.c
+++ b/src/gainmap.c
@@ -2,6 +2,10 @@
 // SPDX-License-Identifier: BSD-2-Clause
 
 #include "avif/internal.h"
+#include <assert.h>
+#include <float.h>
+#include <math.h>
+#include <string.h>
 
 #if defined(AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP)
 
@@ -49,4 +53,258 @@
     return AVIF_TRUE;
 }
 
+// ---------------------------------------------------------------------------
+
+// Returns a weight in [-1.0, 1.0] that represents how much the gain map should be applied.
+static float avifGetGainMapWeight(float hdrCapacity, const avifGainMapMetadataDouble * metadata)
+{
+    const float hdrCapacityMin = (float)metadata->hdrCapacityMin;
+    const float hdrCapacityMax = (float)metadata->hdrCapacityMax;
+    float w = 0.0f;
+    if (hdrCapacity > hdrCapacityMin) {
+        if (hdrCapacity < hdrCapacityMax) {
+            w = (logf(hdrCapacity) - logf(hdrCapacityMin)) / (logf(hdrCapacityMax) - logf(hdrCapacityMin));
+        } else {
+            w = 1.0f;
+        }
+    }
+
+    if (metadata->baseRenditionIsHDR) {
+        w -= 1.0f;
+    }
+
+    return w;
+}
+
+// Linear interpolation between 'a' and 'b' (returns 'a' if w == 0.0f, returns 'b' if w == 1.0f).
+static inline float lerp(float a, float b, float w)
+{
+    return (1.0f - w) * a + w * b;
+}
+
+#define SDR_WHITE_NITS 203.0f
+
+avifResult avifRGBImageApplyGainMap(const avifRGBImage * baseImage,
+                                    avifTransferCharacteristics transferCharacteristics,
+                                    const avifGainMap * gainMap,
+                                    float hdrCapacity,
+                                    avifTransferCharacteristics outputTransferCharacteristics,
+                                    avifRGBImage * toneMappedImage,
+                                    avifContentLightLevelInformationBox * clli,
+                                    avifDiagnostics * diag)
+{
+    avifDiagnosticsClearError(diag);
+
+    if (hdrCapacity < 1.0) {
+        avifDiagnosticsPrintf(diag, "hdrCapacity should be >= 1, got %f", hdrCapacity);
+        return AVIF_RESULT_INVALID_ARGUMENT;
+    }
+    if (baseImage == NULL || gainMap == NULL || toneMappedImage == NULL) {
+        avifDiagnosticsPrintf(diag, "NULL input image");
+        return AVIF_RESULT_INVALID_ARGUMENT;
+    }
+
+    avifGainMapMetadataDouble metadata;
+    if (!avifGainMapMetadataFractionsToDouble(&metadata, &gainMap->metadata)) {
+        avifDiagnosticsPrintf(diag, "Invalid gain map metadata, a denominator value is zero");
+        return AVIF_RESULT_INVALID_ARGUMENT;
+    }
+    if (metadata.hdrCapacityMin > metadata.hdrCapacityMax) {
+        avifDiagnosticsPrintf(diag,
+                              "Invalid gain map metadata, hdrCapacityMin should be <= hdrCapacityMax, got min %f and max %f",
+                              metadata.hdrCapacityMin,
+                              metadata.hdrCapacityMax);
+        return AVIF_RESULT_INVALID_ARGUMENT;
+    }
+    for (int i = 0; i < 3; ++i) {
+        if (metadata.gainMapGamma[i] <= 0) {
+            avifDiagnosticsPrintf(diag, "Invalid gain map metadata, gamma should be strictly positive");
+            return AVIF_RESULT_INVALID_ARGUMENT;
+        }
+    }
+
+    const uint32_t width = baseImage->width;
+    const uint32_t height = baseImage->height;
+    avifImage * rescaledGainMap = NULL;
+    avifRGBImage rgbGainMap;
+    // Basic zero-initialization for now, avifRGBImageSetDefaults() is called later on.
+    memset(&rgbGainMap, 0, sizeof(rgbGainMap));
+
+    avifResult res = AVIF_RESULT_OK;
+    toneMappedImage->width = width;
+    toneMappedImage->height = height;
+    AVIF_CHECKRES(avifRGBImageAllocatePixels(toneMappedImage));
+
+    // --- After this point, the function should exit with 'goto cleanup' to free allocated pixels.
+
+    const float weight = avifGetGainMapWeight(hdrCapacity, &metadata);
+
+    // Early exit if the gain map does not need to be applied and the pixel format is the same.
+    if (weight == 0.0f && outputTransferCharacteristics == transferCharacteristics && baseImage->format == toneMappedImage->format &&
+        baseImage->depth == toneMappedImage->depth && baseImage->isFloat == toneMappedImage->isFloat) {
+        assert(baseImage->rowBytes == toneMappedImage->rowBytes);
+        assert(baseImage->height == toneMappedImage->height);
+        // Copy the base image.
+        memcpy(toneMappedImage->pixels, baseImage->pixels, baseImage->rowBytes * baseImage->height);
+        goto cleanup;
+    }
+
+    avifRGBColorSpaceInfo baseRGBInfo;
+    avifRGBColorSpaceInfo toneMappedPixelRGBInfo;
+    if (!avifGetRGBColorSpaceInfo(baseImage, &baseRGBInfo) || !avifGetRGBColorSpaceInfo(toneMappedImage, &toneMappedPixelRGBInfo)) {
+        avifDiagnosticsPrintf(diag, "Unsupported RGB color space");
+        res = AVIF_RESULT_NOT_IMPLEMENTED;
+        goto cleanup;
+    }
+
+    const avifTransferFunction gammaToLinear = avifTransferCharacteristicsGetGammaToLinearFunction(transferCharacteristics);
+    const avifTransferFunction linearToGamma = avifTransferCharacteristicsGetLinearToGammaFunction(outputTransferCharacteristics);
+
+    // Early exit if the gain map does not need to be applied.
+    if (weight == 0.0f) {
+        // Just convert from one rgb format to another.
+        for (uint32_t j = 0; j < height; ++j) {
+            for (uint32_t i = 0; i < width; ++i) {
+                float basePixelRGBA[4];
+                avifGetRGBAPixel(baseImage, i, j, &baseRGBInfo, basePixelRGBA);
+                if (outputTransferCharacteristics != transferCharacteristics) {
+                    for (int c = 0; c < 3; ++c) {
+                        basePixelRGBA[c] = AVIF_CLAMP(linearToGamma(gammaToLinear(basePixelRGBA[c])), 0.0f, 1.0f);
+                    }
+                }
+                avifSetRGBAPixel(toneMappedImage, i, j, &toneMappedPixelRGBInfo, basePixelRGBA);
+            }
+        }
+        goto cleanup;
+    }
+
+    if (gainMap->image->width != width || gainMap->image->height != height) {
+        rescaledGainMap = avifImageCreateEmpty();
+        const avifCropRect rect = { 0, 0, gainMap->image->width, gainMap->image->height };
+        res = avifImageSetViewRect(rescaledGainMap, gainMap->image, &rect);
+        if (res != AVIF_RESULT_OK) {
+            goto cleanup;
+        }
+        res = avifImageScale(rescaledGainMap, width, height, diag);
+        if (res != AVIF_RESULT_OK) {
+            goto cleanup;
+        }
+    }
+    const avifImage * const gainMapImage = (rescaledGainMap != NULL) ? rescaledGainMap : gainMap->image;
+
+    avifRGBImageSetDefaults(&rgbGainMap, gainMapImage);
+    res = avifRGBImageAllocatePixels(&rgbGainMap);
+    if (res != AVIF_RESULT_OK) {
+        goto cleanup;
+    }
+    res = avifImageYUVToRGB(gainMapImage, &rgbGainMap);
+    if (res != AVIF_RESULT_OK) {
+        goto cleanup;
+    }
+
+    avifRGBColorSpaceInfo gainMapRGBInfo;
+    if (!avifGetRGBColorSpaceInfo(&rgbGainMap, &gainMapRGBInfo)) {
+        avifDiagnosticsPrintf(diag, "Unsupported RGB color space");
+        res = AVIF_RESULT_NOT_IMPLEMENTED;
+        goto cleanup;
+    }
+
+    float gainMapMinLog[3];
+    float gainMapMaxLog[3];
+    for (int i = 0; i < 3; ++i) {
+        gainMapMinLog[i] = (float)log(metadata.gainMapMin[i]);
+        gainMapMaxLog[i] = (float)log(metadata.gainMapMax[i]);
+    }
+
+    const double * offsetBase = gainMap->metadata.baseRenditionIsHDR ? metadata.offsetHdr : metadata.offsetSdr;
+    const double * offsetOther = gainMap->metadata.baseRenditionIsHDR ? metadata.offsetSdr : metadata.offsetHdr;
+
+    float rgbMaxLinear = 0; // Max tone mapped pixel value across R, G and B channels.
+    float rgbSumLinear = 0; // Sum of max(r, g, b) for mapped pixels.
+
+    for (uint32_t j = 0; j < height; ++j) {
+        for (uint32_t i = 0; i < width; ++i) {
+            float basePixelRGBA[4];
+            avifGetRGBAPixel(baseImage, i, j, &baseRGBInfo, basePixelRGBA);
+            float gainMapRGBA[4];
+            avifGetRGBAPixel(&rgbGainMap, i, j, &gainMapRGBInfo, gainMapRGBA);
+
+            // Apply gain map.
+            float toneMappedPixelRGBA[4];
+            float pixelRgbMaxLinear = 0.0f; //  = max(r, g, b) for this pixel
+            for (int c = 0; c < 3; ++c) {
+                const float baseLinear = gammaToLinear(basePixelRGBA[c]);
+                const float gainMapValue = gainMapRGBA[c];
+
+                // Undo gamma & affine transform; the result is in log space.
+                const float gainMapLog = lerp(gainMapMinLog[c], gainMapMaxLog[c], powf(gainMapValue, (float)metadata.gainMapGamma[c]));
+                const float toneMappedLinear = (baseLinear + (float)offsetBase[c]) * expf(gainMapLog * weight) - (float)offsetOther[c];
+
+                if (toneMappedLinear > rgbMaxLinear) {
+                    rgbMaxLinear = toneMappedLinear;
+                }
+                if (toneMappedLinear > pixelRgbMaxLinear) {
+                    pixelRgbMaxLinear = toneMappedLinear;
+                }
+
+                const float toneMappedGamma = linearToGamma(toneMappedLinear);
+                toneMappedPixelRGBA[c] = AVIF_CLAMP(toneMappedGamma, 0.0f, 1.0f);
+            }
+            toneMappedPixelRGBA[3] = basePixelRGBA[3]; // Alpha is unaffected by tone mapping.
+            rgbSumLinear += pixelRgbMaxLinear;
+            avifSetRGBAPixel(toneMappedImage, i, j, &toneMappedPixelRGBInfo, toneMappedPixelRGBA);
+        }
+    }
+    if (clli != NULL) {
+        // For exact CLLI value definitions, see ISO/IEC 23008-2 section D.3.35
+        // at https://standards.iso.org/ittf/PubliclyAvailableStandards/index.html
+
+        // Convert extended SDR (where 1.0 is SDR white) to nits.
+        clli->maxCLL = (uint16_t)AVIF_CLAMP(avifRoundf(rgbMaxLinear * SDR_WHITE_NITS), 0.0f, (float)UINT16_MAX);
+        const float rgbAverageLinear = rgbSumLinear / (width * height);
+        clli->maxPALL = (uint16_t)AVIF_CLAMP(avifRoundf(rgbAverageLinear * SDR_WHITE_NITS), 0.0f, (float)UINT16_MAX);
+    }
+
+cleanup:
+    avifRGBImageFreePixels(&rgbGainMap);
+    if (rescaledGainMap != NULL) {
+        avifImageDestroy(rescaledGainMap);
+    }
+
+    return res;
+}
+
+avifResult avifImageApplyGainMap(const avifImage * baseImage,
+                                 const avifGainMap * gainMap,
+                                 float hdrCapacity,
+                                 avifTransferCharacteristics outputTransferCharacteristics,
+                                 avifRGBImage * toneMappedImage,
+                                 avifContentLightLevelInformationBox * clli,
+                                 avifDiagnostics * diag)
+{
+    avifDiagnosticsClearError(diag);
+
+    avifRGBImage baseImageRgb;
+    avifRGBImageSetDefaults(&baseImageRgb, baseImage);
+    AVIF_CHECKRES(avifRGBImageAllocatePixels(&baseImageRgb));
+    avifResult res = avifImageYUVToRGB(baseImage, &baseImageRgb);
+    if (res != AVIF_RESULT_OK) {
+        goto cleanup;
+    }
+
+    res = avifRGBImageApplyGainMap(&baseImageRgb,
+                                   baseImage->transferCharacteristics,
+                                   gainMap,
+                                   hdrCapacity,
+                                   outputTransferCharacteristics,
+                                   toneMappedImage,
+                                   clli,
+                                   diag);
+
+cleanup:
+    avifRGBImageFreePixels(&baseImageRgb);
+
+    return res;
+}
+
 #endif // AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP
diff --git a/tests/data/README.md b/tests/data/README.md
index 290af0a..a8d3ead 100644
--- a/tests/data/README.md
+++ b/tests/data/README.md
@@ -242,55 +242,6 @@
 
 Source: Encoded from `paris_icc_exif_xmp.png` using `avifenc -s 10` at commit ed52c1b.
 
-### File [paris_exif_xmp_gainmap_littleendian.jpg](paris_exif_xmp_gainmap_littleendian.jpg)
-
-![](paris_exif_xmp_gainmap_littleendian.jpg)
-
-License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
-
-Source: Based on paris_exif_xmp_icc.jpg with ICC stripped out and a gain map added.
-Contains a MPF (Multi-Picture Format) segment with metadata pointing to a second image
-at offset 33487. The MPF metadata is in little endian order, as signaled by the four bytes
-'II*\0'.
-
-| address | marker      | length | data                                         |
-|--------:|-------------|-------:|----------------------------------------------|
-|       0 | 0xffd8 SOI  |        |                                              |
-|       2 | 0xffe0 APP0 |     16 | `JFIF.....,.,.`                              |
-|      20 | 0xffe1 APP1 |    838 | `Exif..II*......................`            |
-|    1156 | 0xffe1 APP1 |   2808 | `http://ns.adobe.com/xap/1.0/.<?x`           |
-|    3670 | 0xffe1 APP1 |    392 | `http://ns.adobe.com/xmp/extension/`         |
-|    4064 | 0xffe2 APP2 |     88 | `MPF..II*.....                   `           |
-|         |             |        | ...                                          |
-|   33487 | 0xffd8 SOI  |        |                                              |
-|   33489 | 0xffe0 APP0 |     16 | `JFIF.....,.,.`                              |
-|   33507 | 0xffe1 APP1 |    571 | `http://ns.adobe.com/xap/1.0/.<?x`           |
-|         |             |        | ...                                          |
-
-### File [paris_exif_xmp_gainmap_bigendian.jpg](paris_exif_xmp_gainmap_bigendian.jpg)
-
-![](paris_exif_xmp_gainmap_bigendian.jpg)
-
-License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
-
-Source: Same as paris_exif_xmp_gainmap_littleendian.jpg but manually edited with
-a hex editor to make the MPF metadata big endian, as signaled by the four bytes
-'MM\0*'.
-
-| address | marker      | length | data                                         |
-|--------:|-------------|-------:|----------------------------------------------|
-|       0 | 0xffd8 SOI  |        |                                              |
-|       2 | 0xffe0 APP0 |     16 | `JFIF.....,.,.`                              |
-|      20 | 0xffe1 APP1 |    838 | `Exif..II*......................`            |
-|    1156 | 0xffe1 APP1 |   2808 | `http://ns.adobe.com/xap/1.0/.<?x`           |
-|    3670 | 0xffe1 APP1 |    392 | `http://ns.adobe.com/xmp/extension/`         |
-|    4064 | 0xffe2 APP2 |     88 | `MPF..MM.*....                   `           |
-|         |             |        | ...                                          |
-|   33487 | 0xffd8 SOI  |        |                                              |
-|   33489 | 0xffe0 APP0 |     16 | `JFIF.....,.,.`                              |
-|   33507 | 0xffe1 APP1 |    571 | `http://ns.adobe.com/xap/1.0/.<?x`           |
-|         |             |        | ...                                          |
-
 ### File [ffffcc-gamma1.6.png](ffffcc-gamma1.6.png)
 
 ![](ffffcc-gamma1.6.png)
@@ -427,8 +378,62 @@
 [`cavif-rs`](https://github.com/kornelski/cavif-rs) with the
 [alpha `ispe` fix](https://github.com/kornelski/avif-serialize/pull/4) removed.
 
+## Gain Maps
+
+### File [paris_exif_xmp_gainmap_littleendian.jpg](paris_exif_xmp_gainmap_littleendian.jpg)
+
+![](paris_exif_xmp_gainmap_littleendian.jpg)
+
+License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
+
+Source: Based on paris_exif_xmp_icc.jpg with ICC stripped out and a gain map added.
+Contains a MPF (Multi-Picture Format) segment with metadata pointing to a second image
+at offset 33487. The MPF metadata is in little endian order, as signaled by the four bytes
+'II*\0'.
+
+| address | marker      | length | data                                         |
+|--------:|-------------|-------:|----------------------------------------------|
+|       0 | 0xffd8 SOI  |        |                                              |
+|       2 | 0xffe0 APP0 |     16 | `JFIF.....,.,.`                              |
+|      20 | 0xffe1 APP1 |    838 | `Exif..II*......................`            |
+|    1156 | 0xffe1 APP1 |   2808 | `http://ns.adobe.com/xap/1.0/.<?x`           |
+|    3670 | 0xffe1 APP1 |    392 | `http://ns.adobe.com/xmp/extension/`         |
+|    4064 | 0xffe2 APP2 |     88 | `MPF..II*.....                   `           |
+|         |             |        | ...                                          |
+|   33487 | 0xffd8 SOI  |        |                                              |
+|   33489 | 0xffe0 APP0 |     16 | `JFIF.....,.,.`                              |
+|   33507 | 0xffe1 APP1 |    571 | `http://ns.adobe.com/xap/1.0/.<?x`           |
+|         |             |        | ...                                          |
+
+### File [paris_exif_xmp_gainmap_bigendian.jpg](paris_exif_xmp_gainmap_bigendian.jpg)
+
+![](paris_exif_xmp_gainmap_bigendian.jpg)
+
+License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
+
+Source: Same as paris_exif_xmp_gainmap_littleendian.jpg but manually edited with
+a hex editor to make the MPF metadata big endian, as signaled by the four bytes
+'MM\0*'.
+
+| address | marker      | length | data                                         |
+|--------:|-------------|-------:|----------------------------------------------|
+|       0 | 0xffd8 SOI  |        |                                              |
+|       2 | 0xffe0 APP0 |     16 | `JFIF.....,.,.`                              |
+|      20 | 0xffe1 APP1 |    838 | `Exif..II*......................`            |
+|    1156 | 0xffe1 APP1 |   2808 | `http://ns.adobe.com/xap/1.0/.<?x`           |
+|    3670 | 0xffe1 APP1 |    392 | `http://ns.adobe.com/xmp/extension/`         |
+|    4064 | 0xffe2 APP2 |     88 | `MPF..MM.*....                   `           |
+|         |             |        | ...                                          |
+|   33487 | 0xffd8 SOI  |        |                                              |
+|   33489 | 0xffe0 APP0 |     16 | `JFIF.....,.,.`                              |
+|   33507 | 0xffe1 APP1 |    571 | `http://ns.adobe.com/xap/1.0/.<?x`           |
+|         |             |        | ...                                          |
+
+
 ### File [color_grid_gainmap_different_grid.avif](color_grid_gainmap_different_grid.avif)
 
+![](color_grid_gainmap_different_grid.avif)
+
 License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
 
 Source: generated with a modified libavif at https://github.com/maryla-uc/libavif/tree/weirdgainmaps
@@ -437,6 +442,8 @@
 
 ### File [color_nogrid_alpha_nogrid_gainmap_grid.avif](color_nogrid_alpha_nogrid_gainmap_grid.avif)
 
+![](color_nogrid_alpha_nogrid_gainmap_grid.avif)
+
 License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
 
 Source: generated with a modified libavif at https://github.com/maryla-uc/libavif/tree/weirdgainmaps
@@ -445,12 +452,65 @@
 
 ### File [color_grid_alpha_grid_gainmap_nogrid.avif](color_grid_alpha_grid_gainmap_nogrid.avif)
 
+![](color_grid_alpha_grid_gainmap_nogrid.avif)
+
 License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
 
 Source: generated with a modified libavif at https://github.com/maryla-uc/libavif/tree/weirdgainmaps
 
 Contains a 4x3 color grid, a 4x3 alpha grid, and a single gain map image.
 
+### File [seine_hdr_srgb.avif](seine_hdr_srgb.avif)
+
+![](seine_hdr_srgb.avif)
+
+License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
+
+Source : created from a personal photo, convertd to HDR using Photoshop.
+
+HDR image using the PQ transfer curve. Contains a gain map in
+[Adobe's format](https://helpx.adobe.com/camera-raw/using/gain-map.html) that is not recogniazed by
+libavif and ignored by the tests.
+
+### File [seine_sdr_gainmap_srgb.avif](seine_sdr_gainmap_srgb.avif)
+
+![](seine_sdr_gainmap_srgb.avif)
+
+License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
+
+Source : created from a personal photo, convertd to HDR using Photoshop, then saved as JPEG+gainmap,
+and converted to avif with avifenc.
+
+SDR image with a gain map to allow tone mapping to HDR.
+
+### File [seine_sdr_gainmap_big_srgb.avif](seine_sdr_gainmap_big_srgb.avif)
+
+![](seine_sdr_gainmap_big_srgb.avif)
+
+Source : modified version of `seine_sdr_gainmap_srgb.avif` with an upscaled gain map, generated using libavif's API.
+
+SDR image with a gain map to allow tone mapping to HDR. The gain map's width and height are doubled compared to the base image. This is an atypical image just for testing. Typically, the gain map would be either the same size or smaller as the base image.
+
+### File [seine_hdr_gainmap_srgb.avif](seine_hdr_gainmap_srgb.avif)
+
+![](seine_hdr_gainmap_srgb.avif)
+
+License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
+
+Source : created from `seine_hdr_srgb.avif` (for the base image) and `seine_sdr_gainmap_srgb.avif` (for the gain map) with libavif's API.
+
+HDR image with a gain map to allow tone mapping to SDR.
+
+### File [seine_hdr_gainmap_small_srgb.avif](seine_hdr_gainmap_small_srgb.avif)
+
+![](seine_hdr_gainmap_small_srgb.avif)
+
+License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE)
+
+Source : modified version of `seine_hdr_gainmap_srgb.avif` with a downscaled gain map, generated using libavif's API.
+
+SDR image with a gain map to allow tone mapping to HDR. The gain map's width and height are halved compared to the base image.
+
 ## Animated Images
 
 ### File [colors-animated-8bpc.avif](colors-animated-8bpc.avif)
diff --git a/tests/data/seine_hdr_gainmap_small_srgb.avif b/tests/data/seine_hdr_gainmap_small_srgb.avif
new file mode 100644
index 0000000..001e232
--- /dev/null
+++ b/tests/data/seine_hdr_gainmap_small_srgb.avif
Binary files differ
diff --git a/tests/data/seine_hdr_gainmap_srgb.avif b/tests/data/seine_hdr_gainmap_srgb.avif
new file mode 100644
index 0000000..47445dd
--- /dev/null
+++ b/tests/data/seine_hdr_gainmap_srgb.avif
Binary files differ
diff --git a/tests/data/seine_hdr_srgb.avif b/tests/data/seine_hdr_srgb.avif
new file mode 100644
index 0000000..55c54b0
--- /dev/null
+++ b/tests/data/seine_hdr_srgb.avif
Binary files differ
diff --git a/tests/data/seine_sdr_gainmap_big_srgb.avif b/tests/data/seine_sdr_gainmap_big_srgb.avif
new file mode 100644
index 0000000..9ae66e4
--- /dev/null
+++ b/tests/data/seine_sdr_gainmap_big_srgb.avif
Binary files differ
diff --git a/tests/data/seine_sdr_gainmap_srgb.avif b/tests/data/seine_sdr_gainmap_srgb.avif
new file mode 100644
index 0000000..43498d7
--- /dev/null
+++ b/tests/data/seine_sdr_gainmap_srgb.avif
Binary files differ
diff --git a/tests/gtest/avifgainmaptest.cc b/tests/gtest/avifgainmaptest.cc
index 9a328db..1f01835 100644
--- a/tests/gtest/avifgainmaptest.cc
+++ b/tests/gtest/avifgainmaptest.cc
@@ -13,6 +13,8 @@
 namespace libavif {
 namespace {
 
+using ::testing::Values;
+
 // Used to pass the data folder path to the GoogleTest suites.
 const char* data_path = nullptr;
 
@@ -736,6 +738,193 @@
       avifGainMapMetadataFractionsToDouble(&metadata_double, &metadata));
 }
 
+class ToneMapTest
+    : public testing::TestWithParam<std::tuple<
+          /*source=*/std::string, /*hdr_capacity=*/float,
+          /*out_depth=*/int,
+          /*out_transfer=*/avifTransferCharacteristics,
+          /*out_rgb_format=*/avifRGBFormat,
+          /*reference=*/std::string, /*min_psnr=*/float, /*max_psnr=*/float>> {
+};
+
+TEST_P(ToneMapTest, ToneMapImage) {
+  const std::string source = std::get<0>(GetParam());
+  const float hdr_capacity = std::get<1>(GetParam());
+  // out_depth and out_transfer_characteristics should match the reference image
+  // when ther eis one, so that GetPsnr works.
+  const int out_depth = std::get<2>(GetParam());
+  const avifTransferCharacteristics out_transfer_characteristics =
+      std::get<3>(GetParam());
+  const avifRGBFormat out_rgb_format = std::get<4>(GetParam());
+  const std::string reference = std::get<5>(GetParam());
+  const float min_psnr = std::get<6>(GetParam());
+  const float max_psnr = std::get<7>(GetParam());
+
+  testutil::AvifImagePtr reference_image = {nullptr, nullptr};
+  if (!source.empty()) {
+    reference_image = testutil::DecodFile(std::string(data_path) + reference);
+  }
+
+  // Load the source image (that should contain a gain map).
+  const std::string path = std::string(data_path) + source;
+  testutil::AvifImagePtr image(avifImageCreateEmpty(), avifImageDestroy);
+  ASSERT_NE(image, nullptr);
+  testutil::AvifDecoderPtr decoder(avifDecoderCreate(), avifDecoderDestroy);
+  ASSERT_NE(decoder, nullptr);
+  decoder->enableDecodingGainMap = true;
+  decoder->enableParsingGainMapMetadata = true;
+  avifResult result =
+      avifDecoderReadFile(decoder.get(), image.get(), path.c_str());
+  ASSERT_EQ(result, AVIF_RESULT_OK)
+      << avifResultToString(result) << " " << decoder->diag.error;
+
+  ASSERT_NE(image->gainMap.image, nullptr);
+
+  testutil::AvifRgbImage tone_mapped_rgb(image.get(), out_depth,
+                                         out_rgb_format);
+  testutil::AvifImagePtr tone_mapped(
+      avifImageCreate(tone_mapped_rgb.width, tone_mapped_rgb.height,
+                      tone_mapped_rgb.depth, AVIF_PIXEL_FORMAT_YUV444),
+      avifImageDestroy);
+  tone_mapped->transferCharacteristics = out_transfer_characteristics;
+  tone_mapped->colorPrimaries = image->colorPrimaries;
+
+  avifDiagnostics diag;
+  result = avifImageApplyGainMap(image.get(), &image->gainMap, hdr_capacity,
+                                 tone_mapped->transferCharacteristics,
+                                 &tone_mapped_rgb, &tone_mapped->clli, &diag);
+  ASSERT_EQ(result, AVIF_RESULT_OK)
+      << avifResultToString(result) << " " << decoder->diag.error;
+  ASSERT_EQ(avifImageRGBToYUV(tone_mapped.get(), &tone_mapped_rgb),
+            AVIF_RESULT_OK);
+  if (reference_image != nullptr) {
+    const double psnr = testutil::GetPsnr(*reference_image, *tone_mapped);
+    EXPECT_GE(psnr, min_psnr);
+    EXPECT_LE(psnr, max_psnr);
+  }
+
+  // Uncomment the following to save the encoded image as an AVIF file.
+  //   testutil::AvifEncoderPtr encoder(avifEncoderCreate(),
+  //     avifEncoderDestroy);
+  //   ASSERT_NE(encoder, nullptr);
+  //   encoder->speed = 9;
+  //   encoder->quality = 90;
+  //   encoder->qualityGainMap = 90;
+  //   testutil::AvifRwData encoded;
+  //   ASSERT_EQ(avifEncoderWrite(encoder.get(), tone_mapped.get(), &encoded),
+  //             AVIF_RESULT_OK);
+  //   std::ofstream(
+  //       "/tmp/tone_mapped_" + std::to_string(hdr_capacity) + "_" + source,
+  //       std::ios::binary)
+  //       .write(reinterpret_cast<char*>(encoded.data), encoded.size);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    All, ToneMapTest,
+    Values(
+        // ------ SDR BASE IMAGE ------
+
+        // hdr_capacity=1, the image should stay SDR (base image untouched).
+        // A small loss is expected due to YUV/RGB conversion.
+        std::make_tuple(
+            /*source=*/"seine_sdr_gainmap_srgb.avif", /*hdr_capacity=*/1.0f,
+            /*out_depth=*/8,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_SRGB,
+            /*out_rgb_format=*/AVIF_RGB_FORMAT_RGB,
+            /*reference=*/"seine_sdr_gainmap_srgb.avif", /*min_psnr=*/60.0f,
+            /*max_psnr=*/80.0f),
+
+        // Same as above, outputting to RGBA.
+        std::make_tuple(
+            /*source=*/"seine_sdr_gainmap_srgb.avif", /*hdr_capacity=*/1.0f,
+            /*out_depth=*/8,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_SRGB,
+            /*out_rgb_format=*/AVIF_RGB_FORMAT_RGBA,
+            /*reference=*/"seine_sdr_gainmap_srgb.avif", /*min_psnr=*/60.0f,
+            /*max_psnr=*/80.0f),
+
+        // Same as above, outputting to a different transfer characteristic.
+        // As a result we expect a low PSNR (since the PSNR function is not
+        // aware of the transfer curve difference).
+        std::make_tuple(
+            /*source=*/"seine_sdr_gainmap_srgb.avif", /*hdr_capacity=*/1.0f,
+            /*out_depth=*/8,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_LOG100,
+            /*out_rgb_format=*/AVIF_RGB_FORMAT_RGBA,
+            /*reference=*/"seine_sdr_gainmap_srgb.avif", /*min_psnr=*/20.0f,
+            /*max_psnr=*/30.0f),
+
+        // hdr_capacity=3, the gain map should be fully applied.
+        std::make_tuple(
+            /*source=*/"seine_sdr_gainmap_srgb.avif", /*hdr_capacity=*/3.0f,
+            /*out_depth=*/10,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_SMPTE2084,
+            /*out_rgb_format=*/AVIF_RGB_FORMAT_RGB,
+            /*reference=*/"seine_hdr_srgb.avif", /*min_psnr=*/40.0f,
+            /*max_psnr=*/60.0f),
+
+        // hdr_capacity=3, the gain map should be fully applied.
+        // Version with a gain map that is larger than the base image (needs
+        // rescaling).
+        std::make_tuple(
+            /*source=*/"seine_sdr_gainmap_big_srgb.avif", /*hdr_capacity=*/3.0f,
+            /*out_depth=*/10,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_SMPTE2084,
+            /*out_rgb_format=*/AVIF_RGB_FORMAT_RGB,
+            /*reference=*/"seine_hdr_srgb.avif", /*min_psnr=*/40.0f,
+            /*max_psnr=*/60.0f),
+
+        // hdr_capacity=1.5 No reference image.
+        std::make_tuple(
+            /*source=*/"seine_sdr_gainmap_srgb.avif", /*hdr_capacity=*/1.5f,
+            /*out_depth=*/10,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_SMPTE2084,
+            /*out_rgb_format=*/AVIF_RGB_FORMAT_RGB,
+            /*reference=*/"", /*min_psnr=*/0.0f,
+            /*max_psnr=*/0.0f),
+
+        // ------ HDR BASE IMAGE ------
+
+        // hdr_capacity=1, the gain map should be fully applied.
+        std::make_tuple(
+            /*source=*/"seine_hdr_gainmap_srgb.avif", /*hdr_capacity=*/1.0f,
+            /*out_depth=*/8,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_SRGB,
+            /*out_rgb_format=*/AVIF_RGB_FORMAT_RGB,
+            /*reference=*/"seine_sdr_gainmap_srgb.avif", /*min_psnr=*/38.0f,
+            /*max_psnr=*/60.0f),
+
+        // hdr_capacity=1, the gain map should be fully applied.
+        // Version with a gain map that is smaller than the base image (needs
+        // rescaling). The PSNR is a bit lower than above due to quality loss on
+        // the gain map.
+        std::make_tuple(
+            /*source=*/"seine_hdr_gainmap_small_srgb.avif",
+            /*hdr_capacity=*/1.0f,
+            /*out_depth=*/8,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_SRGB,
+            AVIF_RGB_FORMAT_RGB,
+            /*reference=*/"seine_sdr_gainmap_srgb.avif", /*min_psnr=*/36.0f,
+            /*max_psnr=*/60.0f),
+
+        // hdr_capacity=3, the image should stay HDR (base image untouched).
+        // A small loss is expected due to YUV/RGB conversion.
+        std::make_tuple(
+            /*source=*/"seine_hdr_gainmap_srgb.avif", /*hdr_capacity=*/3.0f,
+            /*out_depth=*/10,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_SMPTE2084,
+            /*out_rgb_format=*/AVIF_RGB_FORMAT_RGB,
+            /*reference=*/"seine_hdr_gainmap_srgb.avif", /*min_psnr=*/60.0f,
+            /*max_psnr=*/80.0f),
+
+        // hdr_capacity=1.5 No reference image.
+        std::make_tuple(
+            /*source=*/"seine_hdr_gainmap_srgb.avif", /*hdr_capacity=*/1.5f,
+            /*out_depth=*/10,
+            /*out_transfer=*/AVIF_TRANSFER_CHARACTERISTICS_SMPTE2084,
+            /*out_rgb_format=*/AVIF_RGB_FORMAT_RGB,
+            /*reference=*/"", /*min_psnr=*/0.0f, /*max_psnr=*/0.0f)));
+
 }  // namespace
 }  // namespace libavif
 
diff --git a/tests/gtest/aviftest_helpers.cc b/tests/gtest/aviftest_helpers.cc
index aa476d5..1031e1e 100644
--- a/tests/gtest/aviftest_helpers.cc
+++ b/tests/gtest/aviftest_helpers.cc
@@ -449,6 +449,17 @@
   return decoded;
 }
 
+AvifImagePtr DecodFile(const std::string& path) {
+  testutil::AvifImagePtr decoded(avifImageCreateEmpty(), avifImageDestroy);
+  testutil::AvifDecoderPtr decoder(avifDecoderCreate(), avifDecoderDestroy);
+  if (!decoded || !decoder ||
+      (avifDecoderReadFile(decoder.get(), decoded.get(), path.c_str()) !=
+       AVIF_RESULT_OK)) {
+    return {nullptr, nullptr};
+  }
+  return decoded;
+}
+
 bool Av1EncoderAvailable() {
   const char* encoding_codec =
       avifCodecName(AVIF_CODEC_CHOICE_AUTO, AVIF_CODEC_FLAG_CAN_ENCODE);
diff --git a/tests/gtest/aviftest_helpers.h b/tests/gtest/aviftest_helpers.h
index 252554d..dbaabc6 100644
--- a/tests/gtest/aviftest_helpers.h
+++ b/tests/gtest/aviftest_helpers.h
@@ -7,6 +7,7 @@
 #include <array>
 #include <limits>
 #include <memory>
+#include <string>
 #include <vector>
 
 #include "avif/avif.h"
@@ -133,6 +134,10 @@
 // Returns nullptr in case of error.
 AvifImagePtr Decode(const uint8_t* bytes, size_t num_bytes);
 
+// Decodes the file to an image with default parameters.
+// Returns nullptr in case of error.
+AvifImagePtr DecodFile(const std::string& path);
+
 // Returns true if an AV1 encoder is available.
 bool Av1EncoderAvailable();