Add support for creating gain maps for image with different primaries. (#1873)

diff --git a/apps/avifgainmaputil/swapbase_command.cc b/apps/avifgainmaputil/swapbase_command.cc
index 2c2e550..e23f3e3 100644
--- a/apps/avifgainmaputil/swapbase_command.cc
+++ b/apps/avifgainmaputil/swapbase_command.cc
@@ -58,6 +58,7 @@
 
   avifDiagnostics diag;
   result = avifImageApplyGainMap(&image, image.gainMap, headroom,
+                                 swapped->colorPrimaries,
                                  swapped->transferCharacteristics, &swapped_rgb,
                                  (compute_clli ? &clli : nullptr), &diag);
   if (result != AVIF_RESULT_OK) {
diff --git a/apps/avifgainmaputil/tonemap_command.cc b/apps/avifgainmaputil/tonemap_command.cc
index 147ba68..7c05311 100644
--- a/apps/avifgainmaputil/tonemap_command.cc
+++ b/apps/avifgainmaputil/tonemap_command.cc
@@ -191,10 +191,10 @@
   avifRGBImage tone_mapped_rgb;
   avifRGBImageSetDefaults(&tone_mapped_rgb, tone_mapped.get());
   avifDiagnostics diag;
-  result =
-      avifImageApplyGainMap(decoder->image, image->gainMap, arg_headroom_,
-                            cicp.transfer_characteristics, &tone_mapped_rgb,
-                            clli_set ? nullptr : &clli_box, &diag);
+  result = avifImageApplyGainMap(
+      decoder->image, image->gainMap, arg_headroom_, cicp.color_primaries,
+      cicp.transfer_characteristics, &tone_mapped_rgb,
+      clli_set ? nullptr : &clli_box, &diag);
   if (result != AVIF_RESULT_OK) {
     std::cout << "Failed to tone map image: " << avifResultToString(result)
               << " (" << diag.error << ")\n";
diff --git a/include/avif/avif.h b/include/avif/avif.h
index ff9d1cf..a6a103b 100644
--- a/include/avif/avif.h
+++ b/include/avif/avif.h
@@ -632,7 +632,6 @@
     // True if tone mapping should be performed in the color space of the
     // base image. If false, the color space of the alternate image should
     // be used.
-    // TODO(maryla): implement.
     avifBool useBaseColorSpace;
 } avifGainMapMetadata;
 
@@ -1551,15 +1550,18 @@
 AVIF_API avifResult avifImageApplyGainMap(const avifImage * baseImage,
                                           const avifGainMap * gainMap,
                                           float hdrHeadroom,
+                                          avifColorPrimaries outputColorPrimaries,
                                           avifTransferCharacteristics outputTransferCharacteristics,
                                           avifRGBImage * toneMappedImage,
                                           avifContentLightLevelInformationBox * clli,
                                           avifDiagnostics * diag);
 // Same as above but takes an avifRGBImage as input instead of avifImage.
 AVIF_API avifResult avifRGBImageApplyGainMap(const avifRGBImage * baseImage,
-                                             avifTransferCharacteristics transferCharacteristics,
+                                             avifColorPrimaries baseColorPrimaries,
+                                             avifTransferCharacteristics baseTransferCharacteristics,
                                              const avifGainMap * gainMap,
                                              float hdrHeadroom,
+                                             avifColorPrimaries outputColorPrimaries,
                                              avifTransferCharacteristics outputTransferCharacteristics,
                                              avifRGBImage * toneMappedImage,
                                              avifContentLightLevelInformationBox * clli,
@@ -1572,10 +1574,11 @@
 // height, depth and yuvFormat fields set to the desired output values for the
 // gain map. All of these fields may differ from the source images.
 AVIF_API avifResult avifRGBImageComputeGainMap(const avifRGBImage * baseRgbImage,
+                                               avifColorPrimaries baseColorPrimaries,
                                                avifTransferCharacteristics baseTransferCharacteristics,
                                                const avifRGBImage * altRgbImage,
+                                               avifColorPrimaries altColorPrimaries,
                                                avifTransferCharacteristics altTransferCharacteristics,
-                                               avifColorPrimaries colorPrimaries,
                                                avifGainMap * gainMap,
                                                avifDiagnostics * diag);
 // Convenience function. Same as above but takes avifImage images as input
diff --git a/src/avif.c b/src/avif.c
index b254819..9ca9295 100644
--- a/src/avif.c
+++ b/src/avif.c
@@ -1161,6 +1161,7 @@
     gainMap->altTransferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_UNSPECIFIED;
     gainMap->altMatrixCoefficients = AVIF_MATRIX_COEFFICIENTS_UNSPECIFIED;
     gainMap->altYUVRange = AVIF_RANGE_FULL;
+    gainMap->metadata.useBaseColorSpace = AVIF_TRUE;
     return gainMap;
 }
 
diff --git a/src/gainmap.c b/src/gainmap.c
index e1c84ba..f61cdf0 100644
--- a/src/gainmap.c
+++ b/src/gainmap.c
@@ -5,7 +5,6 @@
 #include <assert.h>
 #include <float.h>
 #include <math.h>
-#include <stdio.h>
 #include <string.h>
 
 #if defined(AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP)
@@ -98,9 +97,11 @@
 #define SDR_WHITE_NITS 203.0f
 
 avifResult avifRGBImageApplyGainMap(const avifRGBImage * baseImage,
-                                    avifTransferCharacteristics transferCharacteristics,
+                                    avifColorPrimaries baseColorPrimaries,
+                                    avifTransferCharacteristics baseTransferCharacteristics,
                                     const avifGainMap * gainMap,
                                     float hdrHeadroom,
+                                    avifColorPrimaries outputColorPrimaries,
                                     avifTransferCharacteristics outputTransferCharacteristics,
                                     avifRGBImage * toneMappedImage,
                                     avifContentLightLevelInformationBox * clli,
@@ -131,6 +132,14 @@
 
     const uint32_t width = baseImage->width;
     const uint32_t height = baseImage->height;
+
+    const avifBool useBaseColorSpace = gainMap->metadata.useBaseColorSpace;
+    const avifColorPrimaries gainMapMathPrimaries =
+        (useBaseColorSpace || (gainMap->altColorPrimaries == AVIF_COLOR_PRIMARIES_UNSPECIFIED)) ? baseColorPrimaries
+                                                                                                : gainMap->altColorPrimaries;
+    const avifBool needsInputColorConversion = (baseColorPrimaries != gainMapMathPrimaries);
+    const avifBool needsOutputColorConversion = (gainMapMathPrimaries != outputColorPrimaries);
+
     avifImage * rescaledGainMap = NULL;
     avifRGBImage rgbGainMap;
     // Basic zero-initialization for now, avifRGBImageSetDefaults() is called later on.
@@ -146,7 +155,8 @@
     const float weight = avifGetGainMapWeight(hdrHeadroom, &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 &&
+    if (weight == 0.0f && outputTransferCharacteristics == baseTransferCharacteristics &&
+        outputColorPrimaries == baseColorPrimaries && baseImage->format == toneMappedImage->format &&
         baseImage->depth == toneMappedImage->depth && baseImage->isFloat == toneMappedImage->isFloat) {
         assert(baseImage->rowBytes == toneMappedImage->rowBytes);
         assert(baseImage->height == toneMappedImage->height);
@@ -163,19 +173,32 @@
         goto cleanup;
     }
 
-    const avifTransferFunction gammaToLinear = avifTransferCharacteristicsGetGammaToLinearFunction(transferCharacteristics);
+    const avifTransferFunction gammaToLinear = avifTransferCharacteristicsGetGammaToLinearFunction(baseTransferCharacteristics);
     const avifTransferFunction linearToGamma = avifTransferCharacteristicsGetLinearToGammaFunction(outputTransferCharacteristics);
 
     // Early exit if the gain map does not need to be applied.
     if (weight == 0.0f) {
+        const avifBool primariesDiffer = (baseColorPrimaries != outputColorPrimaries);
+        double conversionCoeffs[3][3];
+        if (primariesDiffer && !avifColorPrimariesComputeRGBToRGBMatrix(baseColorPrimaries, outputColorPrimaries, conversionCoeffs)) {
+            avifDiagnosticsPrintf(diag, "Unsupported RGB color space conversion");
+            res = AVIF_RESULT_NOT_IMPLEMENTED;
+            goto cleanup;
+        }
         // 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) {
+                if (outputTransferCharacteristics != baseTransferCharacteristics || primariesDiffer) {
                     for (int c = 0; c < 3; ++c) {
-                        basePixelRGBA[c] = AVIF_CLAMP(linearToGamma(gammaToLinear(basePixelRGBA[c])), 0.0f, 1.0f);
+                        basePixelRGBA[c] = gammaToLinear(basePixelRGBA[c]);
+                    }
+                    if (primariesDiffer) {
+                        avifLinearRGBConvertColorSpace(basePixelRGBA, conversionCoeffs);
+                    }
+                    for (int c = 0; c < 3; ++c) {
+                        basePixelRGBA[c] = AVIF_CLAMP(linearToGamma(basePixelRGBA[c]), 0.0f, 1.0f);
                     }
                 }
                 avifSetRGBAPixel(toneMappedImage, i, j, &toneMappedPixelRGBInfo, basePixelRGBA);
@@ -184,6 +207,21 @@
         goto cleanup;
     }
 
+    double inputConversionCoeffs[3][3];
+    double outputConversionCoeffs[3][3];
+    if (needsInputColorConversion &&
+        !avifColorPrimariesComputeRGBToRGBMatrix(baseColorPrimaries, gainMapMathPrimaries, inputConversionCoeffs)) {
+        avifDiagnosticsPrintf(diag, "Unsupported RGB color space conversion");
+        res = AVIF_RESULT_NOT_IMPLEMENTED;
+        goto cleanup;
+    }
+    if (needsOutputColorConversion &&
+        !avifColorPrimariesComputeRGBToRGBMatrix(gainMapMathPrimaries, outputColorPrimaries, outputConversionCoeffs)) {
+        avifDiagnosticsPrintf(diag, "Unsupported RGB color space conversion");
+        res = AVIF_RESULT_NOT_IMPLEMENTED;
+        goto cleanup;
+    }
+
     if (gainMap->image->width != width || gainMap->image->height != height) {
         rescaledGainMap = avifImageCreateEmpty();
         const avifCropRect rect = { 0, 0, gainMap->image->width, gainMap->image->height };
@@ -220,7 +258,6 @@
     const float gammaInv[3] = { 1.0f / (float)metadata.gainMapGamma[0],
                                 1.0f / (float)metadata.gainMapGamma[1],
                                 1.0f / (float)metadata.gainMapGamma[2] };
-
     for (uint32_t j = 0; j < height; ++j) {
         for (uint32_t i = 0; i < width; ++i) {
             float basePixelRGBA[4];
@@ -231,8 +268,18 @@
             // 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]);
+                basePixelRGBA[c] = gammaToLinear(basePixelRGBA[c]);
+            }
+
+            if (needsInputColorConversion) {
+                // Convert basePixelRGBA to gainMapMathPrimaries.
+                avifLinearRGBConvertColorSpace(basePixelRGBA, inputConversionCoeffs);
+            }
+
+            for (int c = 0; c < 3; ++c) {
+                const float baseLinear = basePixelRGBA[c];
                 const float gainMapValue = gainMapRGBA[c];
 
                 // Undo gamma & affine transform; the result is in log2 space.
@@ -248,9 +295,18 @@
                     pixelRgbMaxLinear = toneMappedLinear;
                 }
 
-                const float toneMappedGamma = linearToGamma(toneMappedLinear);
-                toneMappedPixelRGBA[c] = AVIF_CLAMP(toneMappedGamma, 0.0f, 1.0f);
+                toneMappedPixelRGBA[c] = toneMappedLinear;
             }
+
+            if (needsOutputColorConversion) {
+                // Convert toneMappedPixelRGBA to outputColorPrimaries.
+                avifLinearRGBConvertColorSpace(toneMappedPixelRGBA, outputConversionCoeffs);
+            }
+
+            for (int c = 0; c < 3; ++c) {
+                toneMappedPixelRGBA[c] = AVIF_CLAMP(linearToGamma(toneMappedPixelRGBA[c]), 0.0f, 1.0f);
+            }
+
             toneMappedPixelRGBA[3] = basePixelRGBA[3]; // Alpha is unaffected by tone mapping.
             rgbSumLinear += pixelRgbMaxLinear;
             avifSetRGBAPixel(toneMappedImage, i, j, &toneMappedPixelRGBInfo, toneMappedPixelRGBA);
@@ -279,6 +335,7 @@
 avifResult avifImageApplyGainMap(const avifImage * baseImage,
                                  const avifGainMap * gainMap,
                                  float hdrHeadroom,
+                                 avifColorPrimaries outputColorPrimaries,
                                  avifTransferCharacteristics outputTransferCharacteristics,
                                  avifRGBImage * toneMappedImage,
                                  avifContentLightLevelInformationBox * clli,
@@ -300,9 +357,11 @@
     }
 
     res = avifRGBImageApplyGainMap(&baseImageRgb,
+                                   baseImage->colorPrimaries,
                                    baseImage->transferCharacteristics,
                                    gainMap,
                                    hdrHeadroom,
+                                   outputColorPrimaries,
                                    outputTransferCharacteristics,
                                    toneMappedImage,
                                    clli,
@@ -387,11 +446,59 @@
     return AVIF_RESULT_OK;
 }
 
+static const float kEpsilon = 1e-10f;
+
+// Decides which of 'basePrimaries' or 'altPrimaries' should be used for doing gain map math when creating a gain map.
+// The other image (base or alternate) will be converted to this color space before computing
+// the ratio between the two images.
+// If a pixel color is outside of the target color space, some of the converted channel values will be negative.
+// This should be avoided, as the negative values must either be clamped or offset before computing the log2()
+// (since log2 only works on > 0 values). But a large offset causes artefacts when partially applying the gain map.
+// Therefore we want to do gain map math in the larger of the two color spaces.
+static avifResult avifChooseColorSpaceForGainMapMath(avifColorPrimaries basePrimaries,
+                                                     avifColorPrimaries altPrimaries,
+                                                     avifColorPrimaries * gainMapMathColorSpace)
+{
+    if (basePrimaries == altPrimaries) {
+        *gainMapMathColorSpace = basePrimaries;
+        return AVIF_RESULT_OK;
+    }
+    // Color convert pure red, pure green and pure blue in turn and see if they result in negative values.
+    float rgba[4] = { 0 };
+    double baseToAltCoeffs[3][3];
+    double altToBaseCoeffs[3][3];
+    if (!avifColorPrimariesComputeRGBToRGBMatrix(basePrimaries, altPrimaries, baseToAltCoeffs) ||
+        !avifColorPrimariesComputeRGBToRGBMatrix(altPrimaries, basePrimaries, altToBaseCoeffs)) {
+        return AVIF_RESULT_NOT_IMPLEMENTED;
+    }
+
+    float baseColorspaceChannelMin = 0;
+    float altColorspaceChannelMin = 0;
+    for (int c = 0; c < 3; ++c) {
+        rgba[0] = rgba[1] = rgba[2] = 0;
+        rgba[c] = 1.0f;
+        avifLinearRGBConvertColorSpace(rgba, altToBaseCoeffs);
+        for (int i = 0; i < 3; ++i) {
+            baseColorspaceChannelMin = AVIF_MIN(baseColorspaceChannelMin, rgba[i]);
+        }
+        rgba[0] = rgba[1] = rgba[2] = 0;
+        rgba[c] = 1.0f;
+        avifLinearRGBConvertColorSpace(rgba, baseToAltCoeffs);
+        for (int i = 0; i < 3; ++i) {
+            altColorspaceChannelMin = AVIF_MIN(altColorspaceChannelMin, rgba[i]);
+        }
+    }
+    // Pick the colorspace that has the largest min value (which is more or less the largest color space).
+    *gainMapMathColorSpace = (altColorspaceChannelMin <= baseColorspaceChannelMin) ? basePrimaries : altPrimaries;
+    return AVIF_RESULT_OK;
+}
+
 avifResult avifRGBImageComputeGainMap(const avifRGBImage * baseRgbImage,
+                                      avifColorPrimaries baseColorPrimaries,
                                       avifTransferCharacteristics baseTransferCharacteristics,
                                       const avifRGBImage * altRgbImage,
+                                      avifColorPrimaries altColorPrimaries,
                                       avifTransferCharacteristics altTransferCharacteristics,
-                                      avifColorPrimaries colorPrimaries,
                                       avifGainMap * gainMap,
                                       avifDiagnostics * diag)
 {
@@ -404,9 +511,13 @@
     }
     if (gainMap->image->width == 0 || gainMap->image->height == 0 || gainMap->image->depth == 0 ||
         gainMap->image->yuvFormat <= AVIF_PIXEL_FORMAT_NONE || gainMap->image->yuvFormat >= AVIF_PIXEL_FORMAT_COUNT) {
-        avifDiagnosticsPrintf(diag, "gianMap->image should be non null with desired width, height, depth and yuvFormat set");
+        avifDiagnosticsPrintf(diag, "gainMap->image should be non null with desired width, height, depth and yuvFormat set");
         return AVIF_RESULT_INVALID_ARGUMENT;
     }
+    const avifBool colorSpacesDiffer = (baseColorPrimaries != altColorPrimaries);
+    avifColorPrimaries gainMapMathPrimaries;
+    AVIF_CHECKRES(avifChooseColorSpaceForGainMapMath(baseColorPrimaries, altColorPrimaries, &gainMapMathPrimaries));
+    const avifBool useBaseColorSpace = (gainMapMathPrimaries == baseColorPrimaries);
 
     const int width = baseRgbImage->width;
     const int height = baseRgbImage->height;
@@ -441,8 +552,66 @@
 
     float (*baseGammaToLinear)(float) = avifTransferCharacteristicsGetGammaToLinearFunction(baseTransferCharacteristics);
     float (*altGammaToLinear)(float) = avifTransferCharacteristicsGetGammaToLinearFunction(altTransferCharacteristics);
-    float y_coeffs[3];
-    avifColorPrimariesComputeYCoeffs(colorPrimaries, y_coeffs);
+    float yCoeffs[3];
+    avifColorPrimariesComputeYCoeffs(gainMapMathPrimaries, yCoeffs);
+
+    double rgbConversionCoeffs[3][3];
+    if (colorSpacesDiffer) {
+        if (useBaseColorSpace) {
+            if (!avifColorPrimariesComputeRGBToRGBMatrix(altColorPrimaries, baseColorPrimaries, rgbConversionCoeffs)) {
+                avifDiagnosticsPrintf(diag, "Unsupported RGB color space conversion");
+                res = AVIF_RESULT_NOT_IMPLEMENTED;
+                goto cleanup;
+            }
+        } else {
+            if (!avifColorPrimariesComputeRGBToRGBMatrix(baseColorPrimaries, altColorPrimaries, rgbConversionCoeffs)) {
+                avifDiagnosticsPrintf(diag, "Unsupported RGB color space conversion");
+                res = AVIF_RESULT_NOT_IMPLEMENTED;
+                goto cleanup;
+            }
+        }
+    }
+
+    // If we are converting from one colorspace to another, some RGB values may be negative and an offset must be added to
+    // avoid clamping (although the choice of color space to do the gain map computation with
+    // avifChooseColorSpaceForGainMapMath() should mostly avoid this).
+    if (colorSpacesDiffer) {
+        // Color convert pure red, pure green and pure blue in turn and see if they result in negative values.
+        float rgba[4] = { 0 };
+        float channelMin[3] = { 0 };
+        for (int j = 0; j < height; ++j) {
+            for (int i = 0; i < width; ++i) {
+                avifGetRGBAPixel(useBaseColorSpace ? altRgbImage : baseRgbImage, i, j, &baseRGBInfo, rgba);
+
+                // Convert to linear.
+                for (int c = 0; c < 3; ++c) {
+                    if (useBaseColorSpace) {
+                        rgba[c] = altGammaToLinear(rgba[c]);
+                    } else {
+                        rgba[c] = baseGammaToLinear(rgba[c]);
+                    }
+                }
+                avifLinearRGBConvertColorSpace(rgba, rgbConversionCoeffs);
+                for (int c = 0; c < 3; ++c) {
+                    channelMin[c] = AVIF_MIN(channelMin[c], rgba[c]);
+                }
+            }
+        }
+
+        for (int c = 0; c < 3; ++c) {
+            // Large offsets cause artefacts when partially applying the gain map, so set a max (empirical) offset value.
+            // If the offset is clamped, some gain map values will get clamped as well.
+            const float maxOffset = 0.1f;
+            if (channelMin[c] < -kEpsilon) {
+                // Increase the offset to avoid negative values.
+                if (useBaseColorSpace) {
+                    gainMapMetadata.alternateOffset[c] = AVIF_MIN(gainMapMetadata.alternateOffset[c] - channelMin[c], maxOffset);
+                } else {
+                    gainMapMetadata.baseOffset[c] = AVIF_MIN(gainMapMetadata.baseOffset[c] - channelMin[c], maxOffset);
+                }
+            }
+        }
+    }
 
     // Compute raw gain map values.
     float baseMax = 1.0f;
@@ -460,13 +629,23 @@
                 altRGBA[c] = altGammaToLinear(altRGBA[c]);
             }
 
+            if (colorSpacesDiffer) {
+                if (useBaseColorSpace) {
+                    // convert altRGBA to baseRGBA's color space
+                    avifLinearRGBConvertColorSpace(altRGBA, rgbConversionCoeffs);
+                } else {
+                    // convert baseRGBA to altRGBA's color space
+                    avifLinearRGBConvertColorSpace(baseRGBA, rgbConversionCoeffs);
+                }
+            }
+
             for (int c = 0; c < numGainMapChannels; ++c) {
                 float base = baseRGBA[c];
                 float alt = altRGBA[c];
                 if (singleChannel) {
                     // Convert to grayscale.
-                    base = y_coeffs[0] * baseRGBA[0] + y_coeffs[1] * baseRGBA[1] + y_coeffs[2] * baseRGBA[2];
-                    alt = y_coeffs[0] * altRGBA[0] + y_coeffs[1] * altRGBA[1] + y_coeffs[2] * altRGBA[2];
+                    base = yCoeffs[0] * baseRGBA[0] + yCoeffs[1] * baseRGBA[1] + yCoeffs[2] * baseRGBA[2];
+                    alt = yCoeffs[0] * altRGBA[0] + yCoeffs[1] * altRGBA[1] + yCoeffs[2] * altRGBA[2];
                 }
                 if (base > baseMax) {
                     baseMax = base;
@@ -474,8 +653,8 @@
                 if (alt > altMax) {
                     altMax = alt;
                 }
-                const float ratioLog2 =
-                    log2f((alt + (float)gainMapMetadata.alternateOffset[c]) / (base + (float)gainMapMetadata.baseOffset[c]));
+                const float ratio = (alt + (float)gainMapMetadata.alternateOffset[c]) / (base + (float)gainMapMetadata.baseOffset[c]);
+                const float ratioLog2 = log2f(AVIF_MAX(ratio, kEpsilon));
                 gainMapF[c][j * width + i] = ratioLog2;
             }
         }
@@ -495,11 +674,12 @@
     for (int c = 0; c < 3; ++c) {
         gainMapMetadata.gainMapMin[c] = gainMapMinLog2[singleChannel ? 0 : c];
         gainMapMetadata.gainMapMax[c] = gainMapMaxLog2[singleChannel ? 0 : c];
-        gainMapMetadata.baseHdrHeadroom = log2f(baseMax);
-        gainMapMetadata.alternateHdrHeadroom = log2f(altMax);
+        gainMapMetadata.baseHdrHeadroom = log2f(AVIF_MAX(baseMax, kEpsilon));
+        gainMapMetadata.alternateHdrHeadroom = log2f(AVIF_MAX(altMax, kEpsilon));
         // baseOffset, alternateOffset and gainMapGamma are all left to their default values.
         // They could be tweaked based on the images to optimize quality/compression.
     }
+    gainMapMetadata.useBaseColorSpace = useBaseColorSpace;
     if (!avifGainMapMetadataDoubleToFractions(&gainMap->metadata, &gainMapMetadata)) {
         res = AVIF_RESULT_UNKNOWN_ERROR;
         goto cleanup;
@@ -589,13 +769,6 @@
         avifDiagnosticsPrintf(diag, "Computing gain maps for images with ICC profiles is not supported");
         return AVIF_RESULT_NOT_IMPLEMENTED;
     }
-    if (baseImage->colorPrimaries != altImage->colorPrimaries) {
-        avifDiagnosticsPrintf(diag,
-                              "Computing gain maps for images with different color primaries is not supported, got %d and %d",
-                              baseImage->colorPrimaries,
-                              altImage->colorPrimaries);
-        return AVIF_RESULT_NOT_IMPLEMENTED;
-    }
     if (baseImage->width != altImage->width || baseImage->height != altImage->height) {
         avifDiagnosticsPrintf(diag,
                               "Image dimensions don't match, got %dx%d and %dx%d",
@@ -630,10 +803,11 @@
     }
 
     res = avifRGBImageComputeGainMap(&baseImageRgb,
+                                     baseImage->colorPrimaries,
                                      baseImage->transferCharacteristics,
                                      &altImageRgb,
+                                     altImage->colorPrimaries,
                                      altImage->transferCharacteristics,
-                                     baseImage->colorPrimaries,
                                      gainMap,
                                      diag);
 
diff --git a/src/reformat.c b/src/reformat.c
index a8e2a55..01d4295 100644
--- a/src/reformat.c
+++ b/src/reformat.c
@@ -1794,6 +1794,9 @@
     assert(dst != NULL);
     assert(!dst->isFloat || dst->depth == 16);
     assert(dst->format != AVIF_RGB_FORMAT_RGB_565 || dst->depth == 8);
+    assert(rgbaPixel[0] >= 0.0f && rgbaPixel[0] <= 1.0f);
+    assert(rgbaPixel[1] >= 0.0f && rgbaPixel[1] <= 1.0f);
+    assert(rgbaPixel[2] >= 0.0f && rgbaPixel[2] <= 1.0f);
 
     uint8_t * const dstPixel = &dst->pixels[y * dst->rowBytes + x * info->pixelBytes];
 
diff --git a/tests/data/README.md b/tests/data/README.md
index 0b23b67..cb84920 100644
--- a/tests/data/README.md
+++ b/tests/data/README.md
@@ -544,11 +544,17 @@
 
 ## Files colors*_hdr_*.avif and colors*_sdr_srgb.avif
 
-SDR and HDR (PQ) AVIF images in various colorspaces.
-The HDR versions all show the same colors: all colors fit in sRGB but are encoded in various colorspaces.
+![](colors_wcg_hdr_rec2020.avif)
 
-The colors_text* files have text on them. They are not currently used in tests but can be used for manual
-testing of gain maps, as they make it easy to see which version the browser is displaying.
+SDR and HDR (PQ) AVIF images in various colorspaces.
+The files with 'wcg' (wide color gamut) in their name have colors outside of the sRGB color space.
+The files without 'wcg' in their name have sRGB colors, but expressed in various color spaces.
+
+The files with 'text' in their name have text on them. They are not currently used in tests but can be used for manual
+testing of gain maps (e.g. with `avifgainmaputil combine ...`), as they make it easy to see which version
+the browser is displaying.
+
+HDR/wide color gamut images should be viewed on an HDR display, such as on a M1+ Mac Book Pro.
 
 Source : created with Photoshop 25.1.0 (Camera Raw 16.0.1.1683), see sources/colors.psd and
 https://helpx.adobe.com/camera-raw/using/hdr-output.html,
@@ -558,8 +564,15 @@
 Then open the Camera Raw filter (Filter > Camera Raw Filter...), click HDR at the top right, and drag
 the histogram towards the right to create brighter pixels.
 Click the save icon on the top right. Select AVIF as output format and check "HDR output" then save.
+To create an image with a wider color gamut, choose Edit > Assign Profile... and set the color space
+to e.g. BT 2020.
 
-To export more images from sources/colors.psd, flatten desired layers before opening the Camera Raw dialog.
+To export more images from sources/colors.psd:
+- For SDR, show/hide the layers as desired then export to PNG then convert to avif with avifenc
+- For HDR, show/hide the layers as desired then flatten the image, (Layers > Flatten Image), open
+  the Camera Raw dialog, and click the save icon on the top right.
+- For the wide color gamut version, choose Edit > Assign Profile... and set the color space to
+  Rec.ITU-R BT.2020-1
 
 ## Animated Images
 
diff --git a/tests/data/colors_text_wcg_hdr_rec2020.avif b/tests/data/colors_text_wcg_hdr_rec2020.avif
new file mode 100644
index 0000000..b3e313c
--- /dev/null
+++ b/tests/data/colors_text_wcg_hdr_rec2020.avif
Binary files differ
diff --git a/tests/data/colors_text_wcg_sdr_rec2020.avif b/tests/data/colors_text_wcg_sdr_rec2020.avif
new file mode 100644
index 0000000..e51a933
--- /dev/null
+++ b/tests/data/colors_text_wcg_sdr_rec2020.avif
Binary files differ
diff --git a/tests/data/colors_wcg_hdr_rec2020.avif b/tests/data/colors_wcg_hdr_rec2020.avif
new file mode 100644
index 0000000..063d61c
--- /dev/null
+++ b/tests/data/colors_wcg_hdr_rec2020.avif
Binary files differ
diff --git a/tests/gtest/avifgainmaptest.cc b/tests/gtest/avifgainmaptest.cc
index 6d63aa6..d5fde40 100644
--- a/tests/gtest/avifgainmaptest.cc
+++ b/tests/gtest/avifgainmaptest.cc
@@ -907,7 +907,9 @@
       avifGainMapMetadataFractionsToDouble(&metadata_double, &metadata));
 }
 
-static void SwapBaseAndAlternate(avifGainMapMetadata& metadata) {
+static void SwapBaseAndAlternate(const avifImage& new_alternate,
+                                 avifGainMap& gain_map) {
+  avifGainMapMetadata& metadata = gain_map.metadata;
   metadata.backwardDirection = !metadata.backwardDirection;
   metadata.useBaseColorSpace = !metadata.useBaseColorSpace;
   std::swap(metadata.baseHdrHeadroomN, metadata.alternateHdrHeadroomN);
@@ -916,6 +918,14 @@
     std::swap(metadata.baseOffsetN[c], metadata.alternateOffsetN[c]);
     std::swap(metadata.baseOffsetD[c], metadata.alternateOffsetD[c]);
   }
+  gain_map.altColorPrimaries = new_alternate.colorPrimaries;
+  gain_map.altTransferCharacteristics = new_alternate.transferCharacteristics;
+  gain_map.altMatrixCoefficients = new_alternate.matrixCoefficients;
+  gain_map.altYUVRange = new_alternate.yuvRange;
+  gain_map.altDepth = new_alternate.depth;
+  gain_map.altPlaneCount =
+      (new_alternate.yuvFormat == AVIF_PIXEL_FORMAT_YUV400) ? 1 : 3;
+  gain_map.altCLLI = new_alternate.clli;
 }
 
 // Test to generate some test images used by other tests and fuzzers.
@@ -939,6 +949,7 @@
         avifDecoderReadFile(decoder.get(), image.get(), path.c_str());
     ASSERT_EQ(result, AVIF_RESULT_OK)
         << avifResultToString(result) << " " << decoder->diag.error;
+    ASSERT_NE(image->gainMap, nullptr);
     ASSERT_NE(image->gainMap->image, nullptr);
 
     avifDiagnostics diag;
@@ -981,7 +992,7 @@
     // Move the gain map from the sdr image to the hdr image.
     hdr_image->gainMap = sdr_with_gainmap->gainMap;
     sdr_with_gainmap->gainMap = nullptr;
-    SwapBaseAndAlternate(hdr_image->gainMap->metadata);
+    SwapBaseAndAlternate(*sdr_with_gainmap, *hdr_image->gainMap);
     hdr_image->gainMap->altColorPrimaries = sdr_with_gainmap->colorPrimaries;
     hdr_image->gainMap->altTransferCharacteristics =
         sdr_with_gainmap->transferCharacteristics;
@@ -1041,13 +1052,18 @@
       avifImageCreate(tone_mapped_rgb.width, tone_mapped_rgb.height,
                       tone_mapped_rgb.depth, AVIF_PIXEL_FORMAT_YUV444));
   tone_mapped->transferCharacteristics = out_transfer_characteristics;
-  tone_mapped->colorPrimaries = base_image->colorPrimaries;
-  tone_mapped->matrixCoefficients = base_image->matrixCoefficients;
+  tone_mapped->colorPrimaries = reference_image
+                                    ? reference_image->colorPrimaries
+                                    : base_image->colorPrimaries;
+  tone_mapped->matrixCoefficients = reference_image
+                                        ? reference_image->matrixCoefficients
+                                        : base_image->matrixCoefficients;
 
   avifDiagnostics diag;
   avifResult result = avifImageApplyGainMap(
-      base_image, &gain_map, hdr_headroom, tone_mapped->transferCharacteristics,
-      &tone_mapped_rgb, &tone_mapped->clli, &diag);
+      base_image, &gain_map, hdr_headroom, tone_mapped->colorPrimaries,
+      tone_mapped->transferCharacteristics, &tone_mapped_rgb,
+      &tone_mapped->clli, &diag);
   ASSERT_EQ(result, AVIF_RESULT_OK)
       << avifResultToString(result) << " " << diag.error;
   ASSERT_EQ(avifImageRGBToYUV(tone_mapped.get(), &tone_mapped_rgb),
@@ -1102,7 +1118,7 @@
       avifDecoderReadFile(decoder.get(), image.get(), path.c_str());
   ASSERT_EQ(result, AVIF_RESULT_OK)
       << avifResultToString(result) << " " << decoder->diag.error;
-
+  ASSERT_NE(image->gainMap, nullptr);
   ASSERT_NE(image->gainMap->image, nullptr);
 
   ToneMapImageAndCompareToReference(image.get(), *image->gainMap, hdr_headroom,
@@ -1255,40 +1271,41 @@
   }
 }
 
-class CreateGainMapTest : public testing::TestWithParam<std::tuple<
-                              /*downscaling=*/int, /*gain_map_depth=*/int,
-                              /*gain_map_format=*/avifPixelFormat,
-                              /*min_psnr=*/float, /*max_psnr=*/float>> {};
+class CreateGainMapTest
+    : public testing::TestWithParam<std::tuple<
+          /*image1_name=*/std::string, /*image2_name=*/std::string,
+          /*downscaling=*/int, /*gain_map_depth=*/int,
+          /*gain_map_format=*/avifPixelFormat,
+          /*min_psnr=*/float, /*max_psnr=*/float>> {};
 
+// Creates a gain map to go from image1 to image2, and tone maps to check we get
+// the correct result. Then does the same thing going from image2 to image1.
 TEST_P(CreateGainMapTest, Create) {
-  const int downscaling = std::get<0>(GetParam());
-  const int gain_map_depth = std::get<1>(GetParam());
-  const avifPixelFormat gain_map_format = std::get<2>(GetParam());
-  const float min_psnr = std::get<3>(GetParam());
-  const float max_psnr = std::get<4>(GetParam());
+  const std::string image1_name = std::get<0>(GetParam());
+  const std::string image2_name = std::get<1>(GetParam());
+  const int downscaling = std::get<2>(GetParam());
+  const int gain_map_depth = std::get<3>(GetParam());
+  const avifPixelFormat gain_map_format = std::get<4>(GetParam());
+  const float min_psnr = std::get<5>(GetParam());
+  const float max_psnr = std::get<6>(GetParam());
 
-  const std::string sdr_image_name = "seine_sdr_gainmap_srgb.avif";
-  const std::string hdr_image_name = "seine_hdr_gainmap_srgb.avif";
-  ImagePtr sdr_image =
-      testutil::DecodeFile(std::string(data_path) + sdr_image_name);
-  ASSERT_NE(sdr_image, nullptr);
-  ImagePtr hdr_image =
-      testutil::DecodeFile(std::string(data_path) + hdr_image_name);
-  ASSERT_NE(hdr_image, nullptr);
+  ImagePtr image1 = testutil::DecodeFile(std::string(data_path) + image1_name);
+  ASSERT_NE(image1, nullptr);
+  ImagePtr image2 = testutil::DecodeFile(std::string(data_path) + image2_name);
+  ASSERT_NE(image2, nullptr);
 
   const uint32_t gain_map_width = std::max<uint32_t>(
-      (uint32_t)std::round((float)sdr_image->width / downscaling), 1u);
+      (uint32_t)std::round((float)image1->width / downscaling), 1u);
   const uint32_t gain_map_height = std::max<uint32_t>(
-      (uint32_t)std::round((float)sdr_image->height / downscaling), 1u);
+      (uint32_t)std::round((float)image1->height / downscaling), 1u);
   std::unique_ptr<avifGainMap, decltype(&avifGainMapDestroy)> gain_map(
       avifGainMapCreate(), avifGainMapDestroy);
