Commit 931db4d377 for ffmpeg
commit 931db4d37759e1914861a78667d8a47955a0a6ff
Author: Niklas Haas <git@haasn.dev>
Date: Wed Aug 19 13:28:47 2026 +0200
avfilter/vf_gainmap: add filter to compute a gain map
This is a mostly straightforward translation of the logic from ISO 21496.
Implementation notes:
- Slice threading is used to vastly speed up processing
- We quantize the EOTF to a LUT if possible; this doesn't work for HLG becaus
HLG embeds a channel-dependent OOTF. It's possible we could implement a
custom HLG function here in the future, but for now, simply calling out
to the EOTF function is a reasonable fallback.
- Similar to libultrahdr, we use a two-pass approach when the gain range is
unknown a priori, and fold the quantization step into a single pass if
all parameters are specified up-front.
- Unlike libultrahdr, we make an effort to also pick an optimal gamma to
tune the quantized encoding. Users can always disable this by specifying
`:gamma=1.0` explicitly.
- This filter is bidirectional and can also compute an HDR->SDR gainmap,
so the naming is left deliberately abstract internally.
- We need to be careful to avoid platform-specific floating point differences.
This is accomplished by quantizing the floating point values to a fixed
point AVRational representation rather than using av_d2q().
Signed-off-by: Niklas Haas <git@haasn.dev>
diff --git a/doc/filters.texi b/doc/filters.texi
index 24d52a3a01..e8c8b23481 100644
--- a/doc/filters.texi
+++ b/doc/filters.texi
@@ -15230,6 +15230,80 @@ sort -n MAP_FILE
ffmpeg -i INPUT -i OUTPUT -filter_complex '[0:v]fsync=file=MAP_FILE[ref];[1:v][ref]ssim' -f null -
@end example
+@section gainmap
+
+Compute an HDR gain map from two renditions of the same image, as defined by
+ISO 21496-1.
+
+The first input is the base rendition (typically SDR), and the second input
+is the alternate rendition (typically HDR). Both must have the same
+dimensions. Outputs a gain map, as either @samp{gbrpf32} or @samp{grayf32}
+depending on the chosen @option{mode}.
+
+@table @option
+@item mode
+Controls what quantity the gain map is computed against.
+
+The accepted values are:
+@table @samp
+@item rgb
+One gain map channel per component.
+
+@item luma
+A single gain map channel, computed from the luma.
+
+@item maxrgb
+A single gain map channel, computed from @samp{max(R, G, B)}.
+@end table
+
+Default is @samp{rgb}.
+
+@item colorspace
+Chooses which colorspace to compute the gain map in.
+
+The accepted values are:
+@table @samp
+@item base
+The base rendition's colorspace.
+
+@item alternate
+The alternate rendition's colorspace.
+@end table
+
+Default is @samp{base}.
+
+@item min
+@item max
+Bounds on the encoded gain (log2). If unspecified, these will be
+measured from the frame.
+
+@item gamma
+Encoding gamma of the resulting gain map. If unspecified, this will be
+optimized for the frame in question. Setting this to @code{1.0} will produce
+a linear gain map, which may be more efficient in some applications.
+
+@item base_offset
+@item alt_offset
+Offset to prevent numerical instability near zero. Both default to @code{1/64}.
+
+@item base_nits
+@item alt_nits
+Override the reference luminance of the input, in nits. If unspecified, uses
+the values from the input metadata.
+@end table
+
+The @code{gainmap} filter also supports the @ref{framesync} options.
+
+@subsection Examples
+
+@itemize
+@item
+Compute a gain map between an SDR and an HDR version of the same image:
+@example
+ffmpeg -i $sdr -i $hdr -filter_complex "[0:v][1:v]gainmap" $out
+@end example
+@end itemize
+
@section gblur
Apply Gaussian blur filter.
diff --git a/libavfilter/Makefile b/libavfilter/Makefile
index ceea328e67..ac194bead7 100644
--- a/libavfilter/Makefile
+++ b/libavfilter/Makefile
@@ -333,6 +333,7 @@ OBJS-$(CONFIG_FREI0R_FILTER) += vf_frei0r.o
OBJS-$(CONFIG_FRUC_VULKAN_FILTER) += vf_fruc_vulkan.o vulkan.o vulkan_filter.o
OBJS-$(CONFIG_FSPP_FILTER) += vf_fspp.o vf_fsppdsp.o qp_table.o
OBJS-$(CONFIG_FSYNC_FILTER) += vf_fsync.o
+OBJS-$(CONFIG_GAINMAP_FILTER) += vf_gainmap.o colorspace.o framesync.o
OBJS-$(CONFIG_GBLUR_FILTER) += vf_gblur.o
OBJS-$(CONFIG_GBLUR_VULKAN_FILTER) += vf_gblur_vulkan.o vulkan.o vulkan_filter.o
OBJS-$(CONFIG_GEQ_FILTER) += vf_geq.o
diff --git a/libavfilter/allfilters.c b/libavfilter/allfilters.c
index 3b93fe12f6..931ac8481e 100644
--- a/libavfilter/allfilters.c
+++ b/libavfilter/allfilters.c
@@ -307,6 +307,7 @@ extern const FFFilter ff_vf_frei0r;
extern const FFFilter ff_vf_fruc_vulkan;
extern const FFFilter ff_vf_fspp;
extern const FFFilter ff_vf_fsync;
+extern const FFFilter ff_vf_gainmap;
extern const FFFilter ff_vf_gblur;
extern const FFFilter ff_vf_gblur_vulkan;
extern const FFFilter ff_vf_geq;
diff --git a/libavfilter/vf_gainmap.c b/libavfilter/vf_gainmap.c
new file mode 100644
index 0000000000..866fef53a6
--- /dev/null
+++ b/libavfilter/vf_gainmap.c
@@ -0,0 +1,774 @@
+/*
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+/**
+ * @file
+ * Filter to compute an HDR gain map from a base and alternate rendition.
+ */
+
+#include <float.h>
+#include <math.h>
+
+#include "libavutil/csp.h"
+#include "libavutil/gain_map.h"
+#include "libavutil/internal.h"
+#include "libavutil/mastering_display_metadata.h"
+#include "libavutil/mem.h"
+#include "libavutil/opt.h"
+#include "libavutil/pixdesc.h"
+
+#include "avfilter.h"
+#include "colorspace.h"
+#include "filters.h"
+#include "formats.h"
+#include "framesync.h"
+#include "video.h"
+
+#define SDR_DIFFUSE_WHITE 203.0
+
+enum GainMapColorspace {
+ GAINMAP_CSP_BASE, ///< apply the map in the base rendition's space
+ GAINMAP_CSP_ALT, ///< apply it in the alternate rendition's space
+ GAINMAP_CSP_NB,
+};
+
+enum GainMapMode {
+ GAINMAP_RGB, ///< one channel per component
+ GAINMAP_LUMA, ///< single channel, from the luminance
+ GAINMAP_MAXRGB, ///< single channel, from max(R,G,B)
+ GAINMAP_MODE_NB,
+};
+
+enum GainMapMeasure {
+ MEASURE_MIN = 1 << 0,
+ MEASURE_MAX = 1 << 1,
+ MEASURE_GAMMA = 1 << 2,
+ MEASURE_RANGE = MEASURE_MIN | MEASURE_MAX,
+ MEASURE_ALL = MEASURE_RANGE | MEASURE_GAMMA,
+};
+
+typedef struct GainMapEOTF {
+ enum AVColorTransferCharacteristic trc;
+ av_csp_eotf_function eotf; /* or NULL */
+ double Lw;
+
+#define GAINMAP_LUT_SIZE 1025
+ float lut[GAINMAP_LUT_SIZE + 1]; /* extra padding entry */
+} GainMapEOTF;
+
+typedef struct GainMapContext {
+ const AVClass *class;
+ FFFrameSync fs;
+
+ /* Filter options */
+ int mode;
+ int colorspace;
+ AVRational gain_min;
+ AVRational gain_max;
+ AVRational gamma;
+ AVRational base_offset;
+ AVRational alt_offset;
+ AVRational base_nits;
+ AVRational alt_nits;
+
+ int warned_noop;
+ int nb_channels;
+ int nb_threads; /* for slice threading */
+ int convert; /* colorspace conversion needed */
+ int sign; /* sign(alt_peak - base_peak) */
+
+ /* Colorspace parameters */
+ GainMapEOTF base_eotf;
+ GainMapEOTF alt_eotf;
+ float rgb2y[3];
+ float rgb2rgb[3][3];
+
+ /* Quantization parameters, either static or recomputed dynamically */
+ float quant_scale[3];
+ float quant_offset[3];
+ float quant_gamma[3];
+
+ /* Measured frame statistics */
+ float (*slice_min)[3]; /* for MEASURE_MIN */
+ float (*slice_max)[3]; /* for MEASURE_MAX */
+ double (*slice_sum)[3]; /* for MEASURE_GAMMA */
+ enum GainMapMeasure measure;
+
+ /* Generated gain map parameters (recomputed per frame) */
+ AVGainMapParams params;
+} GainMapContext;
+
+typedef struct ThreadData {
+ AVFrame *out;
+ const AVFrame *base, *alt;
+} ThreadData;
+
+#define OFFSET(x) offsetof(GainMapContext, x)
+#define FLAGS AV_OPT_FLAG_FILTERING_PARAM | AV_OPT_FLAG_VIDEO_PARAM
+
+static const AVOption gainmap_options[] = {
+ { "mode", "quantity the gain is computed over", OFFSET(mode), AV_OPT_TYPE_INT, { .i64 = GAINMAP_RGB }, 0, GAINMAP_MODE_NB - 1, FLAGS, .unit = "mode" },
+ { "rgb", "one gain channel per component", 0, AV_OPT_TYPE_CONST, { .i64 = GAINMAP_RGB }, 0, 0, FLAGS, .unit = "mode" },
+ { "luma", "single gain channel, from luminance", 0, AV_OPT_TYPE_CONST, { .i64 = GAINMAP_LUMA }, 0, 0, FLAGS, .unit = "mode" },
+ { "maxrgb", "single gain channel, from max(R,G,B)", 0, AV_OPT_TYPE_CONST, { .i64 = GAINMAP_MAXRGB }, 0, 0, FLAGS, .unit = "mode" },
+ { "colorspace", "rendition whose colour space the map is applied in", OFFSET(colorspace), AV_OPT_TYPE_INT, { .i64 = GAINMAP_CSP_BASE }, 0, GAINMAP_CSP_NB - 1, FLAGS, .unit = "colorspace" },
+ { "base", "the base rendition's colour space", 0, AV_OPT_TYPE_CONST, { .i64 = GAINMAP_CSP_BASE }, 0, 0, FLAGS, .unit = "colorspace" },
+ { "alternate", "the alternate rendition's colour space", 0, AV_OPT_TYPE_CONST, { .i64 = GAINMAP_CSP_ALT }, 0, 0, FLAGS, .unit = "colorspace" },
+ { "min", "override lower bound on the encoded gain, in log2 space", OFFSET(gain_min), AV_OPT_TYPE_RATIONAL, { .dbl = NAN }, -32.0, 32.0, FLAGS },
+ { "max", "override upper bound on the encoded gain, in log2 space", OFFSET(gain_max), AV_OPT_TYPE_RATIONAL, { .dbl = NAN }, -32.0, 32.0, FLAGS },
+ { "gamma", "override encoding gamma of the stored map (default: measured)", OFFSET(gamma), AV_OPT_TYPE_RATIONAL, { .dbl = NAN }, 0.0001, 100.0, FLAGS },
+ { "base_offset", "constant added to the base rendition", OFFSET(base_offset), AV_OPT_TYPE_RATIONAL, { .dbl = 1.0 / 64.0 }, 1e-6, 1.0, FLAGS },
+ { "alt_offset", "constant added to the alternate rendition", OFFSET(alt_offset), AV_OPT_TYPE_RATIONAL, { .dbl = 1.0 / 64.0 }, 1e-6, 1.0, FLAGS },
+ { "base_nits", "override input luminance of the base rendition", OFFSET(base_nits), AV_OPT_TYPE_RATIONAL, { .dbl = NAN }, 1.0, 10000.0, FLAGS },
+ { "alt_nits", "override input luminance of the alternate rendition", OFFSET(alt_nits), AV_OPT_TYPE_RATIONAL, { .dbl = NAN }, 1.0, 10000.0, FLAGS },
+ { NULL }
+};
+
+FRAMESYNC_DEFINE_CLASS(gainmap, GainMapContext, fs);
+
+/* Quantize to a fixed point representation with the correct rounding mode */
+static AVRational quantq(double x, enum AVRounding rnd)
+{
+ const int scale = 1 << 22;
+
+ switch (rnd) {
+ case AV_ROUND_DOWN: x = floor(x * scale); break;
+ case AV_ROUND_UP: x = ceil(x * scale); break;
+ case AV_ROUND_NEAR_INF: x = round(x * scale); break;
+ default: av_unreachable("not used / implemented");
+ }
+
+ AVRational q;
+ av_reduce(&q.num, &q.den, x, scale, INT_MAX);
+ return q;
+}
+
+static void setup_range(AVFilterContext *ctx, int ch, AVRational min, AVRational max)
+{
+ GainMapContext *s = ctx->priv;
+ if (av_cmp_q(max, min) <= 0) {
+ /* Nothing to quantize; flat gain map */
+ s->quant_scale[ch] = s->quant_offset[ch] = 0.0f;
+ max = min; /* sanity */
+ } else {
+ const float minf = av_q2d(min), maxf = av_q2d(max);
+ s->quant_scale[ch] = 1.0f / (maxf - minf);
+ s->quant_offset[ch] = -minf * s->quant_scale[ch];
+ }
+
+ s->params.channels[ch].gain_map_min = min;
+ s->params.channels[ch].gain_map_max = max;
+ av_log(ctx, AV_LOG_TRACE, "channel %d: measured min=%g max=%g\n",
+ ch, av_q2d(min), av_q2d(max));
+}
+
+static void setup_gamma(AVFilterContext *ctx, int ch, AVRational gamma)
+{
+ GainMapContext *s = ctx->priv;
+ s->quant_gamma[ch] = av_q2d(gamma);
+ s->params.channels[ch].gamma = gamma;
+ av_log(ctx, AV_LOG_TRACE, "channel %d: measured gamma=%g\n",
+ ch, s->quant_gamma[ch]);
+}
+
+static av_cold int init(AVFilterContext *ctx)
+{
+ GainMapContext *s = ctx->priv;
+
+ if (av_cmp_q(s->gain_min, s->gain_max) >= 0) {
+ av_log(ctx, AV_LOG_ERROR, "min (%g) must be below max (%g)\n",
+ av_q2d(s->gain_min), av_q2d(s->gain_max));
+ return AVERROR(EINVAL);
+ }
+
+ s->nb_channels = s->mode == GAINMAP_RGB ? 3 : 1;
+ if (!s->gain_min.den)
+ s->measure |= MEASURE_MIN;
+ if (!s->gain_max.den)
+ s->measure |= MEASURE_MAX;
+ if (!s->gamma.den)
+ s->measure |= MEASURE_GAMMA;
+ if (s->measure) {
+ av_log(ctx, AV_LOG_VERBOSE, "Using two passes to measure gain map "
+ "parameters from the input frame.\n");
+ }
+
+ s->params = (AVGainMapParams) {
+ .version = 0,
+ .nb_channels = s->nb_channels,
+ .use_base_color_space = s->colorspace == GAINMAP_CSP_BASE,
+ /* payload metadata recomputed per frame */
+ };
+
+ for (int c = 0; c < s->nb_channels; c++) {
+ struct AVGainMapChannel *ch = &s->params.channels[c];
+ ch->base_offset = s->base_offset;
+ ch->alternate_offset = s->alt_offset;
+ if (!(s->measure & MEASURE_RANGE))
+ setup_range(ctx, c, s->gain_min, s->gain_max);
+ if (!(s->measure & MEASURE_GAMMA))
+ setup_gamma(ctx, c, s->gamma);
+ }
+
+ return 0;
+}
+
+static int query_formats(const AVFilterContext *ctx,
+ AVFilterFormatsConfig **cfg_in,
+ AVFilterFormatsConfig **cfg_out)
+{
+ const GainMapContext *s = ctx->priv;
+ enum AVPixelFormat in_fmt, out_fmt;
+ int ret;
+
+ in_fmt = AV_PIX_FMT_GBRPF32;
+ out_fmt = s->nb_channels == 1 ? AV_PIX_FMT_GRAYF32 : AV_PIX_FMT_GBRPF32;
+
+ ret = ff_formats_ref(ff_make_formats_list_singleton(out_fmt), &cfg_out[0]->formats);
+ if (ret < 0)
+ return ret;
+
+ return ff_set_common_formats2(ctx, cfg_in, cfg_out,
+ ff_make_formats_list_singleton(in_fmt));
+}
+
+static double frame_luminance(const AVFrame *frame)
+{
+ const AVFrameSideData *sd;
+ sd = av_frame_get_side_data(frame, AV_FRAME_DATA_MASTERING_DISPLAY_METADATA);
+ if (sd) {
+ const AVMasteringDisplayMetadata *mdm;
+ mdm = (const AVMasteringDisplayMetadata *) sd->data;
+ if (mdm->has_luminance && mdm->max_luminance.num > 0)
+ return av_q2d(mdm->max_luminance);
+ }
+
+ switch (frame->color_trc) {
+ case AVCOL_TRC_SMPTE2084: return 10000.0;
+ case AVCOL_TRC_ARIB_STD_B67: return 1000.0;
+ default: return SDR_DIFFUSE_WHITE;
+ }
+}
+
+static void update_eotf(GainMapEOTF *tf, enum AVColorTransferCharacteristic trc,
+ av_csp_eotf_function eotf, double Lw)
+{
+ if (trc == tf->trc && Lw == tf->Lw)
+ return; /* no change */
+ tf->trc = trc;
+ tf->Lw = Lw;
+
+ switch (trc) {
+ /* EOTFs that are safe to collapse into a 1D LUT */
+ case AVCOL_TRC_BT709:
+ case AVCOL_TRC_GAMMA22:
+ case AVCOL_TRC_GAMMA28:
+ case AVCOL_TRC_SMPTE170M:
+ case AVCOL_TRC_SMPTE240M:
+ case AVCOL_TRC_LINEAR:
+ case AVCOL_TRC_IEC61966_2_4:
+ case AVCOL_TRC_BT1361_ECG:
+ case AVCOL_TRC_IEC61966_2_1:
+ case AVCOL_TRC_BT2020_10:
+ case AVCOL_TRC_BT2020_12:
+ case AVCOL_TRC_SMPTE2084: {
+ for (int i = 0; i < GAINMAP_LUT_SIZE; i++) {
+ double x[3] = { i / (GAINMAP_LUT_SIZE - 1.0) };
+ eotf(Lw, 0.0, x);
+ tf->lut[i] = x[0] / SDR_DIFFUSE_WHITE; /* normalize */
+ }
+
+ tf->lut[GAINMAP_LUT_SIZE] = tf->lut[GAINMAP_LUT_SIZE - 1];
+ tf->eotf = NULL;
+ break;
+ }
+ /* EOTFs that use the av_csp_eotf_function fallback */
+ case AVCOL_TRC_ARIB_STD_B67: /* nontrivial OOTF */
+ case AVCOL_TRC_SMPTE428: /* different normalization per channel */
+ default:
+ tf->eotf = eotf;
+ break;
+ }
+}
+
+static const char *unknown_if_null(const char *s)
+{
+ return s ? s : "unknown";
+}
+
+/* Run per frame, since trc/primaries are not (yet) link-level properties */
+static int setup_colorspace(AVFilterContext *ctx, const AVFrame *base, const AVFrame *alt)
+{
+ GainMapContext *const s = ctx->priv;
+ av_csp_eotf_function base_eotf = av_csp_itu_eotf(base->color_trc);
+ av_csp_eotf_function alt_eotf = av_csp_itu_eotf(alt->color_trc);
+ if (!base_eotf || !alt_eotf) {
+ av_log(ctx, AV_LOG_ERROR, "Unknown transfer: base=%s, alternate=%s\n",
+ unknown_if_null(av_color_transfer_name(base->color_trc)),
+ unknown_if_null(av_color_transfer_name(alt->color_trc)));
+ return AVERROR(EINVAL);
+ }
+
+ const AVColorPrimariesDesc *base_desc, *alt_desc;
+ base_desc = av_csp_primaries_desc_from_id(base->color_primaries);
+ alt_desc = av_csp_primaries_desc_from_id(alt->color_primaries);
+ if (!base_desc || !alt_desc) {
+ av_log(ctx, AV_LOG_ERROR, "Unknown primaries: base=%s, alternate=%s\n",
+ unknown_if_null(av_color_primaries_name(base->color_primaries)),
+ unknown_if_null(av_color_primaries_name(alt->color_primaries)));
+ return AVERROR(EINVAL);
+ }
+
+ double base_lw = s->base_nits.den ? av_q2d(s->base_nits) : frame_luminance(base);
+ double alt_lw = s->alt_nits.den ? av_q2d(s->alt_nits) : frame_luminance(alt);
+ double base_headroom = fmax(log2(base_lw / SDR_DIFFUSE_WHITE), 0.0);
+ double alt_headroom = fmax(log2(alt_lw / SDR_DIFFUSE_WHITE), 0.0);
+ s->params.base_hdr_headroom = quantq(base_headroom, AV_ROUND_NEAR_INF);
+ s->params.alternate_hdr_headroom = quantq(alt_headroom, AV_ROUND_NEAR_INF);
+ s->sign = FFDIFFSIGN(alt_headroom, base_headroom);
+ if (!s->sign) {
+ /* No-op, set up empty gain map */
+ for (int i = 0; i < s->nb_channels; i++) {
+ setup_range(ctx, i, (AVRational) { 0, 1 }, (AVRational) { 0, 1 });
+ setup_gamma(ctx, i, (AVRational) { 1, 1 });
+ }
+
+ av_log_once(ctx, AV_LOG_WARNING, AV_LOG_VERBOSE, &s->warned_noop,
+ "Base and alternate renditions have the same peak "
+ "luminance (%g nits), gain map will be empty\n", base_lw);
+ return 0;
+ } else {
+ av_log(ctx, AV_LOG_DEBUG, "Base peak: %g nits, alternate peak: %g nits\n",
+ base_lw, alt_lw);
+ }
+
+ update_eotf(&s->base_eotf, base->color_trc, base_eotf, base_lw);
+ update_eotf(&s->alt_eotf, alt->color_trc, alt_eotf, alt_lw);
+
+ /* Assume conversion from alt to base */
+ if (s->colorspace == GAINMAP_CSP_ALT)
+ FFSWAP(const AVColorPrimariesDesc *, base_desc, alt_desc);
+
+ double rgb2xyz[3][3];
+ ff_fill_rgb2xyz_table(&base_desc->prim, &base_desc->wp, rgb2xyz);
+ for (int i = 0; i < 3; i++)
+ s->rgb2y[i] = rgb2xyz[1][i] / av_q2d(base_desc->wp.y);
+
+ s->convert = base->color_primaries != alt->color_primaries;
+ if (s->convert) {
+ /* Note: Ignores whitepoint differences (chromatic adaptation) */
+ double xyz2rgb[3][3], rgb2rgb[3][3];
+ ff_fill_rgb2xyz_table(&base_desc->prim, &base_desc->wp, rgb2xyz);
+ ff_matrix_invert_3x3(rgb2xyz, xyz2rgb);
+ ff_fill_rgb2xyz_table(&alt_desc->prim, &alt_desc->wp, rgb2xyz);
+ ff_matrix_mul_3x3(rgb2rgb, rgb2xyz, xyz2rgb);
+ for (int i = 0; i < 3; i++)
+ for (int j = 0; j < 3; j++)
+ s->rgb2rgb[i][j] = (float) rgb2rgb[i][j];
+ } else {
+ memset(s->rgb2rgb, 0, sizeof(s->rgb2rgb));
+ for (int i = 0; i < 3; i++)
+ s->rgb2rgb[i][i] = 1.0f;
+ }
+
+ return 0;
+}
+
+static av_always_inline float quantize(float gain, float scale, float offset, float gamma)
+{
+ const float norm = av_clipf(scale * gain + offset, 0.0f, 1.0f);
+ return gamma == 1.0f ? norm : powf(norm, gamma);
+}
+
+static av_always_inline void
+get_pixel(const GainMapContext *s, float dst[3],
+ const float *restrict const src[3], int x, int alt)
+{
+ const GainMapEOTF *const tf = alt ? &s->alt_eotf : &s->base_eotf;
+ float rgb[3];
+
+ /* Linearize to normalized RGB */
+ if (tf->eotf) {
+ double rgbd[3] = { src[0][x], src[1][x], src[2][x] };
+ tf->eotf(tf->Lw, 0.0, rgbd);
+ for (int i = 0; i < 3; i++)
+ rgb[i] = rgbd[i] / SDR_DIFFUSE_WHITE;
+ } else {
+ for (int i = 0; i < 3; i++) {
+ const float fx = av_clipf(src[i][x], 0.0f, 1.0f) * (GAINMAP_LUT_SIZE - 1.0);
+ const int ix = (int) fx;
+ const float lo = tf->lut[ix];
+ const float hi = tf->lut[ix + 1];
+ rgb[i] = lo + (fx - ix) * (hi - lo);
+ }
+ }
+
+ /* Convert to correct colorspace if needed */
+ if (s->convert && (s->colorspace == GAINMAP_CSP_ALT) != alt) {
+ const float r = rgb[0], g = rgb[1], b = rgb[2];
+ for (int i = 0; i < 3; i++) {
+ dst[i] = fmaxf(r * s->rgb2rgb[i][0] +
+ g * s->rgb2rgb[i][1] +
+ b * s->rgb2rgb[i][2], 0.0f);
+ }
+ } else {
+ for (int i = 0; i < 3; i++)
+ dst[i] = fmaxf(rgb[i], 0.0f);
+ }
+}
+
+/* GBRP plane order */
+static const int gbr_order[3] = { 2, 0, 1 };
+
+static av_always_inline int
+slice_internal(AVFilterContext *ctx, void *arg, int jobnr, int nb_jobs, int quant)
+{
+ GainMapContext *s = ctx->priv;
+ const ThreadData *td = arg;
+ const AVFrame *base = td->base, *alt = td->alt, *out = td->out;
+
+ const float *restrict scale = s->quant_scale;
+ const float *restrict offset = s->quant_offset;
+ const float *restrict gamma = s->quant_gamma;
+
+ /* Normalize both to the base colorspace */
+ const float base_off = av_q2d(s->base_offset);
+ const float alt_off = av_q2d(s->alt_offset);
+ const float sign = s->sign;
+
+ const int y_start = ff_slice_pos(out->height, jobnr, nb_jobs);
+ const int y_end = ff_slice_pos(out->height, jobnr + 1, nb_jobs);
+ const int width = out->width;
+ const int mode = s->mode;
+ const int nb_ch = s->nb_channels;
+
+ float min[3] = { FLT_MAX, FLT_MAX, FLT_MAX };
+ float max[3] = { -FLT_MAX, -FLT_MAX, -FLT_MAX };
+ double sum[3] = { 0.0, 0.0, 0.0 };
+
+ for (int y = y_start; y < y_end; y++) {
+ const float *restrict b_row[3], *restrict a_row[3];
+ for (int i = 0; i < 3; i++) {
+ const int p = gbr_order[i];
+ b_row[i] = (const float *) (base->data[p] + y * base->linesize[p]);
+ a_row[i] = (const float *) ( alt->data[p] + y * alt->linesize[p]);
+ }
+
+ float *restrict out_row[3];
+ for (int i = 0; i < nb_ch; i++) {
+ const int p = nb_ch == 1 ? 0 : gbr_order[i];
+ out_row[i] = (float *) (out->data[p] + y * out->linesize[p]);
+ }
+
+ for (int x = 0; x < width; x++) {
+ float b[3], a[3], gain[3];
+ get_pixel(s, b, b_row, x, 0);
+ get_pixel(s, a, a_row, x, 1);
+
+ #define GAIN(a, b) (sign * log2f(((a) + alt_off) / ((b) + base_off)))
+ switch (mode) {
+ case GAINMAP_MAXRGB: {
+ const float max_a = fmaxf(fmaxf(a[0], a[1]), a[2]);
+ const float max_b = fmaxf(fmaxf(b[0], b[1]), b[2]);
+ gain[0] = GAIN(max_a, max_b);
+ break;
+ }
+ case GAINMAP_LUMA: {
+ const float y_a = s->rgb2y[0] * a[0] + s->rgb2y[1] * a[1] + s->rgb2y[2] * a[2];
+ const float y_b = s->rgb2y[0] * b[0] + s->rgb2y[1] * b[1] + s->rgb2y[2] * b[2];
+ gain[0] = GAIN(y_a, y_b);
+ break;
+ }
+ case GAINMAP_RGB:
+ gain[0] = GAIN(a[0], b[0]);
+ gain[1] = GAIN(a[1], b[1]);
+ gain[2] = GAIN(a[2], b[2]);
+ break;
+ }
+ #undef GAIN
+
+ if (quant) {
+ for (int i = 0; i < nb_ch; i++)
+ out_row[i][x] = quantize(gain[i], scale[i], offset[i], gamma[i]);
+ } else {
+ for (int i = 0; i < nb_ch; i++) {
+ out_row[i][x] = gain[i];
+ min[i] = fminf(min[i], gain[i]);
+ max[i] = fmaxf(max[i], gain[i]);
+ sum[i] += gain[i];
+ }
+ }
+
+ }
+ }
+
+ for (int i = 0; !quant && i < 3; i++) {
+ s->slice_min[jobnr][i] = min[i];
+ s->slice_max[jobnr][i] = max[i];
+ s->slice_sum[jobnr][i] = sum[i];
+ }
+
+ return 0;
+}
+
+static int slice_gain(AVFilterContext *ctx, void *arg, int jobnr, int nb_jobs)
+{
+ return slice_internal(ctx, arg, jobnr, nb_jobs, 0);
+}
+
+static int slice_gain_quant(AVFilterContext *ctx, void *arg, int jobnr, int nb_jobs)
+{
+ return slice_internal(ctx, arg, jobnr, nb_jobs, 1);
+}
+
+static int slice_quant(AVFilterContext *ctx, void *arg, int jobnr, int nb_jobs)
+{
+ const ThreadData *td = arg;
+ const GainMapContext *s = ctx->priv;
+ const AVFrame *out = td->out;
+
+ const float *restrict scale = s->quant_scale;
+ const float *restrict offset = s->quant_offset;
+ const float *restrict gamma = s->quant_gamma;
+
+ const int y_start = ff_slice_pos(out->height, jobnr, nb_jobs);
+ const int y_end = ff_slice_pos(out->height, jobnr + 1, nb_jobs);
+ const int width = out->width;
+ const int nb_ch = s->nb_channels;
+
+ for (int y = y_start; y < y_end; y++) {
+ float *restrict out_row[3];
+ for (int i = 0; i < nb_ch; i++) {
+ const int p = nb_ch == 1 ? 0 : gbr_order[i];
+ out_row[i] = (float *) (out->data[p] + y * out->linesize[p]);
+ }
+
+ for (int x = 0; x < width; x++) {
+ for (int i = 0; i < nb_ch; i++)
+ out_row[i][x] = quantize(out_row[i][x], scale[i], offset[i], gamma[i]);
+ }
+ }
+
+ return 0;
+}
+
+static void choose_quant_params(AVFilterContext *ctx, const AVFrame *out, int nb_jobs)
+{
+ GainMapContext *s = ctx->priv;
+ float frame_min[3] = { FLT_MAX, FLT_MAX, FLT_MAX };
+ float frame_max[3] = { -FLT_MAX, -FLT_MAX, -FLT_MAX };
+ double frame_sum[3] = { 0.0, 0.0, 0.0 };
+
+ for (int j = 0; j < nb_jobs; j++) {
+ for (int i = 0; i < s->nb_channels; i++) {
+ frame_min[i] = fminf(frame_min[i], s->slice_min[j][i]);
+ frame_max[i] = fmaxf(frame_max[i], s->slice_max[j][i]);
+ frame_sum[i] += s->slice_sum[j][i];
+ }
+ }
+
+ for (int i = 0; i < s->nb_channels; i++) {
+ AVRational min = s->gain_min, max = s->gain_max;
+ if (s->measure & MEASURE_MIN)
+ min = quantq(frame_min[i], AV_ROUND_DOWN);
+ if (s->measure & MEASURE_MAX)
+ max = quantq(frame_max[i], AV_ROUND_UP);
+ if (s->measure & MEASURE_RANGE)
+ setup_range(ctx, i, min, max);
+
+ if (!(s->measure & MEASURE_GAMMA))
+ continue;
+
+ const int64_t nb_pixels = (int64_t) out->width * out->height;
+ const double mean = frame_sum[i] / nb_pixels;
+ const double mean_quant = s->quant_scale[i] * mean + s->quant_offset[i];
+
+ /**
+ * Choose gamma such that mean_quant ^ gamma = 0.5; clamp to a sane
+ * value range of [1/8, 8] to prevent degenerate encodings. A gamma
+ * of >8 would collapse half the encoding space into a single 8-bit
+ * value, at which point we're losing more then gaining, and a value
+ * of <1/8 limits the amount by which a single high outlier pixel
+ * can determine the overall encoding gamma.
+ *
+ * We also clamp the mean to [0.01, 0.99] to prevent numerical explosion
+ * in the case that the mean is very close to 0 or 1. This is a looser
+ * bound than the final gamma clamp, so the exact values chosen do not
+ * matter as much.
+ */
+ double gamma = log(0.5) / log(av_clipd(mean_quant, 0.01, 0.99));
+ setup_gamma(ctx, i, quantq(av_clipd(gamma, 1/8.0, 8.0), AV_ROUND_NEAR_INF));
+ }
+}
+
+static int process_frame(FFFrameSync *fs)
+{
+ AVFilterContext *ctx = fs->parent;
+ GainMapContext *s = ctx->priv;
+ AVFilterLink *outlink = ctx->outputs[0];
+ AVFrame *base, *alt, *out;
+
+ int ret = ff_framesync_dualinput_get(fs, &base, &alt);
+ if (ret < 0)
+ return ret;
+ if (!base || !alt)
+ return AVERROR_BUG;
+
+ ret = setup_colorspace(ctx, base, alt);
+ if (ret < 0)
+ return ret;
+
+ out = ff_get_video_buffer(outlink, outlink->w, outlink->h);
+ if (!out)
+ return AVERROR(ENOMEM);
+ av_frame_copy_props(out, base);
+ av_frame_side_data_remove_by_props(&out->side_data, &out->nb_side_data,
+ AV_SIDE_DATA_PROP_COLOR_DEPENDENT);
+
+ out->color_trc = AVCOL_TRC_UNSPECIFIED;
+ out->color_primaries = AVCOL_PRI_UNSPECIFIED;
+ out->colorspace = AVCOL_SPC_UNSPECIFIED;
+ out->color_range = AVCOL_RANGE_JPEG;
+
+ ThreadData td = {
+ .out = out,
+ .base = base,
+ .alt = alt,
+ };
+
+ if (!s->sign) { /* no-op */
+ for (int i = 0; i < s->nb_channels; i++)
+ memset(out->data[i], 0, out->height * out->linesize[i]);
+ goto skip;
+ }
+
+ const int nb_jobs = FFMIN(outlink->h, s->nb_threads);
+ ret = ff_filter_execute(ctx, s->measure ? slice_gain : slice_gain_quant,
+ &td, NULL, nb_jobs);
+ if (ret < 0)
+ goto fail;
+
+ if (s->measure) {
+ choose_quant_params(ctx, out, nb_jobs);
+ ret = ff_filter_execute(ctx, slice_quant, &td, NULL, nb_jobs);
+ if (ret < 0)
+ goto fail;
+ }
+
+skip:;
+ AVGainMapParams *params;
+ params = av_gain_map_params_create_side_data(&out->side_data, &out->nb_side_data);
+ if (!params) {
+ ret = AVERROR(ENOMEM);
+ goto fail;
+ }
+
+ *params = s->params;
+ if (av_gain_map_params_validate(params) < 0)
+ return AVERROR_BUG; /* should never happen */
+
+ return ff_filter_frame(outlink, out);
+
+fail:
+ av_frame_free(&out);
+ return ret;
+}
+
+static int config_output(AVFilterLink *outlink)
+{
+ AVFilterContext *ctx = outlink->src;
+ GainMapContext *s = ctx->priv;
+ AVFilterLink *base = ctx->inputs[0];
+ AVFilterLink *alt = ctx->inputs[1];
+ int ret;
+
+ if (base->w != alt->w || base->h != alt->h) {
+ av_log(ctx, AV_LOG_ERROR,
+ "Input dimensions must match (%dx%d != %dx%d)\n",
+ base->w, base->h, alt->w, alt->h);
+ return AVERROR(EINVAL);
+ }
+
+ outlink->w = base->w;
+ outlink->h = base->h;
+ outlink->colorspace = AVCOL_SPC_UNSPECIFIED;
+ outlink->color_range = AVCOL_RANGE_JPEG;
+
+ s->nb_threads = ff_filter_get_nb_threads(ctx);
+ s->slice_min = av_calloc(s->nb_threads, sizeof(*s->slice_min));
+ s->slice_max = av_calloc(s->nb_threads, sizeof(*s->slice_max));
+ s->slice_sum = av_calloc(s->nb_threads, sizeof(*s->slice_sum));
+ if (!s->slice_min || !s->slice_max || !s->slice_sum)
+ return AVERROR(ENOMEM);
+
+ ret = ff_framesync_init_dualinput(&s->fs, ctx);
+ if (ret < 0)
+ return ret;
+
+ s->fs.on_event = process_frame;
+ return ff_framesync_configure(&s->fs);
+}
+
+static int activate(AVFilterContext *ctx)
+{
+ GainMapContext *s = ctx->priv;
+ return ff_framesync_activate(&s->fs);
+}
+
+static av_cold void uninit(AVFilterContext *ctx)
+{
+ GainMapContext *s = ctx->priv;
+ ff_framesync_uninit(&s->fs);
+ av_freep(&s->slice_min);
+ av_freep(&s->slice_max);
+ av_freep(&s->slice_sum);
+}
+
+static const AVFilterPad gainmap_inputs[] = {
+ {
+ .name = "base",
+ .type = AVMEDIA_TYPE_VIDEO,
+ },
+ {
+ .name = "alternate",
+ .type = AVMEDIA_TYPE_VIDEO,
+ },
+};
+
+static const AVFilterPad gainmap_outputs[] = {
+ {
+ .name = "default",
+ .type = AVMEDIA_TYPE_VIDEO,
+ .config_props = config_output,
+ },
+};
+
+const FFFilter ff_vf_gainmap = {
+ .p.name = "gainmap",
+ .p.description = NULL_IF_CONFIG_SMALL("Generate a gain map from a base/alternate pair."),
+ .p.priv_class = &gainmap_class,
+ .p.flags = AVFILTER_FLAG_SLICE_THREADS,
+ .preinit = gainmap_framesync_preinit,
+ .priv_size = sizeof(GainMapContext),
+ .init = init,
+ .uninit = uninit,
+ .activate = activate,
+ FILTER_INPUTS(gainmap_inputs),
+ FILTER_OUTPUTS(gainmap_outputs),
+ FILTER_QUERY_FUNC2(query_formats),
+};
diff --git a/tests/fate/filter-video.mak b/tests/fate/filter-video.mak
index f813d72106..5f0387b527 100644
--- a/tests/fate/filter-video.mak
+++ b/tests/fate/filter-video.mak
@@ -224,6 +224,14 @@ $(FATE_FILTER_FRAMEPACK): CMD = framecrc -c:v pgmyuv -i $(TARGET_PATH)/tests/vsy
FATE_FILTER_VSYNTH_PGMYUV-$(CONFIG_FRAMEPACK_FILTER) += $(FATE_FILTER_FRAMEPACK)
fate-filter-framepack: $(FATE_FILTER_FRAMEPACK)
+FATE_FILTER_GAINMAP_SRC = testsrc2=s=64x48:r=1:d=1,scale,format=gbrpf32,split[b][a];[b]setparams=color_trc=iec61966-2-1:color_primaries=bt709[base];[a]setparams=color_trc=smpte2084:color_primaries=bt2020[alt];[base][alt]
+
+FATE_FILTER-$(call FILTERFRAMECRC, GAINMAP TESTSRC2 FORMAT SETPARAMS SPLIT, SCALE_FILTER) += fate-filter-gainmap fate-filter-gainmap-luma fate-filter-gainmap-maxrgb fate-filter-gainmap-fixed
+fate-filter-gainmap: CMD = framecrc -lavfi "$(FATE_FILTER_GAINMAP_SRC)gainmap,scale" -pix_fmt gbrp
+fate-filter-gainmap-luma: CMD = framecrc -lavfi "$(FATE_FILTER_GAINMAP_SRC)gainmap=mode=luma,scale" -pix_fmt gray
+fate-filter-gainmap-maxrgb: CMD = framecrc -lavfi "$(FATE_FILTER_GAINMAP_SRC)gainmap=mode=maxrgb,scale" -pix_fmt gray
+fate-filter-gainmap-fixed: CMD = framecrc -lavfi "$(FATE_FILTER_GAINMAP_SRC)gainmap=min=0:max=2.3:gamma=1:base_nits=203:alt_nits=1000,scale" -pix_fmt gbrp
+
FATE_FILTER_VSYNTH_PGMYUV-$(CONFIG_GRADFUN_FILTER) += fate-filter-gradfun
fate-filter-gradfun: CMD = framecrc -c:v pgmyuv -i $(SRC) -vf gradfun
diff --git a/tests/ref/fate/filter-gainmap b/tests/ref/fate/filter-gainmap
new file mode 100644
index 0000000000..96d5904207
--- /dev/null
+++ b/tests/ref/fate/filter-gainmap
@@ -0,0 +1,6 @@
+#tb 0: 1/1
+#media_type 0: video
+#codec_id 0: rawvideo
+#dimensions 0: 64x48
+#sar 0: 1/1
+0, 0, 0, 1, 9216, 0x91dedb2f
diff --git a/tests/ref/fate/filter-gainmap-fixed b/tests/ref/fate/filter-gainmap-fixed
new file mode 100644
index 0000000000..93b721a20e
--- /dev/null
+++ b/tests/ref/fate/filter-gainmap-fixed
@@ -0,0 +1,6 @@
+#tb 0: 1/1
+#media_type 0: video
+#codec_id 0: rawvideo
+#dimensions 0: 64x48
+#sar 0: 1/1
+0, 0, 0, 1, 9216, 0x28fde0dc
diff --git a/tests/ref/fate/filter-gainmap-luma b/tests/ref/fate/filter-gainmap-luma
new file mode 100644
index 0000000000..ea6d074487
--- /dev/null
+++ b/tests/ref/fate/filter-gainmap-luma
@@ -0,0 +1,6 @@
+#tb 0: 1/1
+#media_type 0: video
+#codec_id 0: rawvideo
+#dimensions 0: 64x48
+#sar 0: 1/1
+0, 0, 0, 1, 3072, 0xbfac1585
diff --git a/tests/ref/fate/filter-gainmap-maxrgb b/tests/ref/fate/filter-gainmap-maxrgb
new file mode 100644
index 0000000000..1d52102004
--- /dev/null
+++ b/tests/ref/fate/filter-gainmap-maxrgb
@@ -0,0 +1,6 @@
+#tb 0: 1/1
+#media_type 0: video
+#codec_id 0: rawvideo
+#dimensions 0: 64x48
+#sar 0: 1/1
+0, 0, 0, 1, 3072, 0x2253f63c