-  ImagePtr gain_map_image(avifImageCreate(gain_map_width, gain_map_height,
-                                          gain_map_depth, gain_map_format));
-  gain_map->image =
-      gain_map_image.release();  // 'gain_map' now owns gain_map_image;
+  gain_map->image = avifImageCreate(gain_map_width, gain_map_height,
+                                    gain_map_depth, gain_map_format);
 
   avifDiagnostics diag;
-  avifResult result = avifImageComputeGainMap(sdr_image.get(), hdr_image.get(),
+  gain_map->metadata.useBaseColorSpace = true;
+  avifResult result = avifImageComputeGainMap(image1.get(), image2.get(),
                                               gain_map.get(), &diag);
   ASSERT_EQ(result, AVIF_RESULT_OK)
       << avifResultToString(result) << " " << diag.error;
@@ -1296,98 +1313,140 @@
   EXPECT_EQ(gain_map->image->width, gain_map_width);
   EXPECT_EQ(gain_map->image->height, gain_map_height);
 
-  const float hdr_headroom = (float)gain_map->metadata.alternateHdrHeadroomN /
-                             gain_map->metadata.alternateHdrHeadroomD;
+  const float image1_headroom = (float)gain_map->metadata.baseHdrHeadroomN /
+                                gain_map->metadata.baseHdrHeadroomD;
+  const float image2_headroom =
+      (float)gain_map->metadata.alternateHdrHeadroomN /
+      gain_map->metadata.alternateHdrHeadroomD;
 
-  // Tone map from sdr to hdr.
-  float psnr_sdr_to_hdr_forward;
+  // Tone map from image1 to image2 by applying the gainmap forward.
+  float psnr_image1_to_image2_forward;
   ToneMapImageAndCompareToReference(
-      sdr_image.get(), *gain_map, hdr_headroom, hdr_image->depth,
-      hdr_image->transferCharacteristics, AVIF_RGB_FORMAT_RGB, hdr_image.get(),
-      min_psnr, max_psnr, &psnr_sdr_to_hdr_forward);
+      image1.get(), *gain_map, image2_headroom, image2->depth,
+      image2->transferCharacteristics, AVIF_RGB_FORMAT_RGB, image2.get(),
+      min_psnr, max_psnr, &psnr_image1_to_image2_forward);
 
-  // Tone map from hdr to sdr.
-  SwapBaseAndAlternate(gain_map->metadata);
-  float psnr_hdr_to_sdr_backward;
+  // Tone map from image2 to image1 by applying the gainmap backward.
+  SwapBaseAndAlternate(*image1, *gain_map);
+  float psnr_image2_to_image1_backward;
   ToneMapImageAndCompareToReference(
-      hdr_image.get(), *gain_map, /*hdr_headroom=*/0.0, sdr_image->depth,
-      sdr_image->transferCharacteristics, AVIF_RGB_FORMAT_RGB, sdr_image.get(),
-      min_psnr, max_psnr, &psnr_hdr_to_sdr_backward);
-
-  // Uncomment the following to save the gain map as a PNG file.
-  // ASSERT_TRUE(testutil::WriteImage(gain_map.image,
-  // "/tmp/gain_map_sdr_to_hdr.png"));
-
-  // Compute the gain map in the other direction (from hdr to sdr).
-  result = avifImageComputeGainMap(hdr_image.get(), sdr_image.get(),
-                                   gain_map.get(), &diag);
-  ASSERT_EQ(result, AVIF_RESULT_OK)
-      << avifResultToString(result) << " " << diag.error;
-
-  const float hdr_headroom2 = (float)gain_map->metadata.baseHdrHeadroomN /
-                              gain_map->metadata.baseHdrHeadroomD;
-  EXPECT_NEAR(hdr_headroom2, hdr_headroom, 0.001);
-
-  // Tone map from hdr to sdr.
-  float psnr_hdr_to_sdr_forward;
-  ToneMapImageAndCompareToReference(
-      hdr_image.get(), *gain_map, /*hdr_headroom=*/0.0, sdr_image->depth,
-      sdr_image->transferCharacteristics, AVIF_RGB_FORMAT_RGB, sdr_image.get(),
-      min_psnr, max_psnr, &psnr_hdr_to_sdr_forward);
-
-  // Tone map from sdr to hdr.
-  SwapBaseAndAlternate(gain_map->metadata);
-  float psnr_sdr_to_hdr_backward;
-  ToneMapImageAndCompareToReference(
-      sdr_image.get(), *gain_map, hdr_headroom, hdr_image->depth,
-      hdr_image->transferCharacteristics, AVIF_RGB_FORMAT_RGB, hdr_image.get(),
-      min_psnr, max_psnr, &psnr_sdr_to_hdr_backward);
+      image2.get(), *gain_map, image1_headroom, image1->depth,
+      image1->transferCharacteristics, AVIF_RGB_FORMAT_RGB, image1.get(),
+      min_psnr, max_psnr, &psnr_image2_to_image1_backward);
 
   // Uncomment the following to save the gain map as a PNG file.
   // ASSERT_TRUE(testutil::WriteImage(gain_map->image,
-  // "/tmp/gain_map_hdr_to_sdr.png"));
+  //                                  "/tmp/gain_map_image1_to_image2.png"));
+
+  // Compute the gain map in the other direction (from image2 to image1).
+  gain_map->metadata.useBaseColorSpace = false;
+  result = avifImageComputeGainMap(image2.get(), image1.get(), gain_map.get(),
+                                   &diag);
+  ASSERT_EQ(result, AVIF_RESULT_OK)
+      << avifResultToString(result) << " " << diag.error;
+
+  const float image2_headroom2 = (float)gain_map->metadata.baseHdrHeadroomN /
+                                 gain_map->metadata.baseHdrHeadroomD;
+  EXPECT_NEAR(image2_headroom2, image2_headroom, 0.001);
+
+  // Tone map from image2 to image1 by applying the new gainmap forward.
+  float psnr_image2_to_image1_forward;
+  ToneMapImageAndCompareToReference(
+      image2.get(), *gain_map, image1_headroom, image1->depth,
+      image1->transferCharacteristics, AVIF_RGB_FORMAT_RGB, image1.get(),
+      min_psnr, max_psnr, &psnr_image2_to_image1_forward);
+
+  // Tone map from image1 to image2 by applying the new gainmap backward.
+  SwapBaseAndAlternate(*image2, *gain_map);
+  float psnr_image1_to_image2_backward;
+  ToneMapImageAndCompareToReference(
+      image1.get(), *gain_map, image2_headroom, image2->depth,
+      image2->transferCharacteristics, AVIF_RGB_FORMAT_RGB, image2.get(),
+      min_psnr, max_psnr, &psnr_image1_to_image2_backward);
+
+  // Uncomment the following to save the gain map as a PNG file.
+  // ASSERT_TRUE(testutil::WriteImage(gain_map->image,
+  //                                  "/tmp/gain_map_image2_to_image1.png"));
 
   // Results should be about the same whether the gain map was computed from sdr
   // to hdr or the other way around.
-  EXPECT_NEAR(psnr_sdr_to_hdr_backward, psnr_sdr_to_hdr_forward, 0.6f);
-  EXPECT_NEAR(psnr_hdr_to_sdr_forward, psnr_hdr_to_sdr_backward, 0.6f);
+  EXPECT_NEAR(psnr_image1_to_image2_backward, psnr_image1_to_image2_forward,
+              min_psnr * 0.1f);
+  EXPECT_NEAR(psnr_image2_to_image1_forward, psnr_image2_to_image1_backward,
+              min_psnr * 0.1f);
 }
 
 INSTANTIATE_TEST_SUITE_P(
     All, CreateGainMapTest,
     Values(
         // Full scale gain map, 3 channels, 10 bit gain map.
-        std::make_tuple(/*downscaling=*/1, /*gain_map_depth=*/10,
+        std::make_tuple(/*image1_name=*/"seine_sdr_gainmap_srgb.avif",
+                        /*image2_name=*/"seine_hdr_gainmap_srgb.avif",
+                        /*downscaling=*/1, /*gain_map_depth=*/10,
                         /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV444,
                         /*min_psnr=*/55.0f, /*max_psnr=*/80.0f),
         // 8 bit gain map, expect a slightly lower PSNR.
-        std::make_tuple(/*downscaling=*/1, /*gain_map_depth=*/8,
+        std::make_tuple(/*image1_name=*/"seine_sdr_gainmap_srgb.avif",
+                        /*image2_name=*/"seine_hdr_gainmap_srgb.avif",
+                        /*downscaling=*/1, /*gain_map_depth=*/8,
                         /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV444,
                         /*min_psnr=*/50.0f, /*max_psnr=*/70.0f),
         // 420 gain map, expect a lower PSNR.
-        std::make_tuple(/*downscaling=*/1, /*gain_map_depth=*/8,
+        std::make_tuple(/*image1_name=*/"seine_sdr_gainmap_srgb.avif",
+                        /*image2_name=*/"seine_hdr_gainmap_srgb.avif",
+                        /*downscaling=*/1, /*gain_map_depth=*/8,
                         /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV420,
                         /*min_psnr=*/40.0f, /*max_psnr=*/60.0f),
         // Downscaled gain map, expect a lower PSNR.
-        std::make_tuple(/*downscaling=*/2, /*gain_map_depth=*/8,
+        std::make_tuple(/*image1_name=*/"seine_sdr_gainmap_srgb.avif",
+                        /*image2_name=*/"seine_hdr_gainmap_srgb.avif",
+                        /*downscaling=*/2, /*gain_map_depth=*/8,
                         /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV444,
                         /*min_psnr=*/35.0f, /*max_psnr=*/45.0f),
         // Even more downscaled gain map, expect a lower PSNR.
-        std::make_tuple(/*downscaling=*/3, /*gain_map_depth=*/8,
+        std::make_tuple(/*image1_name=*/"seine_sdr_gainmap_srgb.avif",
+                        /*image2_name=*/"seine_hdr_gainmap_srgb.avif",
+                        /*downscaling=*/3, /*gain_map_depth=*/8,
                         /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV444,
                         /*min_psnr=*/35.0f, /*max_psnr=*/45.0f),
         // Extreme downscaling, just for fun.
-        std::make_tuple(/*downscaling=*/255, /*gain_map_depth=*/8,
+        std::make_tuple(/*image1_name=*/"seine_sdr_gainmap_srgb.avif",
+                        /*image2_name=*/"seine_hdr_gainmap_srgb.avif",
+                        /*downscaling=*/255, /*gain_map_depth=*/8,
                         /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV444,
                         /*min_psnr=*/20.0f, /*max_psnr=*/35.0f),
         // Grayscale gain map.
-        std::make_tuple(/*downscaling=*/1, /*gain_map_depth=*/8,
+        std::make_tuple(/*image1_name=*/"seine_sdr_gainmap_srgb.avif",
+                        /*image2_name=*/"seine_hdr_gainmap_srgb.avif",
+                        /*downscaling=*/1, /*gain_map_depth=*/8,
                         /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV400,
                         /*min_psnr=*/40.0f, /*max_psnr=*/60.0f),
         // Downscaled AND grayscale.
-        std::make_tuple(/*downscaling=*/2, /*gain_map_depth=*/8,
+        std::make_tuple(/*image1_name=*/"seine_sdr_gainmap_srgb.avif",
+                        /*image2_name=*/"seine_hdr_gainmap_srgb.avif",
+                        /*downscaling=*/2, /*gain_map_depth=*/8,
                         /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV400,
-                        /*min_psnr=*/35.0f, /*max_psnr=*/45.0f)));
+                        /*min_psnr=*/35.0f, /*max_psnr=*/45.0f),
+
+        // Color space conversions.
+        std::make_tuple(/*image1_name=*/"colors_sdr_srgb.avif",
+                        /*image2_name=*/"colors_hdr_rec2020.avif",
+                        /*downscaling=*/1, /*gain_map_depth=*/10,
+                        /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV444,
+                        /*min_psnr=*/55.0f, /*max_psnr=*/100.0f),
+        // The PSNR is very high because there are essentially the same image,
+        // simply expresed in different colorspaces.
+        std::make_tuple(/*image1_name=*/"colors_hdr_rec2020.avif",
+                        /*image2_name=*/"colors_hdr_p3.avif",
+                        /*downscaling=*/1, /*gain_map_depth=*/8,
+                        /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV444,
+                        /*min_psnr=*/90.0f, /*max_psnr=*/100.0f),
+        // Color space conversions with wider color gamut.
+        std::make_tuple(/*image1_name=*/"colors_sdr_srgb.avif",
+                        /*image2_name=*/"colors_wcg_hdr_rec2020.avif",
+                        /*downscaling=*/1, /*gain_map_depth=*/10,
+                        /*gain_map_format=*/AVIF_PIXEL_FORMAT_YUV444,
+                        /*min_psnr=*/55.0f, /*max_psnr=*/80.0f)));
 
 TEST(FindMinMaxWithoutOutliers, AllSame) {
   constexpr int kNumValues = 10000;
diff --git a/tests/gtest/aviftest_helpers.cc b/tests/gtest/aviftest_helpers.cc
index 592a084..3813103 100644
--- a/tests/gtest/aviftest_helpers.cc
+++ b/tests/gtest/aviftest_helpers.cc
@@ -305,6 +305,21 @@
   }
   assert(image1.width * image1.height > 0);
 
+  if (image1.colorPrimaries != image2.colorPrimaries ||
+      image1.transferCharacteristics != image2.transferCharacteristics ||
+      image1.matrixCoefficients != image2.matrixCoefficients ||
+      image1.yuvRange != image2.yuvRange) {
+    fprintf(stderr,
+            "WARNING: computing PSNR of images with different CICP: %d/%d/%d%s "
+            "vs %d/%d/%d%s\n",
+            image1.colorPrimaries, image1.transferCharacteristics,
+            image1.matrixCoefficients,
+            (image1.yuvRange == AVIF_RANGE_FULL) ? "f" : "l",
+            image2.colorPrimaries, image2.transferCharacteristics,
+            image2.matrixCoefficients,
+            (image2.yuvRange == AVIF_RANGE_FULL) ? "f" : "l");
+  }
+
   uint64_t squared_diff_sum = 0;
   uint32_t num_samples = 0;
   const uint32_t max_sample_value = (1 << image1.depth) - 1;
diff --git a/tests/test_cmd_avifgainmaputil.sh b/tests/test_cmd_avifgainmaputil.sh
index c48e323..08acaf7 100755
--- a/tests/test_cmd_avifgainmaputil.sh
+++ b/tests/test_cmd_avifgainmaputil.sh
@@ -31,6 +31,7 @@
 # Input file paths.
 INPUT_AVIF_GAINMAP_SDR="${TESTDATA_DIR}/seine_sdr_gainmap_srgb.avif"
 INPUT_AVIF_GAINMAP_HDR="${TESTDATA_DIR}/seine_hdr_gainmap_srgb.avif"
+INPUT_AVIF_GAINMAP_HDR2020="${TESTDATA_DIR}/seine_hdr_rec2020.avif"
 JPEG_AVIF_GAINMAP_SDR="${TESTDATA_DIR}/seine_sdr_gainmap_srgb.jpg"
 # Output file names.
 AVIF_OUTPUT="avif_test_cmd_avifgainmaputil_output.avif"
@@ -60,6 +61,8 @@
       -q 50 --qgain-map 90 && exit 1 # should fail because icc profiles are not supported
   "${AVIFGAINMAPUTIL}" combine "${JPEG_AVIF_GAINMAP_SDR}" "${INPUT_AVIF_GAINMAP_HDR}" "${AVIF_OUTPUT}" \
       -q 50 --qgain-map 90 --ignore-profile
+  "${AVIFGAINMAPUTIL}" combine "${INPUT_AVIF_GAINMAP_SDR}" "${INPUT_AVIF_GAINMAP_HDR2020}" "${AVIF_OUTPUT}" \
+      -q 50 --downscaling 2 --yuv-gain-map 400
 
   "${AVIFGAINMAPUTIL}" combine "${INPUT_AVIF_GAINMAP_HDR}" "${INPUT_AVIF_GAINMAP_SDR}" "${AVIF_OUTPUT}" \
       -q 90 --qgain-map 90