Ctrl K
ComunidadGuías…

src/iop/colorequal.c

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
    This file is part of darktable,
    Copyright (C) 2022-2026 darktable developers.

    darktable is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    darktable 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 General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with darktable.  If not, see <http://www.gnu.org/licenses/>.
*/
#include "common/gdk_event_utils.h"

/* Midi mapping is supported, here is the reference for loupedeck+
midi:D7=iop/colorequal/page;hue
midi:D#7=iop/colorequal/page
midi:E7=iop/colorequal/page;brightness
None;midi:CC1=iop/colorequal/hue/red
None;midi:CC2=iop/colorequal/hue/orange
None;midi:CC3=iop/colorequal/hue/yellow
None;midi:CC4=iop/colorequal/hue/green
None;midi:CC5=iop/colorequal/hue/cyan
None;midi:CC6=iop/colorequal/hue/blue
None;midi:CC7=iop/colorequal/hue/lavender
None;midi:CC8=iop/colorequal/hue/magenta
None;midi:CC9=iop/colorequal/saturation/red
None;midi:CC10=iop/colorequal/saturation/orange
None;midi:CC11=iop/colorequal/saturation/yellow
None;midi:CC12=iop/colorequal/saturation/green
None;midi:CC13=iop/colorequal/saturation/cyan
None;midi:CC14=iop/colorequal/saturation/blue
None;midi:CC15=iop/colorequal/saturation/lavender
None;midi:CC16=iop/colorequal/saturation/magenta
None;midi:CC17=iop/colorequal/brightness/red
None;midi:CC18=iop/colorequal/brightness/orange
None;midi:CC19=iop/colorequal/brightness/yellow
None;midi:CC20=iop/colorequal/brightness/green
None;midi:CC21=iop/colorequal/brightness/cyan
None;midi:CC22=iop/colorequal/brightness/blue
None;midi:CC23=iop/colorequal/brightness/lavender
None;midi:CC24=iop/colorequal/brightness/magenta
*/

//#include "common/extra_optimizations.h" // results in crashes on some systems

#include <math.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <time.h>

#include "bauhaus/bauhaus.h"
#include "common/chromatic_adaptation.h"
#include "common/darktable_ucs_22_helpers.h"
#include "common/darktable.h"
#include "common/bilinear.h"
#include "common/eigf.h"
#include "common/interpolation.h"
#include "common/gaussian.h"
#include "common/opencl.h"
#include "common/color_picker.h"
#include "control/conf.h"
#include "control/control.h"
#include "develop/blend.h"
#include "develop/develop.h"
#include "develop/imageop.h"
#include "develop/imageop_math.h"
#include "develop/imageop_gui.h"
#include "develop/preview_data.h"
#include "develop/tiling.h"
#include "dtgtk/drawingarea.h"
#include "gui/accelerators.h"
#include "gui/color_picker_proxy.h"
#include "gui/draw.h"
#include "gui/gtk.h"
#include "gui/presets.h"
#include "gui/color_picker_proxy.h"
#include "iop/iop_api.h"
#include "iop/choleski.h"
#include "common/colorspaces_inline_conversions.h"

#ifdef _OPENMP
#include <omp.h>
#endif

#define NODES 8
#define SATSIZE 4096
#define SLIDER_BRIGHTNESS 0.65f // 65 %

#define SAT_EFFECT 2.0f
#define BRIGHT_EFFECT 8.0f

DT_MODULE_INTROSPECTION(4, dt_iop_colorequal_params_t)

typedef struct dt_iop_colorequal_params_t
{
  float threshold;          // $MIN: 0.0 $MAX: 0.3 $DEFAULT: 0.1 $DESCRIPTION: "saturation threshold"
  float smoothing_hue;      // $MIN: 0.05 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "hue curve"
  float contrast;           // $MIN: -1.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "contrast"

  float white_level;        // $MIN: -2.0 $MAX: 16.0 $DEFAULT: 1.0 $DESCRIPTION: "white level"
  float chroma_size;        // $MIN: 1.0 $MAX: 10.0 $DEFAULT: 1.5 $DESCRIPTION: "hue analysis radius"
  float param_size;         // $MIN: 1.0 $MAX: 128. $DEFAULT: 1.0 $DESCRIPTION: "effect radius"
  gboolean use_filter;      // $DEFAULT: TRUE $DESCRIPTION: "use guided filter"

  // Note: what follows is tedious because each param needs to be declared separately.
  // A more efficient way would be to use 3 arrays of 8 elements,
  // but then GUI sliders would need to be wired manually to the correct array index.
  // So we do it the tedious way here, and let the introspection magic connect sliders to params automatically,
  // then we pack the params in arrays in commit_params().

  float sat_red;         // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "red"
  float sat_orange;      // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "orange"
  float sat_yellow;      // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "yellow"
  float sat_green;       // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "green"
  float sat_cyan;        // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "cyan"
  float sat_blue;        // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "blue"
  float sat_lavender;    // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "lavender"
  float sat_magenta;     // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "magenta"

  float hue_red;         // $MIN: -180. $MAX: 180. $DEFAULT: 0.0 $DESCRIPTION: "red"
  float hue_orange;      // $MIN: -180. $MAX: 180. $DEFAULT: 0.0 $DESCRIPTION: "orange"
  float hue_yellow;      // $MIN: -180. $MAX: 180. $DEFAULT: 0.0 $DESCRIPTION: "yellow"
  float hue_green;       // $MIN: -180. $MAX: 180. $DEFAULT: 0.0 $DESCRIPTION: "green"
  float hue_cyan;        // $MIN: -180. $MAX: 180. $DEFAULT: 0.0 $DESCRIPTION: "cyan"
  float hue_blue;        // $MIN: -180. $MAX: 180. $DEFAULT: 0.0 $DESCRIPTION: "blue"
  float hue_lavender;    // $MIN: -180. $MAX: 180. $DEFAULT: 0.0 $DESCRIPTION: "lavender"
  float hue_magenta;     // $MIN: -180. $MAX: 180. $DEFAULT: 0.0 $DESCRIPTION: "magenta"

  float bright_red;      // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "red"
  float bright_orange;   // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "orange"
  float bright_yellow;   // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "yellow"
  float bright_green;    // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "green"
  float bright_cyan;     // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "cyan"
  float bright_blue;     // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "blue"
  float bright_lavender; // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "lavender"
  float bright_magenta;  // $MIN: 0. $MAX: 2. $DEFAULT: 1.0 $DESCRIPTION: "magenta"

  float hue_shift;       // $MIN: -23. $MAX: 23. $DEFAULT: 0.0 $DESCRIPTION: "node placement"
} dt_iop_colorequal_params_t;

typedef enum dt_iop_colorequal_channel_t
{
  HUE = 0,
  SATURATION = 1,
  BRIGHTNESS = 2,
  NUM_CHANNELS = 3,
  GRAD_SWITCH = 4,
  SATURATION_GRAD = SATURATION + GRAD_SWITCH,
  BRIGHTNESS_GRAD = BRIGHTNESS + GRAD_SWITCH
} dt_iop_colorequal_channel_t;


typedef struct dt_iop_colorequal_data_t
{
  float *LUT_saturation;
  float *LUT_hue;
  float *LUT_brightness;
  float *gamut_LUT;
  gboolean lut_inited;
  float white_level;
  float chroma_size;
  float chroma_feathering;
  float param_size;
  float param_feathering;
  gboolean use_filter;
  dt_iop_order_iccprofile_info_t *work_profile;
  float hue_shift;
  float threshold;
  float max_brightness;
  float contrast;
} dt_iop_colorequal_data_t;

typedef struct dt_iop_colorequal_global_data_t
{
  int ce_init_covariance;
  int ce_finish_covariance;
  int ce_prepare_prefilter;
  int ce_apply_prefilter;
  int ce_prepare_correlations;
  int ce_finish_correlations;
  int ce_final_guide;
  int ce_apply_guided;
  int ce_sample_input;
  int ce_process_data;
  int ce_write_output;
  int ce_write_visual;
  int ce_draw_weight;
} dt_iop_colorequal_global_data_t;


const char *name()
{
  return _("color equalizer");
}

const char *aliases()
{
  return _("color zones|hsl");
}

const char **description(dt_iop_module_t *self)
{
  return dt_iop_set_description
    (self,
     _("change saturation, hue and brightness\n"
       "depending on local hue"),
     _("corrective and creative"),
     _("linear, RGB, scene-referred"),
     _("quasi-linear, RGB"),
     _("quasi-linear, RGB, scene-referred"));
}

int default_group()
{
  return IOP_GROUP_COLOR;
}

int flags()
{
  return IOP_FLAGS_ALLOW_TILING | IOP_FLAGS_INCLUDE_IN_STYLES | IOP_FLAGS_SUPPORTS_BLENDING;
}

dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self,
                                            dt_dev_pixelpipe_t *pipe,
                                            dt_dev_pixelpipe_iop_t *piece)
{
  return IOP_CS_RGB;
}

typedef struct dt_iop_colorequal_gui_data_t
{
  GtkWidget *white_level;
  GtkWidget *sat_red, *sat_orange, *sat_yellow, *sat_green;
  GtkWidget *sat_cyan, *sat_blue, *sat_lavender, *sat_magenta;
  GtkWidget *hue_red, *hue_orange, *hue_yellow, *hue_green;
  GtkWidget *hue_cyan, *hue_blue, *hue_lavender, *hue_magenta;
  GtkWidget *bright_red, *bright_orange, *bright_yellow, *bright_green;
  GtkWidget *bright_cyan, *bright_blue, *bright_lavender, *bright_magenta;

  GtkWidget *smoothing_hue, *threshold, *contrast;
  GtkWidget *chroma_size, *param_size, *use_filter;

  GtkWidget *hue_shift;

  // Array-like re-indexing of the above for efficient uniform
  // handling in loops. Populate the array in gui_init()
  GtkWidget *sat_sliders[NODES];
  GtkWidget *hue_sliders[NODES];
  GtkWidget *bright_sliders[NODES];
  int page_num;

  GtkNotebook *notebook;
  GtkDrawingArea *area;
  GtkStack *stack;
  dt_gui_collapsible_section_t cs;
  dt_gui_collapsible_section_t cs_sliders;
  float *LUT;
  dt_iop_colorequal_channel_t channel;

  dt_iop_order_iccprofile_info_t *work_profile;
  dt_iop_order_iccprofile_info_t *white_adapted_profile;

  unsigned char *b_data[NUM_CHANNELS];
  cairo_surface_t *b_surface[NUM_CHANNELS];

  float graph_height;
  float max_saturation;
  gboolean gradients_cached;

  float *gamut_LUT;

  int mask_mode;
  gboolean dragging;
  gboolean on_node;
  int selected;
  float points[NODES+1][2];

  // Hue read under mouse cursor (degrees, GUI space 0..360)
  float cursor_hue;
  // TRUE if the last hue reading is usable (picker active, sufficient chroma)
  gboolean cursor_valid;

  // Cursor position in preview image coordinates (for gui_post_expose)
  float cursor_pos_x;
  float cursor_pos_y;

  // TRUE once a preview reprocess has been requested to (re)fill pd and
  // no fresh data has been observed since. Prevents flooding the pipeline
  // with redundant reprocess requests while hovering with a stale/missing
  // buffer (see mouse_moved()/gui_focus()).
  gboolean reprocess_pending;

  // Last cursor position within the graph/histogram widget, in widget
  // pixel coordinates, and whether it is currently valid. Independent of
  // cursor_pos_x/y (which track the mouse over the main image) so that
  // scrolling on the graph acts on the node actually under the cursor
  // there, not on the last hue seen while hovering the image.
  float graph_cursor_x;
  gboolean graph_cursor_valid;

  // Shared preview pipe under-cursor data (buffer + freshness hash),
  // filled by process() for the preview pipe (CPU and OpenCL paths).
  // Enables direct reading of the UCS hue (radians) under the cursor
  // without depending on the GTK color picker (asynchronous).
  dt_preview_data_t pd;
} dt_iop_colorequal_gui_data_t;

void init_global(dt_iop_module_so_t *self)
{
  const int program = 37; // colorequal.cl, from programs.conf

  dt_iop_colorequal_global_data_t *gd = malloc(sizeof(dt_iop_colorequal_global_data_t));
  self->data = gd;

  gd->ce_init_covariance = dt_opencl_create_kernel(program, "init_covariance");
  gd->ce_finish_covariance = dt_opencl_create_kernel(program, "finish_covariance");
  gd->ce_prepare_prefilter = dt_opencl_create_kernel(program, "prepare_prefilter");
  gd->ce_apply_prefilter = dt_opencl_create_kernel(program, "apply_prefilter");
  gd->ce_prepare_correlations = dt_opencl_create_kernel(program, "prepare_correlations");
  gd->ce_finish_correlations = dt_opencl_create_kernel(program, "finish_correlations");
  gd->ce_final_guide = dt_opencl_create_kernel(program, "final_guide");
  gd->ce_apply_guided = dt_opencl_create_kernel(program, "apply_guided");
  gd->ce_sample_input = dt_opencl_create_kernel(program, "sample_input");
  gd->ce_process_data = dt_opencl_create_kernel(program, "process_data");
  gd->ce_write_output = dt_opencl_create_kernel(program, "write_output");
  gd->ce_write_visual = dt_opencl_create_kernel(program, "write_visual");
  gd->ce_draw_weight = dt_opencl_create_kernel(program, "draw_weight");
}

void cleanup_global(dt_iop_module_so_t *self)
{
  const dt_iop_colorequal_global_data_t *gd = self->data;
  dt_opencl_free_kernel(gd->ce_init_covariance);
  dt_opencl_free_kernel(gd->ce_finish_covariance);
  dt_opencl_free_kernel(gd->ce_prepare_prefilter);
  dt_opencl_free_kernel(gd->ce_apply_prefilter);
  dt_opencl_free_kernel(gd->ce_prepare_correlations);
  dt_opencl_free_kernel(gd->ce_finish_correlations);
  dt_opencl_free_kernel(gd->ce_final_guide);
  dt_opencl_free_kernel(gd->ce_apply_guided);
  dt_opencl_free_kernel(gd->ce_sample_input);
  dt_opencl_free_kernel(gd->ce_process_data);
  dt_opencl_free_kernel(gd->ce_write_output);
  dt_opencl_free_kernel(gd->ce_write_visual);
  dt_opencl_free_kernel(gd->ce_draw_weight);

  free(self->data);
  self->data = NULL;
}


static inline float _get_scaling(const float sigma)
{
  return MAX(1.0f, MIN(4.0f, floorf(sigma - 1.5f)));
}

void tiling_callback(dt_iop_module_t *self,
                     dt_dev_pixelpipe_iop_t *piece,
                     const dt_iop_roi_t *roi_in,
                     const dt_iop_roi_t *roi_out,
                     dt_develop_tiling_t *tiling)
{
  const dt_iop_colorequal_data_t *data = piece->data;

  tiling->maxbuf = 1.0f;
  tiling->align = 1;
  tiling->overhead = (2 * SATSIZE + 4 * LUT_ELEM) * sizeof(float);
  const int maxradius = MAX(data->chroma_size, data->param_size);
  tiling->overlap = 16 + maxradius; // safe feathering

  tiling->factor = 4.5f;  // in/out buffers plus mainloop incl gaussian
  if(data->use_filter)
  {
    // calculate relative size of downsampled buffers
    const float sigma = (float)maxradius * MAX(0.5f, roi_in->scale / piece->iscale);
    const float scaling = _get_scaling(sigma);
    tiling->factor += scaling == 1.0f
                      ? 3.0f
                      : (1.0f + 4.0f / sqrf(scaling));
  }
}


int legacy_params(dt_iop_module_t *self,
                  const void *const old_params,
                  const int old_version,
                  void **new_params,
                  int32_t *new_params_size,
                  int *new_version)
{
  if(old_version == 1)
  {
    const dt_iop_colorequal_params_t *o = old_params;
    dt_iop_colorequal_params_t *n = malloc(sizeof(dt_iop_colorequal_params_t));

    memcpy(n, o, sizeof(dt_iop_colorequal_params_t) - sizeof(float));
    n->hue_shift = 0.0f;
    *new_params = n;
    *new_params_size = sizeof(dt_iop_colorequal_params_t);
    *new_version = 2;
    return 0;
  }

  if(old_version == 2)
  {
    const dt_iop_colorequal_params_t *o = old_params;
    dt_iop_colorequal_params_t *n = malloc(sizeof(dt_iop_colorequal_params_t));

    memcpy(n, o, sizeof(dt_iop_colorequal_params_t) - sizeof(float));
    n->threshold = 0.024f;  // in v1/2 we had an inflection point of 0.1

    // brightness and saturation slider ranges have been expanded by 4:3 so we correct here
    const float *sodata = &o->sat_red;
    const float *bodata = &o->bright_red;
    float *sndata = &n->sat_red;
    float *bndata = &n->bright_red;
    for(int i = 0; i < NODES; i++)
    {
      sndata[i] = 1.0f + 0.75f * (sodata[i] - 1.0f);
      bndata[i] = 1.0f + 0.75f * (bodata[i] - 1.0f);
    }

    *new_params = n;
    *new_params_size = sizeof(dt_iop_colorequal_params_t);
    *new_version = 3;
    return 0;
  }

  if(old_version == 3)
  {
    const dt_iop_colorequal_params_t *o = old_params;
    dt_iop_colorequal_params_t *n = malloc(sizeof(dt_iop_colorequal_params_t));

    memcpy(n, o, sizeof(dt_iop_colorequal_params_t) - sizeof(float));
    n->threshold = o->threshold + 0.1f;
    n->contrast = -5.0f * MAX(0.0f, o->threshold - 0.024f); // sort of magic from what we had

    *new_params = n;
    *new_params_size = sizeof(dt_iop_colorequal_params_t);
    *new_version = 4;
    return 0;
  }

  return 1;
}

// dt_gaussian_mean_blur() in common/gaussian.h replaces the former local dt_gaussian_mean_blur().


// sRGB primary red records at 20° of hue in darktable UCS 22, so we offset the whole hue range
// such that red is the origin hues in the GUI. This is consistent with HSV/HSL color wheels UI.
#define ANGLE_SHIFT +20.f
static inline float _conventional_hue_deg_to_ucs_rad(const float angle)
{
  return deg2radf(angle + ANGLE_SHIFT);
}

/* We use precalculated data for the logistic weighting function for performance and stability
   and do linear interpolation at runtime. Avoids banding effects and allows a sharp transition.
*/
static float satweights[2 * SATSIZE + 1];
static float lastcontrast = NAN;
static void _init_satweights(const float contrast)
{
  if(lastcontrast == contrast)
    return;
  lastcontrast = contrast;
  const double factor = -60.0 - 40.0 * (double)contrast;
  for(int i = -SATSIZE; i < SATSIZE + 1; i++)
  {
    const double val = 0.5 / (double)SATSIZE * (double)i;
    satweights[i+SATSIZE] = (float)(1.0 / (1.0 + exp(factor * val)));
  }
}

static inline float _get_satweight(const float sat)
{
  const float isat = (float)SATSIZE * (1.0f + CLAMP(sat, -1.0f, 1.0f-(1.0f/SATSIZE)));
  const float base = floorf(isat);
  const int i = base;
  return satweights[i] + (isat - base) * (satweights[i+1] - satweights[i]);
}

DT_OMP_DECLARE_SIMD(aligned(UV))
static float *const _init_covariance(const size_t pixels, const float *const restrict UV)
{
  // Init the symmetric covariance matrix of the guide (4 elements by pixel) :
  // covar = [[ covar(U, U), covar(U, V)],
  //          [ covar(V, U), covar(V, V)]]
  // with covar(x, y) = avg(x * y) - avg(x) * avg(y), corr(x, y) = x * y
  // so here, we init it with x * y, compute all the avg() at the next step
  // and subtract avg(x) * avg(y) later
  float *const restrict covariance = dt_alloc_align_float(pixels * 4);
  if(!covariance)
    return covariance;

  DT_OMP_FOR()
  for(size_t k = 0; k < pixels; k++)
  {
    // corr(U, U)
    covariance[4 * k + 0] = UV[2 * k + 0] * UV[2 * k + 0];
    // corr(U, V)
    covariance[4 * k + 1] = covariance[4 * k + 2] = UV[2 * k] * UV[2 * k + 1];
    // corr(V, V)
    covariance[4 * k + 3] = UV[2 * k + 1] * UV[2 * k + 1];
  }
  return covariance;
}

DT_OMP_DECLARE_SIMD(aligned(UV, covariance: 64))
static void _finish_covariance(const size_t pixels,
                               const float *const restrict UV,
                               float *const restrict covariance)
{
  // Finish the UV covariance matrix computation by subtracting avg(x) * avg(y)
  // to avg(x * y) already computed
  DT_OMP_FOR()
  for(size_t k = 0; k < pixels; k++)
  {
    // covar(U, U) = var(U)
    covariance[4 * k + 0] -= UV[2 * k + 0] * UV[2 * k + 0];
    // covar(U, V)
    covariance[4 * k + 1] -= UV[2 * k + 0] * UV[2 * k + 1];
    covariance[4 * k + 2] -= UV[2 * k + 0] * UV[2 * k + 1];
    // covar(V, V) = var(V)
    covariance[4 * k + 3] -= UV[2 * k + 1] * UV[2 * k + 1];
  }
}

DT_OMP_DECLARE_SIMD(aligned(UV, covariance, a, b: 64))
static void _prepare_prefilter(const size_t pixels,
                               const float *const restrict UV,
                               const float *const restrict covariance,
                               float *const restrict a,
                               float *const restrict b,
                               const float eps)
{
  DT_OMP_FOR()
  for(size_t k = 0; k < pixels; k++)
  {
    // Extract the 2×2 covariance matrix sigma = cov(U, V) at current pixel
    // and add the variance threshold : sigma' = sigma + epsilon * Identity
    const dt_aligned_pixel_t Sigma = {covariance[4 * k + 0] + eps,
                                covariance[4 * k + 1],
                                covariance[4 * k + 2],
                                covariance[4 * k + 3] + eps};

    // Invert the 2×2 sigma matrix algebraically
    // see https://www.mathcentre.ac.uk/resources/uploaded/sigma-matrices7-2009-1.pdf
    const float det = Sigma[0] * Sigma[3] - Sigma[1] * Sigma[2];

    // a(chan) = dot_product(cov(chan, uv), sigma_inv)
    if(fabsf(det) > 4.f * FLT_EPSILON)
    {
      const dt_aligned_pixel_t sigma_inv = { Sigma[3] / det, -Sigma[1] / det,
                                      -Sigma[2] / det,  Sigma[0] / det };
      // find a_1, a_2 s.t. U' = a_1 * U + a_2 * V
      a[4 * k + 0] = (covariance[4 * k + 0] * sigma_inv[0]
                    + covariance[4 * k + 1] * sigma_inv[1]);
      a[4 * k + 1] = (covariance[4 * k + 0] * sigma_inv[2]
                    + covariance[4 * k + 1] * sigma_inv[3]);

      // find a_3, a_4 s.t. V' = a_3 * U + a_4 V
      a[4 * k + 2] = (covariance[4 * k + 2] * sigma_inv[0]
                    + covariance[4 * k + 3] * sigma_inv[1]);
      a[4 * k + 3] = (covariance[4 * k + 2] * sigma_inv[2]
                    + covariance[4 * k + 3] * sigma_inv[3]);
    }
    else
    {
      // determinant too close to 0: singular matrix
      a[4 * k + 0] = a[4 * k + 1] = a[4 * k + 2] = a[4 * k + 3] = 0.f;
    }

    b[2 * k + 0] = UV[2 * k + 0]  - a[4 * k + 0] * UV[2 * k + 0]  - a[4 * k + 1] * UV[2 * k + 1];
    b[2 * k + 1] = UV[2 * k + 1]  - a[4 * k + 2] * UV[2 * k + 0]  - a[4 * k + 3] * UV[2 * k + 1];
  }
}

DT_OMP_DECLARE_SIMD(aligned(a, b, saturation, UV: 64))
static void _apply_prefilter(const size_t npixels,
                             const float sat_shift,
                             float *const restrict UV,
                             const float *const restrict saturation,
                             const float *const restrict a,
                             const float *const restrict b)
{
  DT_OMP_FOR_SIMD()
  for(size_t k = 0; k < npixels; k++)
  {
    // For each correction factor, we re-express it as a[0] * U + a[1] * V + b
    const float uv[2] = { UV[2 * k + 0], UV[2 * k + 1] };
    const float cv[2] = { a[4 * k + 0] * uv[0] + a[4 * k + 1] * uv[1] + b[2 * k + 0],
                          a[4 * k + 2] * uv[0] + a[4 * k + 3] * uv[1] + b[2 * k + 1] };

    // we avoid chroma blurring into achromatic areas by interpolating
    // input UV vs corrected UV
    const float satweight = _get_satweight(saturation[k] - sat_shift);
    UV[2 * k + 0] = mixf(uv[0], cv[0], satweight);
    UV[2 * k + 1] = mixf(uv[1], cv[1], satweight);
  }
}

static void _prefilter_chromaticity(float *const restrict UV,
                                    const float *const restrict saturation,
                                    const int width,
                                    const int height,
                                    const float sigma,
                                    const float eps,
                                    const float sat_shift)
{
  // We guide the 3-channels corrections with the 2-channels
  // chromaticity coordinates UV aka we express corrections = a * UV +
  // b where a is a 2×2 matrix and b a constant Therefore the guided
  // filter computation is a bit more complicated than the typical
  // 1-channel case.  We use by-the-book 3-channels fast guided filter
  // as in http://kaiminghe.com/eccv10/ but obviously reduced to 2.
  // We know that it tends to oversmooth the input where its intensity
  // is close to 0, but this is actually desirable here since
  // chromaticity -> 0 means neutral greys and we want to discard them
  // as much as possible from any color equalization.

  // possibly downsample for speed-up
  const size_t pixels = (size_t)width * height;
  const float scaling = _get_scaling(sigma);
  const float gsigma = MAX(0.2f, sigma / scaling);
  const int ds_height = height / scaling;
  const int ds_width = width / scaling;
  const size_t ds_pixels = (size_t)ds_width * ds_height;
  const gboolean resized = width != ds_width || height != ds_height;

  float *ds_UV = UV;
  if(resized)
  {
    ds_UV = dt_alloc_align_float(ds_pixels * 2);
    if(!ds_UV)
      return;	//out of memory, can't run the prefilter
    interpolate_bilinear(UV, width, height, ds_UV, ds_width, ds_height, 2);
  }

  float *const restrict covariance = _init_covariance(ds_pixels, ds_UV);
  if(!covariance)
  {
    if(ds_UV != UV) dt_free_align(ds_UV);
    return;
  }

  // Compute the local averages of everything over the window size We
  // use a gaussian blur as a weighted local average because it's a
  // radial function so it will not favour vertical and horizontal
  // edges over diagonal ones as the by-the-book box blur (unweighted
  // local average) would.

  dt_gaussian_mean_blur(ds_UV, ds_width, ds_height, 2, gsigma);
  dt_gaussian_mean_blur(covariance, ds_width, ds_height, 4, gsigma);

  _finish_covariance(ds_pixels, ds_UV, covariance);

  // Compute a and b the params of the guided filters
  float *const restrict ds_a = dt_alloc_align_float(4 * ds_pixels);
  float *const restrict ds_b = dt_alloc_align_float(2 * ds_pixels);

  if(ds_a && ds_b)
    _prepare_prefilter(ds_pixels, ds_UV, covariance, ds_a, ds_b, eps);

  dt_free_align(covariance);
  if(ds_UV != UV) dt_free_align(ds_UV);
  if(!ds_a || !ds_b)
  {
    dt_free_align(ds_a);
    dt_free_align(ds_b);
    return;
  }

  // Compute the averages of a and b for each filter
  dt_gaussian_mean_blur(ds_a, ds_width, ds_height, 4, gsigma);
  dt_gaussian_mean_blur(ds_b, ds_width, ds_height, 2, gsigma);

  // Upsample a and b to real-size image
  float *a = ds_a;
  float *b = ds_b;

  if(resized)
  {
    a = dt_alloc_align_float(pixels * 4);
    b = dt_alloc_align_float(pixels * 2);
    if(a && b)
    {
      interpolate_bilinear(ds_a, ds_width, ds_height, a, width, height, 4);
      interpolate_bilinear(ds_b, ds_width, ds_height, b, width, height, 2);
      dt_free_align(ds_a);
      dt_free_align(ds_b);
    }
    else
    {
      dt_free_align(ds_a);
      dt_free_align(ds_b);
      return;
    }
  }

  // Apply the guided filter
  _apply_prefilter(pixels, sat_shift, UV, saturation, a, b);

  dt_free_align(a);
  dt_free_align(b);
}

static void _guide_with_chromaticity(float *const restrict UV,
                              float *const restrict corrections,
                              const float *const restrict saturation,
                              float *const restrict b_corrections,
                              const float *const restrict gradients,
                              const int width,
                              const int height,
                              const float sigma,
                              const float eps,
                              const float bright_shift,
                              const float sat_shift)
{
  // We guide the 3-channels corrections with the 2-channels
  // chromaticity coordinates UV aka we express corrections = a * UV +
  // b where a is a 2×2 matrix and b a constant Therefore the guided
  // filter computation is a bit more complicated than the typical
  // 1-channel case.  We use by-the-book 3-channels fast guided filter
  // as in http://kaiminghe.com/eccv10/ but obviously reduced to 2.
  // We know that it tends to oversmooth the input where its intensity
  // is close to 0, but this is actually desirable here since
  // chromaticity -> 0 means neutral greys and we want to discard them
  // as much as possible from any color equalization.

  // Downsample for speed-up
  const size_t pixels = (size_t)width * height;
  const float scaling = _get_scaling(sigma);
  const float gsigma = MAX(0.2f, sigma / scaling);
  const int ds_height = height / scaling;
  const int ds_width = width / scaling;
  const size_t ds_pixels = (size_t)ds_width * ds_height;
  const gboolean resized = width != ds_width || height != ds_height;

  float *ds_UV = UV;
  float *ds_corrections = corrections;
  float *ds_b_corrections = b_corrections;
  if(resized)
  {
    ds_UV = dt_alloc_align_float(ds_pixels * 2);
    ds_corrections = dt_alloc_align_float(ds_pixels * 2);
    ds_b_corrections = dt_alloc_align_float(ds_pixels);
    if(ds_UV && ds_corrections && ds_b_corrections)
    {
      interpolate_bilinear(UV, width, height, ds_UV, ds_width, ds_height, 2);
      interpolate_bilinear(corrections, width, height, ds_corrections, ds_width, ds_height, 2);
      interpolate_bilinear(b_corrections, width, height, ds_b_corrections, ds_width, ds_height, 1);
    }
    else
    {
      dt_free_align(ds_UV);
      dt_free_align(ds_corrections);
      dt_free_align(ds_b_corrections);
      return;
    }
  }

  float *const restrict covariance = _init_covariance(ds_pixels, ds_UV);
  // Get the correlations between corrections and UV
  float *const restrict correlations = dt_alloc_align_float(ds_pixels * 4);
  if(!covariance || !correlations)
  {
    // ran out of memory, so we won't be able to apply the guided filter.
    // clean up and return now
    if(resized)
    {
      dt_free_align(ds_UV);
      dt_free_align(ds_corrections);
      dt_free_align(ds_b_corrections);
    }
    dt_free_align(covariance);
    dt_free_align(correlations);
    return;
  }

  DT_OMP_FOR_SIMD(aligned(ds_UV, ds_corrections, ds_b_corrections, correlations: 64))
  for(size_t k = 0; k < ds_pixels; k++)
  {
    // corr(sat, U)
    correlations[4 * k + 0] = ds_UV[2 * k + 0] * ds_corrections[2 * k + 1];
    // corr(sat, V)
    correlations[4 * k + 1] = ds_UV[2 * k + 1] * ds_corrections[2 * k + 1];

    // corr(bright, U)
    correlations[4 * k + 2] = ds_UV[2 * k + 0] * ds_b_corrections[k];
    // corr(bright, V)
    correlations[4 * k + 3] = ds_UV[2 * k + 1] * ds_b_corrections[k];
  }

  // Compute the local averages of everything over the window size We
  // use a gaussian blur as a weighted local average because it's a
  // radial function so it will not favour vertical and horizontal
  // edges over diagonal ones as the by-the-book box blur (unweighted
  // local average) would.
  // We use unbounded signals, so don't care for the internal value clipping
  dt_gaussian_mean_blur(ds_UV, ds_width, ds_height, 2, gsigma);
  dt_gaussian_mean_blur(covariance, ds_width, ds_height, 4, gsigma);
  dt_gaussian_mean_blur(ds_corrections, ds_width, ds_height, 2, gsigma);
  dt_gaussian_mean_blur(ds_b_corrections, ds_width, ds_height, 1, 0.1f * gsigma);
  dt_gaussian_mean_blur(correlations, ds_width, ds_height, 4, gsigma);

  _finish_covariance(ds_pixels, ds_UV, covariance);

  // Finish the guide * guided correlation computation
  DT_OMP_FOR_SIMD(aligned(ds_UV, ds_corrections, correlations: 64))
  for(size_t k = 0; k < ds_pixels; k++)
  {
    correlations[4 * k + 0] -= ds_UV[2 * k + 0] * ds_corrections[2 * k + 1];
    correlations[4 * k + 1] -= ds_UV[2 * k + 1] * ds_corrections[2 * k + 1];

    correlations[4 * k + 2] -= ds_UV[2 * k + 0] * ds_b_corrections[k];
    correlations[4 * k + 3] -= ds_UV[2 * k + 1] * ds_b_corrections[k];
  }

  // Compute a and b the params of the guided filters
  float *const restrict ds_a = dt_alloc_align_float(4 * ds_pixels);
  float *const restrict ds_b = dt_alloc_align_float(2 * ds_pixels);
  if(!ds_a || !ds_b)
  {
    dt_free_align(ds_a);
    dt_free_align(ds_b);
    dt_free_align(correlations);
    dt_free_align(covariance);
    if(resized)
    {
      dt_free_align(ds_corrections);
      dt_free_align(ds_b_corrections);
      dt_free_align(ds_UV);
    }
    return;
  }

  DT_OMP_FOR_SIMD(aligned(ds_UV, covariance, correlations, ds_corrections, ds_b_corrections, ds_a, ds_b: 64))
  for(size_t k = 0; k < ds_pixels; k++)
  {
    // Extract the 2×2 covariance matrix sigma = cov(U, V) at current pixel
    // and add the covariance threshold : sigma' = sigma + epsilon * Identity
    const dt_aligned_pixel_t Sigma
        = { covariance[4 * k + 0] + eps,
            covariance[4 * k + 1],
            covariance[4 * k + 2],
            covariance[4 * k + 3] + eps };

    // Invert the 2×2 sigma matrix algebraically
    // see https://www.mathcentre.ac.uk/resources/uploaded/sigma-matrices7-2009-1.pdf
    const float det = Sigma[0] * Sigma[3] - Sigma[1] * Sigma[2];
    // Note : epsilon prevents determinant == 0 so the invert exists all the time
    if(fabsf(det) > 4.f * FLT_EPSILON)
    {
      const dt_aligned_pixel_t sigma_inv = { Sigma[3] / det, -Sigma[1] / det, -Sigma[2] / det, Sigma[0] / det };
      ds_a[4 * k + 0] = (correlations[4 * k + 0] * sigma_inv[0] + correlations[4 * k + 1] * sigma_inv[1]);
      ds_a[4 * k + 1] = (correlations[4 * k + 0] * sigma_inv[2] + correlations[4 * k + 1] * sigma_inv[3]);
      ds_a[4 * k + 2] = (correlations[4 * k + 2] * sigma_inv[0] + correlations[4 * k + 3] * sigma_inv[1]);
      ds_a[4 * k + 3] = (correlations[4 * k + 2] * sigma_inv[2] + correlations[4 * k + 3] * sigma_inv[3]);
    }
    else
    {
      ds_a[4 * k + 0] = ds_a[4 * k + 1] = ds_a[4 * k + 2] = ds_a[4 * k + 3] = 0.f;
    }
    // b = avg(chan) - dot_product(a_chan * avg(UV))
    ds_b[2 * k + 0] = ds_corrections[2 * k + 1] - ds_a[4 * k + 0] * ds_UV[2 * k + 0]  - ds_a[4 * k + 1] * ds_UV[2 * k + 1];
    ds_b[2 * k + 1] = ds_b_corrections[k]       - ds_a[4 * k + 2] * ds_UV[2 * k + 0]  - ds_a[4 * k + 3] * ds_UV[2 * k + 1];
  }

  if(resized)
  {
    dt_free_align(ds_corrections);
    dt_free_align(ds_b_corrections);
    dt_free_align(ds_UV);
  }
  dt_free_align(correlations);
  dt_free_align(covariance);

  // Compute the averages of a and b for each filter and blur
  dt_gaussian_mean_blur(ds_a, ds_width, ds_height, 4, gsigma);
  dt_gaussian_mean_blur(ds_b, ds_width, ds_height, 2, gsigma);

  // Upsample a and b to real-size image
  float *a = ds_a;
  float *b = ds_b;
  if(resized)
  {
    a = dt_alloc_align_float(pixels * 4);
    b = dt_alloc_align_float(pixels * 2);
    if(a && b)
    {
      interpolate_bilinear(ds_a, ds_width, ds_height, a, width, height, 4);
      interpolate_bilinear(ds_b, ds_width, ds_height, b, width, height, 2);
      dt_free_align(ds_a);
      dt_free_align(ds_b);
    }
    else
    {
      dt_free_align(ds_a);
      dt_free_align(ds_b);
      return;
    }
  }

  // Apply the guided filter
  DT_OMP_FOR_SIMD(aligned(a, b, corrections, saturation, gradients, UV: 64))
  for(size_t k = 0; k < pixels; k++)
  {
    // For each correction factor, we re-express it as a[0] * U + a[1] * V + b
    const float uv[2] = { UV[2 * k + 0], UV[2 * k + 1] };
    const float cv[2] = { a[4 * k + 0] * uv[0] + a[4 * k + 1] * uv[1] + b[2 * k + 0],
                          a[4 * k + 2] * uv[0] + a[4 * k + 3] * uv[1] + b[2 * k + 1] };
    corrections[2 * k + 1] = 1.0f + (cv[0] - 1.0f) * _get_satweight(saturation[k] - sat_shift);
    const float gradient_weight = 1.0f - CLIP(gradients[k]);
    b_corrections[k] = cv[1] * gradient_weight * _get_satweight(saturation[k] - bright_shift);
  }
  dt_free_align(a);
  dt_free_align(b);
}

static void _prepare_process(const float roi_scale,
                             const dt_iop_colorequal_data_t *d,

                             // parameters to be setup
                             float *white,
                             float *sat_shift,
                             float *max_brightness_shift,
                             float *corr_max_brightness_shift,
                             float *bright_shift,
                             float *gradient_amp,
                             float *hue_sigma,
                             float *par_sigma,
                             float *sat_sigma,
                             float *scharr_sigma)
{
  *white = Y_to_dt_UCS_L_star(d->white_level);

  /* We use the logistic weighting function to diminish effects in the guided filter for locations
     with low chromacity. The logistic function is precalculated for a inflection point of zero
     so we have to shift the input value (saturation) for both brightness and saturation corrections.
     The default can be shifted by the threshold slider.
     Depending on the maximum for the eight brightness sliders we increase the brightness shift, the value
     of 0.01 has been found by a lot of testing to be safe.
     As increased param_size leads to propagation of brightness into achromatic parts we have to correct for that too.
  */
  *sat_shift = d->threshold;
  *max_brightness_shift = 0.01f * d->max_brightness;
  *corr_max_brightness_shift = *max_brightness_shift * MIN(5.0f, sqrtf(d->param_size));
  *bright_shift = *sat_shift + *corr_max_brightness_shift;

  /* We want information about sharp transitions of saturation for halo suppression.
     As the scharr operator is faster and more stable for scale changes we use
       it instead of local variance.
     We reduce chroma noise effects by using a minimum threshold and by sqaring the gradient.
     The gradient_amp corrects a gradient of 0.5 to be 1.0 and takes care
       of maximum changed brightness and roi_scale.
  */
  *gradient_amp = 4.0f * sqrtf(d->max_brightness) * sqrf(roi_scale);
  *hue_sigma = 0.5f * d->chroma_size * roi_scale;
  *par_sigma = 0.5f * d->param_size * roi_scale;
  *sat_sigma = MAX(0.5f, roi_scale);
  *scharr_sigma = MAX(0.5f, roi_scale);

  _init_satweights(d->contrast);
}

static void _copy_HSB_cb(void *const user_data,
                         float *const buf,
                         const size_t npixels)
{
  // pix_out[0..2] = HSB of the module input pixel: hue (radians UCS),
  // saturation, brightness — computed in process() before any correction.
  // npixels covers components floats per pixel (3·width·height).
  const float *const src = (const float *)user_data;
  DT_OMP_FOR()
  for(size_t k = 0; k < npixels / 3; k++)
  {
    buf[k * 3 + 0] = src[k * 4 + 0];
    buf[k * 3 + 1] = src[k * 4 + 1];
    buf[k * 3 + 2] = src[k * 4 + 2];
  }
}

void process(dt_iop_module_t *self,
             dt_dev_pixelpipe_iop_t *piece,
             const void *const i,
             void *const o,
             const dt_iop_roi_t *const roi_in,
             const dt_iop_roi_t *const roi_out)
{
  if(!dt_iop_have_required_input_format(4 /*we need full-color pixels*/,
                                        self, piece->colors, i, o, roi_in, roi_out))
    return;

  const int width = roi_out->width;
  const int height = roi_out->height;
  const size_t npixels = (size_t)width * height;
  float *restrict UV = NULL;
  float *restrict corrections = NULL;
  float *restrict b_corrections = NULL;
  float *restrict Lscharr = NULL;
  float *restrict saturation = NULL;
  if(!dt_iop_alloc_image_buffers(self, roi_in, roi_out,
                                 2/*ch per pix*/ | DT_IMGSZ_OUTPUT | DT_IMGSZ_FULL, &UV,
                                 2               | DT_IMGSZ_OUTPUT | DT_IMGSZ_FULL, &corrections,
                                 1               | DT_IMGSZ_OUTPUT | DT_IMGSZ_FULL, &b_corrections,
                                 1               | DT_IMGSZ_OUTPUT | DT_IMGSZ_FULL, &Lscharr,
                                 1               | DT_IMGSZ_OUTPUT | DT_IMGSZ_FULL, &saturation,
                                 0/*end of list*/))
  {
    // Uh oh, we didn't have enough memory!  Any buffers that had
    // already been allocated have been freed, and the module's
    // trouble flag has been set.  We can simply pass through the
    // input image and return now, since there isn't anything else we
    // need to clean up at this point.
    dt_iop_copy_image_roi(o, i, piece->colors, roi_in, roi_out);
    return;
  }
  const dt_iop_colorequal_data_t *d = piece->data;
  const dt_iop_colorequal_gui_data_t *g = self->gui_data;
  const gboolean fullpipe = dt_pipe_is_full(piece->pipe);

  int mask_mode = 0;
  if(self->dev->gui_attached && g && fullpipe)
  {
    dt_iop_gui_enter_critical_section(self);
    mask_mode = g->mask_mode;
    dt_iop_gui_leave_critical_section(self);
  }
  const gboolean run_fast = dt_pipe_is_fast(piece->pipe);

  const float *const restrict in = (float*)i;
  float *const restrict out = (float*)o;

  // STEP 0: prepare the RGB <-> XYZ D65 matrices
  // see colorbalancergb.c process() for the details, it's exactly the same
  const struct dt_iop_order_iccprofile_info_t *const work_profile =
    dt_ioppr_get_pipe_current_profile_info(self, piece->pipe);
  if(work_profile == NULL) return; // no point

  dt_colormatrix_t input_matrix;
  dt_colormatrix_t output_matrix;
  dt_colormatrix_mul(input_matrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in);
  dt_colormatrix_mul(output_matrix, work_profile->matrix_out, XYZ_D65_to_D50_CAT16);

  float white, sat_shift, max_brightness_shift, corr_max_brightness_shift, bright_shift, gradient_amp, hue_sigma, par_sigma, sat_sigma, scharr_sigma;
  _prepare_process(roi_in->scale / piece->iscale, d,
    &white, &sat_shift, &max_brightness_shift, &corr_max_brightness_shift, &bright_shift, &gradient_amp, &hue_sigma, &par_sigma, &sat_sigma, &scharr_sigma);

  // STEP 1: convert image from RGB to darktable UCS LUV and calc saturation
  DT_OMP_FOR()
  for(size_t k = 0; k < npixels; k++)
  {
    const float *const restrict pix_in = DT_IS_ALIGNED_PIXEL(in + k * 4);
    float *const restrict uv = UV + k * 2;

    // Convert to XYZ D65
    dt_aligned_pixel_t XYZ_D65 = { 0.0f, 0.0f, 0.0f, 0.0f };
    dot_product(pix_in, input_matrix, XYZ_D65);
    // Convert to dt UCS 22 UV and store UV
    dt_aligned_pixel_t xyY = { 0.0f, 0.0f, 0.0f, 0.0f };
    dt_D65_XYZ_to_xyY(XYZ_D65, xyY);

    // calc saturation from input data
    const float dmin = min3f(pix_in);
    const float dmax = max3f(pix_in);
    const float delta = dmax - dmin;
    saturation[k] = (dmax > NORM_MIN && delta > NORM_MIN) ? delta / dmax : 0.0f;

    xyY_to_dt_UCS_UV(xyY, uv);
    Lscharr[k] = Y_to_dt_UCS_L_star(xyY[2]);
  }

  dt_gaussian_mean_blur(saturation, width, height, 1, sat_sigma);

  // STEP 2 : smoothen UV to avoid discontinuities in hue
  if(d->use_filter && !run_fast)
    _prefilter_chromaticity(UV, saturation, width, height, hue_sigma, d->chroma_feathering, sat_shift);

  // STEP 3 : carry-on with conversion from LUV to HSB
  DT_OMP_FOR()
  for(int row = 0; row < height; row++)
  {
    for(int col = 0; col < width; col++)
    {
      const size_t k = (size_t)row * width + col;

      const float *const restrict pix_in = DT_IS_ALIGNED_PIXEL(in + k * 4);
      float *const restrict pix_out = DT_IS_ALIGNED_PIXEL(out + k * 4);
      float *const restrict corrections_out = corrections + k * 2;

      const float *const restrict uv = UV + k * 2;

      // Finish the conversion to dt UCS JCH then HSB
      dt_aligned_pixel_t JCH = { 0.0f, 0.0f, 0.0f, 0.0f };
      dt_UCS_LUV_to_JCH(Lscharr[k], white, uv, JCH);
      dt_UCS_JCH_to_HSB(JCH, pix_out);

      // As tmp[k] is not used any longer as L(uminance) we re-use it for the saturation gradient
      if(d->use_filter)
      {
        const int vrow = MIN(height - 2, MAX(1, row));
        const int vcol = MIN(width - 2, MAX(1, col));
        const size_t kk = vrow * width + vcol;
        Lscharr[k] = gradient_amp * sqrf(MAX(0.0f, scharr_gradient(&saturation[kk], width) - 0.02f));
      }

      // Get the boosts - if chroma = 0, we have a neutral grey so set everything to 0
      if(JCH[1] > NORM_MIN)
      {
        const float hue = pix_out[0];
        const float sat = pix_out[1];
        corrections_out[0] = lookup_gamut(d->LUT_hue, hue);
        corrections_out[1] = lookup_gamut(d->LUT_saturation, hue);
        b_corrections[k] = sat * (lookup_gamut(d->LUT_brightness, hue) - 1.0f);
      }
      else
      {
        corrections_out[0] = 0.0f;
        corrections_out[1] = 1.0f;
        b_corrections[k] = 0.0f;
      }

      // Copy alpha
      pix_out[3] = pix_in[3];
    }
  }

  // Cache the HSB (hue in radians, saturation, brightness) of the module
  // *input* pixel in the preview buffer for mouse_moved/scrolled and the
  // cursor in/out colors.  The service resizes, fills and commits the
  // freshness hash under one GUI lock so the GUI thread can never observe
  // a resized but not-yet-filled buffer.
  if(g && (piece->pipe->type & DT_DEV_PIXELPIPE_PREVIEW))
  {
    dt_iop_colorequal_gui_data_t *gui = self->gui_data; // non-const for writing
    dt_preview_data_store(&gui->pd, width, height, piece, _copy_HSB_cb, (void *)out);
  }

  if(d->use_filter && !run_fast)
  {
    // blur the saturation gradients
    dt_gaussian_mean_blur(Lscharr, width, height, 1, scharr_sigma);

    // STEP 4: apply a guided filter on the corrections, guided with UV chromaticity, to ensure spatially-contiguous corrections.
    // Even if the hue is not perfectly constant this will help avoiding chroma noise.
    _guide_with_chromaticity(UV, corrections, saturation, b_corrections, Lscharr, width, height, par_sigma, d->param_feathering, bright_shift, sat_shift);
  }

  if(mask_mode == 0)
  {
    // STEP 5: apply the corrections and convert back to RGB
    DT_OMP_FOR()
    for(size_t k = 0; k < npixels; k++)
    {
      const float *const restrict corrections_out = corrections + k * 2;
      float *const restrict pix_out = DT_IS_ALIGNED_PIXEL(out + k * 4);

      // Apply the corrections
      pix_out[0] += corrections_out[0]; // WARNING: hue is an offset
      // pix_out[1] (saturation) and pix_out[2] (brightness) are gains
      pix_out[1] = MAX(0.0f, pix_out[1] * (1.0f + SAT_EFFECT * (corrections_out[1] - 1.0f)));
      pix_out[2] = MAX(0.0f, pix_out[2] * (1.0f + BRIGHT_EFFECT * b_corrections[k]));

      // Sanitize gamut
      gamut_map_HSB(pix_out, d->gamut_LUT, white);

      // Convert back to XYZ D65
      dt_aligned_pixel_t XYZ_D65 = { 0.f };
      dt_UCS_HSB_to_XYZ(pix_out, white, XYZ_D65);

      // And back to pipe RGB through XYZ D50
      dot_product(XYZ_D65, output_matrix, pix_out);
    }
  }
  else
  {
    piece->pipe->mask_display = DT_DEV_PIXELPIPE_DISPLAY_PASSTHRU;
    const int mode = mask_mode - 1;
    DT_OMP_FOR()
    for(size_t k = 0; k < npixels; k++)
    {
      float *const restrict pix_out = DT_IS_ALIGNED_PIXEL(out + k * 4);
      const float *const restrict corrections_out = corrections + k * 2;

      const float val = sqrtf(pix_out[2] * white);
      float corr = 0.0f;
      switch(mode)
      {
        case BRIGHTNESS:
          corr = BRIGHT_EFFECT * b_corrections[k];
          break;
        case SATURATION:
          corr = SAT_EFFECT * (corrections_out[1] - 1.0f);
          break;
        case BRIGHTNESS_GRAD:
          corr = _get_satweight(saturation[k] - bright_shift) - 0.5f;
          break;
        case SATURATION_GRAD:
          corr = _get_satweight(saturation[k] - sat_shift) - 0.5f;
          break;

        default:  // HUE
          corr = 0.2f * corrections_out[0];
      }

      const gboolean neg = corr < 0.0f;
      corr = fabsf(corr);
      corr = corr < 2e-3 ? 0.0f : corr;
      pix_out[0] = MAX(0.0f, neg ? val - corr : val);
      pix_out[1] = MAX(0.0f, val - corr);
      pix_out[2] = MAX(0.0f, neg ? val : val - corr);

      if(mode == BRIGHTNESS && Lscharr[k] > 0.1f)
      {
        pix_out[0] = pix_out[2] = 0.0f;
        pix_out[1] = Lscharr[k];
      }
    }

    if((mode == BRIGHTNESS_GRAD) || (mode == SATURATION_GRAD))
    {
      const float scaler = (float)width * 16.0f;
      const float eps = 0.5f / (float)height;
      for(int col = 0; col < 16 * width; col++)
      {
        const float weight = _get_satweight((float)col / scaler - (mode == SATURATION_GRAD ? sat_shift : bright_shift));
        if(weight > eps && weight < 1.0f - eps)
        {
          const int row = (int)((1.0f - weight) * (float)(height-1));
          const size_t k = row * width + col / 16;
          out[4*k] = out[4*k+2] = 0.0f;
          out[4*k+1] = 1.0f;
        }
      }
    }
  }

  dt_free_align(corrections);
  dt_free_align(b_corrections);
  dt_free_align(saturation);
  dt_free_align(UV);
  dt_free_align(Lscharr);
}

// dt_gaussian_mean_blur_cl() in common/gaussian.h replaces the former local dt_gaussian_mean_blur_cl().

#if HAVE_OPENCL

static cl_mem _init_covariance_cl(const int devid,
                                  const dt_iop_colorequal_global_data_t *gd,
                                  const cl_mem UV,
                                  const int width,
                                  const int height)
{
  cl_mem covariance = dt_opencl_alloc_device_buffer(devid, 4 * sizeof(float) * width * height);
  if(covariance == NULL) return NULL;

  const cl_int err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_init_covariance, width, height,
                    CLARG(covariance), CLARG(UV), CLARG(width), CLARG(height));
  if(err != CL_SUCCESS)
  {
    dt_opencl_release_mem_object(covariance);
    covariance = NULL;
  }
  return covariance;
}

static int _prefilter_chromaticity_cl(const int devid,
                                      const dt_iop_colorequal_global_data_t *gd,
                                      const cl_mem UV,
                                      const cl_mem saturation,
                                      const cl_mem weight,
                                      const int width,
                                      const int height,
                                      const float sigma,
                                      const float eps,
                                      const float sat_shift)
{
  cl_int err = CL_MEM_OBJECT_ALLOCATION_FAILURE;

  const float scaling = _get_scaling(sigma);
  const float gsigma = MAX(0.2f, sigma / scaling);
  const int ds_height = height / scaling;
  const int ds_width = width / scaling;
  const gboolean resized = width != ds_width || height != ds_height;

  const size_t bsize = (size_t) width * height * sizeof(float);
  const size_t ds_bsize = (size_t) ds_width * ds_height * sizeof(float);

  cl_mem ds_UV = UV;
  cl_mem covariance = NULL;
  cl_mem ds_a = NULL;
  cl_mem ds_b = NULL;
  cl_mem a = NULL;
  cl_mem b = NULL;

  if(resized)
  {
    ds_UV = dt_opencl_alloc_device_buffer(devid, 2 * ds_bsize);
    if(ds_UV == NULL) return err;

    err = dt_interpolate_bilinear_cl(devid, UV, width, height,
                                     ds_UV, ds_width, ds_height, 2);
    if(err != CL_SUCCESS) goto error;
  }

  covariance = _init_covariance_cl(devid, gd, ds_UV, ds_width, ds_height);
  if(covariance == NULL)
  {
    err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
    goto error;
  }

  err = dt_gaussian_mean_blur_cl(devid, ds_UV, ds_width, ds_height, 2, gsigma);
  if(err != CL_SUCCESS) goto error;

  err = dt_gaussian_mean_blur_cl(devid, covariance, ds_width, ds_height, 4, gsigma);
  if(err != CL_SUCCESS) goto error;

  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_finish_covariance, ds_width, ds_height,
          CLARG(covariance), CLARG(ds_UV), CLARG(ds_width), CLARG(ds_height));
  if(err != CL_SUCCESS) goto error;

  ds_a = dt_opencl_alloc_device_buffer(devid, 4 * ds_bsize);
  ds_b = dt_opencl_alloc_device_buffer(devid, 2 * ds_bsize);
  if(ds_a == NULL || ds_b == NULL)
  {
    err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
    goto error;
  }

  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_prepare_prefilter, ds_width, ds_height,
          CLARG(ds_UV), CLARG(covariance), CLARG(ds_a), CLARG(ds_b), CLARG(eps),
          CLARG(ds_width), CLARG(ds_height));
  if(err != CL_SUCCESS) goto error;

  dt_opencl_release_mem_object(covariance);
  covariance = NULL;
  if(resized)
  {
    dt_opencl_release_mem_object(ds_UV);
    ds_UV = NULL;
  }

  err = dt_gaussian_mean_blur_cl(devid, ds_a, ds_width, ds_height, 4, gsigma);
  if(err != CL_SUCCESS) goto error;

  err = dt_gaussian_mean_blur_cl(devid, ds_b, ds_width, ds_height, 2, gsigma);
  if(err != CL_SUCCESS) goto error;

  a = ds_a;
  b = ds_b;
  if(resized)
  {
    a = dt_opencl_alloc_device_buffer(devid, 4 * bsize);
    b = dt_opencl_alloc_device_buffer(devid, 2 * bsize);
    if(a == NULL || b == NULL)
    {
      err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
      goto error;
    }

    err = dt_interpolate_bilinear_cl(devid, ds_a, ds_width, ds_height,
                                     a, width, height, 4);
    if(err != CL_SUCCESS) goto error;

    err = dt_interpolate_bilinear_cl(devid, ds_b, ds_width, ds_height,
                                     b, width, height, 2);
    if(err != CL_SUCCESS) goto error;

    dt_opencl_release_mem_object(ds_a);
    ds_a = NULL;
    dt_opencl_release_mem_object(ds_b);
    ds_b = NULL;
  }

  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_apply_prefilter, width, height,
          CLARG(UV), CLARG(saturation), CLARG(a), CLARG(b), CLARG(weight),
          CLARG(sat_shift), CLARG(width), CLARG(height));

error:
  if(resized)
  {
    dt_opencl_release_mem_object(a);
    dt_opencl_release_mem_object(b);
    dt_opencl_release_mem_object(ds_UV);
  }
  dt_opencl_release_mem_object(covariance);
  dt_opencl_release_mem_object(ds_a);
  dt_opencl_release_mem_object(ds_b);
  return err;
}

static int _guide_with_chromaticity_cl(const int devid,
                                      const dt_iop_colorequal_global_data_t *gd,
                                      const cl_mem UV,
                                      const cl_mem corrections,
                                      const cl_mem saturation,
                                      const cl_mem b_corrections,
                                      const cl_mem scharr,
                                      const cl_mem weight,
                                      const int width,
                                      const int height,
                                      const float sigma,
                                      const float eps,
                                      const float bright_shift,
                                      const float sat_shift)
{
  const float scaling = _get_scaling(sigma);
  const float gsigma =  MAX(0.2f, sigma / scaling);
  const int ds_height = height / scaling;
  const int ds_width = width / scaling;
  const gboolean resized = width != ds_width || height != ds_height;

  const size_t bsize = (size_t) width * height * sizeof(float);
  const size_t ds_bsize = (size_t) ds_width * ds_height * sizeof(float);

  cl_int err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
  cl_mem ds_UV = UV;
  cl_mem ds_corrections = corrections;
  cl_mem ds_b_corrections = b_corrections;
  cl_mem covariance = NULL;
  cl_mem correlations = NULL;
  cl_mem ds_a = NULL;
  cl_mem ds_b = NULL;
  cl_mem a = NULL;
  cl_mem b = NULL;

  if(resized)
  {
    ds_UV = dt_opencl_alloc_device_buffer(devid, 2 * ds_bsize);
    ds_corrections = dt_opencl_alloc_device_buffer(devid, 2 * ds_bsize);
    ds_b_corrections = dt_opencl_alloc_device_buffer(devid, ds_bsize);
    if(ds_UV == NULL || ds_corrections == NULL || ds_b_corrections == NULL) goto error;

    err = dt_interpolate_bilinear_cl(devid, UV, width, height,
                                     ds_UV, ds_width, ds_height, 2);
    if(err != CL_SUCCESS) goto error;

    err = dt_interpolate_bilinear_cl(devid, corrections, width, height,
                                     ds_corrections, ds_width, ds_height, 2);
    if(err != CL_SUCCESS) goto error;

    err = dt_interpolate_bilinear_cl(devid, b_corrections, width, height,
                                     ds_b_corrections, ds_width, ds_height, 1);
    if(err != CL_SUCCESS) goto error;
  }

  covariance = _init_covariance_cl(devid, gd, ds_UV, ds_width, ds_height);
  correlations = dt_opencl_alloc_device_buffer(devid, 4 * ds_bsize);
  if(covariance == NULL || correlations == NULL)
  {
    err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
    goto error;
  }

  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_prepare_correlations, ds_width, ds_height,
          CLARG(ds_corrections), CLARG(ds_b_corrections), CLARG(ds_UV), CLARG(correlations),
          CLARG(ds_width), CLARG(ds_height));
  if(err != CL_SUCCESS) goto error;

  err = dt_gaussian_mean_blur_cl(devid, ds_UV, ds_width, ds_height, 2, gsigma);
  if(err != CL_SUCCESS) goto error;
  err = dt_gaussian_mean_blur_cl(devid, covariance, ds_width, ds_height, 4, gsigma);
  if(err != CL_SUCCESS) goto error;
  err = dt_gaussian_mean_blur_cl(devid, ds_corrections, ds_width, ds_height, 2, gsigma);
  if(err != CL_SUCCESS) goto error;
  err = dt_gaussian_mean_blur_cl(devid, ds_b_corrections, ds_width, ds_height, 1, 0.1f * gsigma);
  if(err != CL_SUCCESS) goto error;
  err = dt_gaussian_mean_blur_cl(devid, correlations, ds_width, ds_height, 4, gsigma);
  if(err != CL_SUCCESS) goto error;

  ds_a = dt_opencl_alloc_device_buffer(devid, 4 * ds_bsize);
  ds_b = dt_opencl_alloc_device_buffer(devid, 2 * ds_bsize);
  if(ds_a == NULL || ds_b == NULL)
  {
    err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
    goto error;
  }

  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_finish_correlations, ds_width, ds_height,
          CLARG(ds_corrections), CLARG(ds_b_corrections), CLARG(ds_UV),
          CLARG(correlations), CLARG(covariance),
          CLARG(ds_width), CLARG(ds_height));
  if(err != CL_SUCCESS) goto error;

  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_final_guide, ds_width, ds_height,
          CLARG(covariance), CLARG(correlations), CLARG(ds_corrections), CLARG(ds_b_corrections),
          CLARG(ds_UV), CLARG(ds_a), CLARG(ds_b), CLARG(eps),
          CLARG(ds_width), CLARG(ds_height));
  if(err != CL_SUCCESS) goto error;

  if(resized)
  {
    dt_opencl_release_mem_object(ds_UV);
    ds_UV = NULL;
    dt_opencl_release_mem_object(ds_corrections);
    ds_corrections = NULL;
    dt_opencl_release_mem_object(ds_b_corrections);
    ds_b_corrections = NULL;
  }
  dt_opencl_release_mem_object(correlations);
  correlations = NULL;
  dt_opencl_release_mem_object(covariance);
  covariance = NULL;

  err = dt_gaussian_mean_blur_cl(devid, ds_a, ds_width, ds_height, 4, gsigma);
  if(err != CL_SUCCESS) goto error;
  err = dt_gaussian_mean_blur_cl(devid, ds_b, ds_width, ds_height, 2, gsigma);
  if(err != CL_SUCCESS) goto error;

  a = ds_a;
  b = ds_b;
  if(resized)
  {
    a = dt_opencl_alloc_device_buffer(devid, 4 * bsize);
    b = dt_opencl_alloc_device_buffer(devid, 2 * bsize);
    if(a == NULL || b == NULL)
    {
      err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
      goto error;
    }

    err = dt_interpolate_bilinear_cl(devid, ds_a, ds_width, ds_height,
                                     a, width, height, 4);
    if(err != CL_SUCCESS) goto error;

    err = dt_interpolate_bilinear_cl(devid, ds_b, ds_width, ds_height,
                                     b, width, height, 2);
    if(err != CL_SUCCESS) goto error;
  }

  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_apply_guided, width, height,
          CLARG(UV), CLARG(saturation), CLARG(scharr), CLARG(a), CLARG(b),
          CLARG(corrections), CLARG(b_corrections), CLARG(weight),
          CLARG(sat_shift), CLARG(bright_shift), CLARG(width), CLARG(height));

error:
  if(resized)
  {
    dt_opencl_release_mem_object(ds_UV);
    dt_opencl_release_mem_object(ds_corrections);
    dt_opencl_release_mem_object(ds_b_corrections);
    dt_opencl_release_mem_object(a);
    dt_opencl_release_mem_object(b);
  }
  dt_opencl_release_mem_object(correlations);
  dt_opencl_release_mem_object(covariance);
  dt_opencl_release_mem_object(ds_a);
  dt_opencl_release_mem_object(ds_b);

  return err;
}

int process_cl(dt_iop_module_t *self,
               dt_dev_pixelpipe_iop_t *piece,
               cl_mem dev_in,
               cl_mem dev_out,
               const dt_iop_roi_t *const roi_in,
               const dt_iop_roi_t *const roi_out)
{
  const dt_iop_colorequal_data_t *d = (dt_iop_colorequal_data_t *)piece->data;
  const dt_iop_colorequal_global_data_t *const gd = (dt_iop_colorequal_global_data_t *)self->global_data;

  // Get working color profile
  const struct dt_iop_order_iccprofile_info_t *const work_profile
      = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe);
  if(piece->colors != 4 || work_profile == NULL)
    return DT_OPENCL_PROCESS_CL;

  cl_int err = CL_MEM_OBJECT_ALLOCATION_FAILURE;

  const int devid = piece->pipe->devid;
  const int width = roi_out->width;
  const int height = roi_out->height;
  const size_t bsize = (size_t) width * height * sizeof(float);

  const dt_iop_colorequal_gui_data_t *g = (dt_iop_colorequal_gui_data_t *)self->gui_data;
  const gboolean fullpipe = dt_pipe_is_full(piece->pipe);

  int mask_mode = 0;
  if(self->dev->gui_attached && g && fullpipe)
  {
    dt_iop_gui_enter_critical_section(self);
    mask_mode = g->mask_mode;
    dt_iop_gui_leave_critical_section(self);
  }
  const int guiding = d->use_filter;
  const gboolean run_fast = dt_pipe_is_fast(piece->pipe);

  float white, sat_shift, max_brightness_shift, corr_max_brightness_shift, bright_shift, gradient_amp, hue_sigma, par_sigma, sat_sigma, scharr_sigma;
  _prepare_process(roi_in->scale / piece->iscale, d,
    &white, &sat_shift, &max_brightness_shift, &corr_max_brightness_shift, &bright_shift, &gradient_amp, &hue_sigma, &par_sigma, &sat_sigma, &scharr_sigma);

  dt_colormatrix_t input_matrix;
  dt_colormatrix_t output_matrix;
  dt_colormatrix_mul(input_matrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in);
  dt_colormatrix_mul(output_matrix, work_profile->matrix_out, XYZ_D65_to_D50_CAT16);

  const cl_mem input_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), input_matrix);
  const cl_mem output_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), output_matrix);
  const cl_mem gamut_LUT = dt_opencl_copy_host_to_device_constant(devid, LUT_ELEM * sizeof(float), d->gamut_LUT);
  const cl_mem LUT_saturation = dt_opencl_copy_host_to_device_constant(devid, LUT_ELEM * sizeof(float), d->LUT_saturation);
  const cl_mem LUT_hue = dt_opencl_copy_host_to_device_constant(devid, LUT_ELEM * sizeof(float), d->LUT_hue);
  const cl_mem LUT_brightness = dt_opencl_copy_host_to_device_constant(devid, LUT_ELEM * sizeof(float), d->LUT_brightness);
  const cl_mem weight = dt_opencl_copy_host_to_device_constant(devid, (2 * SATSIZE + 1) * sizeof(float), satweights);

  const cl_mem pixout = dt_opencl_alloc_device_buffer(devid, 4 * bsize);
  const cl_mem UV = dt_opencl_alloc_device_buffer(devid, 2 * bsize);
  const cl_mem corrections = dt_opencl_alloc_device_buffer(devid, 2 * bsize);
  const cl_mem b_corrections = dt_opencl_alloc_device_buffer(devid, bsize);
  const cl_mem Lscharr = dt_opencl_alloc_device_buffer(devid, bsize);
  const cl_mem saturation = dt_opencl_alloc_device_buffer(devid, bsize);

  if(input_matrix_cl == NULL || output_matrix_cl == NULL || gamut_LUT == NULL || LUT_saturation == NULL
      || LUT_hue == NULL || LUT_brightness == NULL || weight == NULL
      || pixout == NULL || UV == NULL || corrections == NULL || b_corrections == NULL
      || Lscharr == NULL || saturation == NULL)
    goto error;

  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_sample_input, width, height,
          CLARG(dev_in), CLARG(saturation), CLARG(Lscharr), CLARG(UV), CLARG(pixout),
          CLARG(input_matrix_cl), CLARG(width),  CLARG(height));
  if(err != CL_SUCCESS) goto error;

  err = dt_gaussian_mean_blur_cl(devid, saturation, width, height, 1, sat_sigma);
  if(err != CL_SUCCESS) goto error;

  // STEP 2 : smoothen UV to avoid discontinuities in hue
  if(guiding && !run_fast)
  {
    err = _prefilter_chromaticity_cl(devid, gd, UV, saturation, weight, width, height, hue_sigma, d->chroma_feathering, sat_shift);
    if(err != CL_SUCCESS) goto error;
  }

  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_process_data, width, height,
                CLARG(UV), CLARG(Lscharr), CLARG(saturation),
                CLARG(corrections), CLARG(b_corrections), CLARG(pixout),
                CLARG(LUT_saturation), CLARG(LUT_hue), CLARG(LUT_brightness),
                CLARG(white), CLARG(gradient_amp), CLARG(guiding),
                CLARG(width),  CLARG(height));
  if(err != CL_SUCCESS) goto error;

  // On the preview pipe, read the original (uncorrected) HSB back from the
  // GPU pixout buffer to populate the shared preview buffer for
  // mouse_moved/scrolled.  pixout[k].xyz contains the raw HSB of the
  // module input (same as the CPU process() path).
  if(self->gui_data && (piece->pipe->type & DT_DEV_PIXELPIPE_PREVIEW))
  {
    dt_iop_colorequal_gui_data_t *gui = (dt_iop_colorequal_gui_data_t *)self->gui_data;
    const size_t npixels = (size_t)width * height;
    const size_t px_sz = 4 * npixels * sizeof(float);
    float *host_pixout = dt_alloc_align_float(4 * npixels);
    if(host_pixout)
    {
      err = dt_opencl_read_buffer_from_device(devid, host_pixout, pixout, 0, px_sz, TRUE);
      if(err == CL_SUCCESS)
        dt_preview_data_store(&gui->pd, width, height, piece, _copy_HSB_cb, (void *)host_pixout);
      dt_free_align(host_pixout);
    }
  }

  if(guiding && !run_fast)
  {
    err = dt_gaussian_mean_blur_cl(devid, Lscharr, width, height, 1, scharr_sigma);
    if(err != CL_SUCCESS) goto error;

    err = _guide_with_chromaticity_cl(devid, gd, UV, corrections, saturation, b_corrections, Lscharr, weight, width, height, par_sigma, d->param_feathering, bright_shift, sat_shift);
    if(err != CL_SUCCESS) goto error;
  }

  if(!mask_mode)
  {
    err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_write_output, width, height,
          CLARG(dev_out), CLARG(pixout),
          CLARG(corrections), CLARG(b_corrections),
          CLARG(output_matrix_cl), CLARG(gamut_LUT), CLARG(white),
          CLARG(width), CLARG(height));
  }
  else
  {
    piece->pipe->mask_display = DT_DEV_PIXELPIPE_DISPLAY_PASSTHRU;
    const int mode = mask_mode - 1;
    err = dt_opencl_enqueue_kernel_2d_args(devid, gd->ce_write_visual, width, height,
          CLARG(dev_out), CLARG(pixout), CLARG(corrections), CLARG(b_corrections), CLARG(saturation), CLARG(Lscharr),
          CLARG(weight), CLARG(bright_shift), CLARG(sat_shift), CLARG(white), CLARG(mode),
          CLARG(width), CLARG(height));
    if(err != CL_SUCCESS) goto error;

    if(mode == BRIGHTNESS_GRAD || mode == SATURATION_GRAD)
    {
      err = dt_opencl_enqueue_kernel_1d_args(devid, gd->ce_draw_weight, width,
          CLARG(dev_out), CLARG(weight), CLARG(bright_shift), CLARG(sat_shift), CLARG(mode), CLARG(width), CLARG(height));
    }
  }

error:
  dt_opencl_release_mem_object(input_matrix_cl);
  dt_opencl_release_mem_object(output_matrix_cl);
  dt_opencl_release_mem_object(gamut_LUT);
  dt_opencl_release_mem_object(LUT_saturation);
  dt_opencl_release_mem_object(LUT_hue);
  dt_opencl_release_mem_object(LUT_brightness);
  dt_opencl_release_mem_object(weight);
  dt_opencl_release_mem_object(UV);
  dt_opencl_release_mem_object(pixout);
  dt_opencl_release_mem_object(corrections);
  dt_opencl_release_mem_object(b_corrections);
  dt_opencl_release_mem_object(Lscharr);
  dt_opencl_release_mem_object(saturation);
  return err;
}

#endif // OpenCL

static inline float _get_hue_node(const int k, const float hue_shift)
{
  // Get the angular coordinate of the k-th hue node, including hue shift
  return _conventional_hue_deg_to_ucs_rad(((float)k) * 360.f / ((float)NODES) + hue_shift);
}

static inline float _cosine_coeffs(const float l,
                                   const float c)
{
  return expf(-l * l / c);
}

static inline void _periodic_RBF_interpolate(float nodes[NODES],
                                             const float smoothing,
                                             float *const LUT,
                                             const float hue_shift,
                                             const gboolean clip)
{
  // Perform a periodic interpolation across hue angles using radial-basis functions
  // see https://eng.aurelienpierre.com/2022/06/interpolating-hue-angles/#Refined-approach
  // for the theory and Python demo

  // Number of terms for the cosine series
  const int m = (int)ceilf(3.f * sqrtf(smoothing));

  float DT_ALIGNED_ARRAY A[NODES][NODES] = { { 0.f } };

  // Build the A matrix with nodes
  for(int i = 0; i < NODES; i++)
    for(int j = 0; j < NODES; j++)
    {
      for(int l = 0; l < m; l++)
      {
        A[i][j] += _cosine_coeffs(l, smoothing) * \
          cosf(((float)l) * fabsf(_get_hue_node(i, hue_shift) - _get_hue_node(j, hue_shift)));
      }
      A[i][j] = expf(A[i][j]);
    }

  // Solve A * x = y for lambdas
  pseudo_solve((float *)A, nodes, NODES, NODES, TRUE);

  // Interpolate data for all x : generate the LUT
  // WARNING: the LUT spans from [-pi; pi[ for consistency with the output of atan2f()
  for(int i = 0; i < LUT_ELEM; i++)
  {
    // i is directly the hue angle in degree since we sample the LUT
    // every degree.  We use un-offset angles here, since thue hue
    // offset is merely a GUI thing, only relevant for user-defined
    // nodes.
    const float hue = deg2radf((float)i * 360.0f / (float)LUT_ELEM) - M_PI_F;
    LUT[i] = 0.f;

    for(int k = 0; k < NODES; k++)
    {
      float result = 0;
      for(int l = 0; l < m; l++)
      {
        result += _cosine_coeffs(l, smoothing)
          * cosf(((float)l) * fabsf(hue - _get_hue_node(k, hue_shift)));
      }
      LUT[i] += nodes[k] * expf(result);
    }

    if(clip) LUT[i] = fmaxf(0.f, LUT[i]);
  }
}

void init_pipe(dt_iop_module_t *self,
               dt_dev_pixelpipe_t *pipe,
               dt_dev_pixelpipe_iop_t *piece)
{
  piece->data = dt_calloc_aligned(sizeof(dt_iop_colorequal_data_t));
  dt_iop_colorequal_data_t *d = piece->data;
  d->LUT_saturation = dt_alloc_align_float(LUT_ELEM);
  d->LUT_hue = dt_alloc_align_float(LUT_ELEM);
  d->LUT_brightness = dt_alloc_align_float(LUT_ELEM);
  d->gamut_LUT = dt_alloc_align_float(LUT_ELEM);
  d->lut_inited = FALSE;
  d->work_profile = NULL;
}


void cleanup_pipe(dt_iop_module_t *self,
                  dt_dev_pixelpipe_t *pipe,
                  dt_dev_pixelpipe_iop_t *piece)
{
  const dt_iop_colorequal_data_t *d = piece->data;
  dt_free_align(d->LUT_saturation);
  dt_free_align(d->LUT_hue);
  dt_free_align(d->LUT_brightness);
  dt_free_align(d->gamut_LUT);
  dt_free_align(piece->data);
  piece->data = NULL;
}

static inline void _pack_saturation(const dt_iop_colorequal_params_t *p,
                                    float array[NODES])
{
  array[0] = p->sat_red;
  array[1] = p->sat_orange;
  array[2] = p->sat_yellow;
  array[3] = p->sat_green;
  array[4] = p->sat_cyan;
  array[5] = p->sat_blue;
  array[6] = p->sat_lavender;
  array[7] = p->sat_magenta;
}

static inline void _pack_hue(const dt_iop_colorequal_params_t *p,
                             float array[NODES])
{
  array[0] = p->hue_red;
  array[1] = p->hue_orange;
  array[2] = p->hue_yellow;
  array[3] = p->hue_green;
  array[4] = p->hue_cyan;
  array[5] = p->hue_blue;
  array[6] = p->hue_lavender;
  array[7] = p->hue_magenta;

  for(int i = 0; i < NODES; i++)
    array[i] = deg2radf(array[i]);
}

static inline void _pack_brightness(const dt_iop_colorequal_params_t *p,
                                    float array[NODES])
{
  array[0] = p->bright_red;
  array[1] = p->bright_orange;
  array[2] = p->bright_yellow;
  array[3] = p->bright_green;
  array[4] = p->bright_cyan;
  array[5] = p->bright_blue;
  array[6] = p->bright_lavender;
  array[7] = p->bright_magenta;
}


void commit_params(dt_iop_module_t *self,
                   dt_iop_params_t *p1,
                   dt_dev_pixelpipe_t *pipe,
                   dt_dev_pixelpipe_iop_t *piece)
{
  const dt_iop_colorequal_params_t *p = (dt_iop_colorequal_params_t *)p1;
  dt_iop_colorequal_data_t *d = piece->data;

  d->white_level = exp2f(p->white_level);
  d->chroma_size = p->chroma_size;
  d->chroma_feathering = powf(10.f, -5.0f);
  d->param_size = p->param_size;
  d->param_feathering = powf(10.f, -6.0f);
  d->use_filter = p->use_filter;
  d->hue_shift = p->hue_shift;
  // default inflection point at a sat of 6%; allow selection up to ~60%
  d->threshold = -0.015f + 0.3f * sqrf(5.0f * p->threshold);
  d->contrast = p->contrast;
  float DT_ALIGNED_ARRAY sat_values[NODES];
  float DT_ALIGNED_ARRAY hue_values[NODES];
  float DT_ALIGNED_ARRAY bright_values[NODES];

  // FIXME only calc LUTs if necessary
  _pack_saturation(p, sat_values);
  _periodic_RBF_interpolate(sat_values,
                            M_PI_F,
                            d->LUT_saturation, d->hue_shift, TRUE);

  _pack_hue(p, hue_values);
  _periodic_RBF_interpolate(hue_values,
                            1.f / p->smoothing_hue * M_PI_F,
                            d->LUT_hue, d->hue_shift, FALSE);

  _pack_brightness(p, bright_values);

  d->max_brightness = 1.0f;
  for(int c = 0; c < NODES; c++)
    d->max_brightness = fmaxf(d->max_brightness, bright_values[c]);

  _periodic_RBF_interpolate(bright_values,
                            M_PI_F,
                            d->LUT_brightness, d->hue_shift, TRUE);

  // Check if the RGB working profile has changed in pipe
  // WARNING: this function is not triggered upon working profile change,
  // so the gamut boundaries are wrong until we change some param in this module
  struct dt_iop_order_iccprofile_info_t *const work_profile =
    dt_ioppr_get_pipe_current_profile_info(self, piece->pipe);
  if(work_profile == NULL)
    return;
  if(work_profile != d->work_profile)
  {
    d->lut_inited = FALSE;
    d->work_profile = work_profile;
  }

  // find the maximum chroma allowed by the current working gamut in
  // conjunction to hue this will be used to prevent users to mess up
  // their images by pushing chroma out of gamut
  if(!d->lut_inited)
  {
    dt_colormatrix_t input_matrix;
    dt_colormatrix_mul(input_matrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in);
    dt_UCS_22_build_gamut_LUT(input_matrix, d->gamut_LUT);
    d->lut_inited = TRUE;
  }
}


static inline void _build_dt_UCS_HSB_gradients
  (dt_aligned_pixel_t HSB,
   dt_aligned_pixel_t RGB,
   const struct dt_iop_order_iccprofile_info_t *work_profile,
   const float *const gamut_LUT)
{
  // Generate synthetic HSB gradients and convert to display RGB

  // First, gamut-map to ensure the requested HSB color is available in display gamut
  gamut_map_HSB(HSB, gamut_LUT, 1.f);

  // Then, convert to XYZ D65
  dt_aligned_pixel_t XYZ_D65 = { 1.0f, 1.0f, 1.0f, 1.0f };
  dt_UCS_HSB_to_XYZ(HSB, 1.f, XYZ_D65);

  if(work_profile)
  {
    dt_ioppr_xyz_to_rgb_matrix(XYZ_D65, RGB,
                               work_profile->matrix_out_transposed,
                               work_profile->lut_out,
                               work_profile->unbounded_coeffs_out,
                               work_profile->lutsize,
                               work_profile->nonlinearlut);
  }
  else
  {
    // Fall back to sRGB output and slow white point conversion
    dt_aligned_pixel_t XYZ_D50;
    XYZ_D65_to_D50(XYZ_D65, XYZ_D50);
    dt_XYZ_to_sRGB(XYZ_D50, RGB);
  }

  dt_vector_clip(RGB);
}

static inline void _draw_sliders_saturation_gradient
  (const float sat_min,
   const float sat_max,
   const float hue,
   const float brightness,
   GtkWidget *const slider,
   const struct dt_iop_order_iccprofile_info_t *work_profile,
   const float *const gamut_LUT)
{
  const float range = sat_max - sat_min;

  for(int i = 0; i < DT_BAUHAUS_SLIDER_MAX_STOPS; i++)
  {
    const float stop = ((float)i / (float)(DT_BAUHAUS_SLIDER_MAX_STOPS - 1));
    const float sat = sat_min + stop * range;
    dt_aligned_pixel_t RGB = { 1.0f, 1.0f, 1.0f, 1.0f };
    _build_dt_UCS_HSB_gradients((dt_aligned_pixel_t){ hue, sat, brightness, 0.f },
                                RGB, work_profile, gamut_LUT);
    dt_bauhaus_slider_set_stop(slider, stop, RGB[0], RGB[1], RGB[2]);
  }
}

static inline void _draw_sliders_hue_gradient
  (const float sat,
   const float hue,
   const float brightness,
   GtkWidget *const slider,
   const struct dt_iop_order_iccprofile_info_t *work_profile,
   const float *const gamut_LUT)
{
  const float hue_min = hue - M_PI_F;

  for(int i = 0; i < DT_BAUHAUS_SLIDER_MAX_STOPS; i++)
  {
    const float stop = ((float)i / (float)(DT_BAUHAUS_SLIDER_MAX_STOPS - 1));
    const float hue_temp = hue_min + stop * DT_2PI_F;
    dt_aligned_pixel_t RGB = {  1.0f, 1.0f, 1.0f, 1.0f };
    _build_dt_UCS_HSB_gradients((dt_aligned_pixel_t){ hue_temp, sat, brightness, 0.f },
                                RGB, work_profile, gamut_LUT);
    dt_bauhaus_slider_set_stop(slider, stop, RGB[0], RGB[1], RGB[2]);
  }
}

static inline void _draw_sliders_brightness_gradient
  (const float sat,
   const float hue,
   GtkWidget *const slider,
   const struct dt_iop_order_iccprofile_info_t *work_profile,
   const float *const gamut_LUT)
{
  for(int i = 0; i < DT_BAUHAUS_SLIDER_MAX_STOPS; i++)
  {
    const float stop = ((float)i / (float)(DT_BAUHAUS_SLIDER_MAX_STOPS - 1))
      * (1.f - 0.001f);
    dt_aligned_pixel_t RGB = {  1.0f, 1.0f, 1.0f, 1.0f };
    _build_dt_UCS_HSB_gradients((dt_aligned_pixel_t){ hue, sat, stop + 0.001f, 0.f },
                                RGB, work_profile, gamut_LUT);
    dt_bauhaus_slider_set_stop(slider, stop, RGB[0], RGB[1], RGB[2]);
  }
}

static inline void _init_sliders(const dt_iop_module_t *self)
{
  const dt_iop_colorequal_gui_data_t *g = self->gui_data;
  const dt_iop_colorequal_params_t *p = self->params;

  // Saturation sliders
  for(int k = 0; k < NODES; k++)
  {
    GtkWidget *const slider = g->sat_sliders[k];
    _draw_sliders_saturation_gradient(0.f, g->max_saturation, _get_hue_node(k, p->hue_shift),
                                      SLIDER_BRIGHTNESS, slider,
                                      g->white_adapted_profile,
                                      g->gamut_LUT);
    dt_bauhaus_slider_set_format(slider, "%");
    dt_bauhaus_slider_set_offset(slider, -100.0f);
    dt_bauhaus_slider_set_digits(slider, 2);
    gtk_widget_queue_draw(slider);
  }

  // Hue sliders
  for(int k = 0; k < NODES; k++)
  {
    GtkWidget *const slider = g->hue_sliders[k];
    _draw_sliders_hue_gradient(g->max_saturation, _get_hue_node(k, p->hue_shift),
                               SLIDER_BRIGHTNESS, slider,
                               g->white_adapted_profile,
                               g->gamut_LUT);
    dt_bauhaus_slider_set_format(slider, "°");
    dt_bauhaus_slider_set_digits(slider, 2);
    gtk_widget_queue_draw(slider);
  }

  // Brightness sliders
  for(int k = 0; k < NODES; k++)
  {
    GtkWidget *const slider = g->bright_sliders[k];
    _draw_sliders_brightness_gradient(g->max_saturation, _get_hue_node(k, p->hue_shift),
                                      slider,
                                      g->white_adapted_profile,
                                      g->gamut_LUT);
    dt_bauhaus_slider_set_format(slider, "%");
    dt_bauhaus_slider_set_offset(slider, -100.0f);
    dt_bauhaus_slider_set_digits(slider, 2);
    gtk_widget_queue_draw(slider);
  }
}

static void _init_graph_backgrounds(dt_iop_colorequal_gui_data_t *g,
                                    const float graph_width,
                                    const float graph_height,
                                    const float *const restrict gamut_LUT)
{
  const int gwidth = graph_width;
  const int gheight = graph_height;
  const int stride = cairo_format_stride_for_width(CAIRO_FORMAT_RGB24, gwidth);
  const float max_saturation = g->max_saturation;

  for(int c = 0; c < NUM_CHANNELS; c++)
  {
    if(g->b_data[c])
      free(g->b_data[c]);
    g->b_data[c] = malloc(stride * gheight);

    if(g->b_surface[c])
      cairo_surface_destroy(g->b_surface[c]);
    g->b_surface[c] = cairo_image_surface_create_for_data(g->b_data[c], CAIRO_FORMAT_RGB24, gwidth, gheight, stride);
  }

  DT_OMP_FOR(collapse(2))
  for(int i = 0; i < gheight; i++)
  {
    for(int j = 0; j < gwidth; j++)
    {
      const size_t idx = i * stride + j * 4;
      const float x = 360.0f * (float)(gwidth - j - 1) / (graph_width - 1.0f) - 90.0f;
      const float y = 1.0f - (float)i / (graph_height - 1.0f);
      const float hue = (x < -180.0f) ? _conventional_hue_deg_to_ucs_rad(x +180.0f) : _conventional_hue_deg_to_ucs_rad(x);
      const float hhue = hue - (y - 0.5f) * DT_2PI_F;

      dt_aligned_pixel_t RGB;
      dt_aligned_pixel_t HSB[NUM_CHANNELS] = {{ hhue, max_saturation,     SLIDER_BRIGHTNESS,              1.0f },
                                              { hue,  max_saturation * y, SLIDER_BRIGHTNESS,              1.0f },
                                              { hue,  max_saturation,     1.25f * SLIDER_BRIGHTNESS * y,  1.0f } };

      for(int k = 0; k < NUM_CHANNELS; k++)
      {
        _build_dt_UCS_HSB_gradients(HSB[k], RGB, g->white_adapted_profile, gamut_LUT);
        for_three_channels(c)
          g->b_data[k][idx + c] = roundf(CLIP(RGB[c]) * 255.f);
      }
    }
  }
  g->gradients_cached = TRUE;
}

void reload_defaults(dt_iop_module_t *self)
{
  // we might be called from presets update infrastructure => there is no image
  if(!self->dev || !dt_is_valid_imgid(self->dev->image_storage.id)) return;

  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  if(g)
  {
    // reset masking
    dt_bauhaus_widget_set_quad_active(g->param_size, FALSE);
    dt_bauhaus_widget_set_quad_active(g->threshold, FALSE);
    dt_iop_gui_enter_critical_section(self);
    g->mask_mode = 0;
    dt_iop_gui_leave_critical_section(self);
  }
}

void init_presets(dt_iop_module_so_t *self)
{
  // bleach bypass
  dt_iop_colorequal_params_t p1 =
    { .threshold       = 0.0f,
      .smoothing_hue   = 1.0f,
      .contrast        = 0.0f,
      .white_level     = 1.0f,
      .chroma_size     = 1.5f,
      .param_size      = 1.0f,
      .use_filter      = TRUE,

      .sat_red         = 1.0f - 0.2215f,
      .sat_orange      = 1.0f - 0.1772f,
      .sat_yellow      = 1.0f - 0.3861f,
      .sat_green       = 1.0f - 0.3924f,
      .sat_cyan        = 1.0f - 0.4557f,
      .sat_blue        = 1.0f - 0.4177f,
      .sat_lavender    = 1.0f - 0.2468f,
      .sat_magenta     = 1.0f - 0.2532f,

      .hue_red         = 15.46f,
      .hue_orange      = 0.0f,
      .hue_yellow      = -2.21f,
      .hue_green       = 28.72f,
      .hue_cyan        = 16.57f,
      .hue_blue        = 0.0f,
      .hue_lavender    = 0.0f,
      .hue_magenta     = 0.0f,

      .bright_red      = 1.0f - 0.250f,
      .bright_orange   = 1.0f - 0.250f,
      .bright_yellow   = 1.0f - 0.250f,
      .bright_green    = 1.0f - 0.350f,
      .bright_cyan     = 1.0f - 0.350f,
      .bright_blue     = 1.0f - 0.250f,
      .bright_lavender = 1.0f - 0.250f,
      .bright_magenta  = 1.0f - 0.250f,

      .hue_shift       = 0.0f
    };

  dt_gui_presets_add_generic(_("bleach bypass"), self->op,
                             self->version(), &p1, sizeof(p1),
                             TRUE, DEVELOP_BLEND_CS_RGB_SCENE);

  // Kodachrome 64 like
  dt_iop_colorequal_params_t p2 =
    { .threshold       = 0.193f,
      .smoothing_hue   = 1.0f,
      .contrast        = -0.345f,
      .white_level     = 1.0f,
      .chroma_size     = 1.5f,
      .param_size      = 19.0f,
      .use_filter      = TRUE,

      .sat_red         = 1.0f + 0.2390f,
      .sat_orange      = 1.0f + 0.0377f,
      .sat_yellow      = 1.0f - 0.1761f,
      .sat_green       = 1.0f - 0.1635f,
      .sat_cyan        = 1.0f - 0.0126f,
      .sat_blue        = 1.0f + 0.0126f,
      .sat_lavender    = 1.0f - 0.0000f,
      .sat_magenta     = 1.0f + 0.1384f,

      .hue_red         = -2.20f,
      .hue_orange      = -17.56f,
      .hue_yellow      = -3.29f,
      .hue_green       = 32.93f,
      .hue_cyan        = 14.27f,
      .hue_blue        = 6.59f,
      .hue_lavender    = -7.68f,
      .hue_magenta     = 0.0f,

      .bright_red      = 1.0f - 0.0063f,
      .bright_orange   = 1.0f + 0.1824f,
      .bright_yellow   = 1.0f - 0.1950f,
      .bright_green    = 1.0f - 0.2390f,
      .bright_cyan     = 1.0f - 0.2453f,
      .bright_blue     = 1.0f + 0.0377f,
      .bright_lavender = 1.0f - 0.1572f,
      .bright_magenta  = 1.0f - 0.1384f,

      .hue_shift       = 0.0f
    };

  dt_gui_presets_add_generic(_("Kodachrome 64 like"), self->op,
                             self->version(), &p2, sizeof(p2),
                             TRUE, DEVELOP_BLEND_CS_RGB_SCENE);

  // Kodak Portra 400
  dt_iop_colorequal_params_t p3 =
    { .threshold       = 0.199f,
      .smoothing_hue   = 1.0f,
      .contrast        = -0.375f,
      .white_level     = 1.0f,
      .chroma_size     = 1.5f,
      .param_size      = 1.0f,
      .use_filter      = TRUE,

      .sat_red         = 1.0f + 0.0692f,
      .sat_orange      = 1.0f + 0.0503f,
      .sat_yellow      = 1.0f - 0.0000f,
      .sat_green       = 1.0f - 0.0000f,
      .sat_cyan        = 1.0f - 0.0000f,
      .sat_blue        = 1.0f - 0.0000f,
      .sat_lavender    = 1.0f - 0.0000f,
      .sat_magenta     = 1.0f - 0.0000f,

      .hue_red         = 9.88f,
      .hue_orange      = -4.39,
      .hue_yellow      = 15.37f,
      .hue_green       = 8.78f,
      .hue_cyan        = 2.20f,
      .hue_blue        = -19.76f,
      .hue_lavender    = -3.29f,
      .hue_magenta     = 0.0f,

      .bright_red      = 1.0f + 0.0881f,
      .bright_orange   = 1.0f + 0.0629f,
      .bright_yellow   = 1.0f + 0.0629f,
      .bright_green    = 1.0f - 0.1069f,
      .bright_cyan     = 1.0f - 0.1069f,
      .bright_blue     = 1.0f - 0.1006f,
      .bright_lavender = 1.0f - 0.0189f,
      .bright_magenta  = 1.0f - 0.0000f,

      .hue_shift       = -23.0f
    };

  dt_gui_presets_add_generic(_("Kodak Portra 400 like"), self->op,
                             self->version(), &p3, sizeof(p3),
                             TRUE, DEVELOP_BLEND_CS_RGB_SCENE);

  // Teal & Orange
  dt_iop_colorequal_params_t p4 =
    { .threshold       = 0.184f,
      .smoothing_hue   = 0.52f,
      .contrast        = -0.3f,
      .white_level     = 1.0f,
      .chroma_size     = 1.5f,
      .param_size      = 1.0f,
      .use_filter      = TRUE,

      .sat_red         = 1.0f + 0.1572f,
      .sat_orange      = 1.0f - 0.0063f,
      .sat_yellow      = 1.0f - 0.3270f,
      .sat_green       = 1.0f - 0.0377f,
      .sat_cyan        = 1.0f - 0.0000f,
      .sat_blue        = 1.0f + 0.0126f,
      .sat_lavender    = 1.0f - 0.0000f,
      .sat_magenta     = 1.0f - 0.0000f,

      .hue_red         = 15.37f,
      .hue_orange      = -24.15f,
      .hue_yellow      = 75.74f,
      .hue_green       = 42.81f,
      .hue_cyan        = 2.20f,
      .hue_blue        = -36.22f,
      .hue_lavender    = 2.20f,
      .hue_magenta     = 42.81f,

      .bright_red      = 1.0f - 0.0000f,
      .bright_orange   = 1.0f - 0.0000f,
      .bright_yellow   = 1.0f - 0.0000f,
      .bright_green    = 1.0f - 0.0000f,
      .bright_cyan     = 1.0f - 0.0000f,
      .bright_blue     = 1.0f - 0.0000f,
      .bright_lavender = 1.0f - 0.0000f,
      .bright_magenta  = 1.0f - 0.0000f,

      .hue_shift       = 0.0f
    };

  dt_gui_presets_add_generic(_("teal & orange"), self->op,
                             self->version(), &p4, sizeof(p4),
                             TRUE, DEVELOP_BLEND_CS_RGB_SCENE);
}

/* in_mask_editing — returns TRUE when the mask editor UI is visible
 * (form_gui exists and form_visible is set), so that the correction
 * cursor can be hidden consistently with tone equalizer behavior.
 */
static gboolean in_mask_editing(const dt_iop_module_t *self)
{
  const dt_develop_t *dev = self->dev;
  return dev->form_gui && dev->form_visible;
}

/* _switch_cursors — mirrors the tone equalizer's on-canvas cursor
 * handling: hide the native GTK cursor so only our own indicator
 * (gui_post_expose) is visible while a valid reading is available,
 * whether or not the preview pipe is still (re)computing.  No busy/wait
 * animation is shown while hovering.  Falls back to the default cursor
 * otherwise (mask editing, module not focused, no valid reading yet).
 */
static void _switch_cursors(dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  if(!g || !self->dev->gui_attached) return;

  // Editing a mask (brush/path/etc.) or canvas otherwise not interactive:
  // leave the default cursor alone.
  if(in_mask_editing(self) || dt_iop_canvas_not_sensitive(self->dev))
  {
    dt_control_change_cursor("default");
    return;
  }

  if(!self->expanded)
    return; // module not focused: let the app decide

  if(g->cursor_valid)
  {
    // valid reading: hide the native cursor and rely on the custom
    // indicator drawn by gui_post_expose, whatever the pipe processing
    // state (no busy animation while hovering)
    dt_control_change_cursor("none");
  }
  else
  {
    dt_control_change_cursor("default");
  }
}

void gui_focus(dt_iop_module_t *self, gboolean in)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  if(in)
  {
    // Opening/focusing the module does not by itself dirty the pipe, so the
    // shared under-cursor buffer can still be NULL/stale (gui_init() always
    // resets it). Kick a preview-only reprocess so hovering works right
    // away instead of only after some unrelated trigger happens to also
    // reprocess the preview pipe.
    if(!dt_preview_data_is_fresh(&g->pd) && !g->reprocess_pending)
    {
      dt_iop_gui_enter_critical_section(self);
      g->reprocess_pending = TRUE;
      dt_iop_gui_leave_critical_section(self);
      dt_dev_reprocess_preview(self->dev, self->iop_order);
    }
    _switch_cursors(self);
  }
  else
  {
    dt_iop_color_picker_reset(self, FALSE);
    dt_iop_gui_enter_critical_section(self);
    const gboolean buttons = g->mask_mode != 0;
    g->mask_mode = 0;
    g->cursor_valid = FALSE; // disables Gaussian mode when module loses focus
    g->reprocess_pending = FALSE;
    dt_iop_gui_leave_critical_section(self);

    dt_bauhaus_widget_set_quad_active(g->param_size, FALSE);
    dt_bauhaus_widget_set_quad_active(g->threshold, FALSE);
    dt_bauhaus_widget_set_quad_active(g->hue_shift, FALSE);
    dt_preview_data_invalidate(&g->pd);
    if(buttons) dt_dev_reprocess_center(self->dev, self->iop_order);
    _switch_cursors(self);
    dt_control_queue_redraw_center();
  }
}

#define MINJZ 0.0001f
static inline float _get_hueval(const float hue)
{
  const float b = hue - ANGLE_SHIFT / 360.0f;
  return b < 0.0f ? b + 1.0f : b;
}

static void _draw_color_picker(const dt_iop_module_t *self,
                               cairo_t *cr,
                               dt_iop_colorequal_params_t *p,
                               dt_iop_colorequal_gui_data_t *g,
                               const double width,
                               const double height)
{
  // only visualize for decent brightness & saturation
  if(self->picked_color[0] < MINJZ || self->picked_color[1] < MINJZ)
    return;

  float mean_alpha = 0.6f;

  float hav  = self->picked_color[2];
  float hmax = self->picked_color_max[2];
  float hmin = self->picked_color_min[2];

  const float hava  = self->picked_color[3];
  const float hmina = self->picked_color_min[3];
  const float hmaxa = self->picked_color_max[3];

  if(hmax - hmin > hmaxa - hmina)
  {
    hmax = hmaxa < 0.5f ? hmaxa + 0.5f  : hmaxa - 0.5f;
    hmin = hmina < 0.5f ? hmina + 0.5f  : hmina - 0.5f;
    hav  = hava < 0.5f  ? hava + 0.5f   : hava - 0.5f;
  }

  const float xmin = width * _get_hueval(hmin);
  const float xmax = width * _get_hueval(hmax);
  if(xmax != xmin)
  {
    cairo_set_source_rgba(cr, 1.0, 1.0, 1.0, 0.3);
    mean_alpha -= 0.3f;
    if(xmax > xmin)
      cairo_rectangle(cr, xmin, 0.0, xmax - xmin, height);
    else
    {
      cairo_rectangle(cr, 0.0, 0.0, xmax, height);
      cairo_rectangle(cr, xmin, 0.0, width - xmin, height);
    }
    cairo_fill(cr);
  }

  cairo_set_source_rgba(cr, 1.0, 1.0, 1.0, mean_alpha);
  const float xav = width * _get_hueval(hav);
  cairo_move_to(cr, xav, 0.0);
  cairo_line_to(cr, xav, height);
  cairo_stroke(cr);
}
#undef MINJZ

static gboolean _iop_colorequalizer_draw(GtkWidget *widget,
                                         cairo_t *crf,
                                         const dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  dt_iop_colorequal_params_t *p = self->params;

  // Cache the graph objects to avoid recomputing all the view at each redraw
  GtkAllocation allocation;
  gtk_widget_get_allocation(widget, &allocation);
  GtkStyleContext *context = gtk_widget_get_style_context(widget);

  cairo_surface_t *cst = dt_cairo_image_surface_create(CAIRO_FORMAT_ARGB32,
                                                       allocation.width,
                                                       allocation.height);
  PangoFontDescription *desc = dt_gui_get_font();
  cairo_t *cr = cairo_create(cst);
  PangoLayout *layout = pango_cairo_create_layout(cr);

  const gint font_size = pango_font_description_get_size(desc);
  pango_font_description_set_size(desc, 0.95 * font_size);
  pango_layout_set_font_description(layout, desc);
  pango_cairo_context_set_resolution(pango_layout_get_context(layout), darktable.gui->dpi);

  char text[256];

  // Get the text line height for spacing
  PangoRectangle ink;
  snprintf(text, sizeof(text), "X");
  pango_layout_set_text(layout, text, -1);
  pango_layout_get_pixel_extents(layout, &ink, NULL);
  const float line_height = ink.height;

  const float inset = DT_PIXEL_APPLY_DPI(4);
  const float margin_top = inset;
  const float margin_bottom = line_height + 2.0 * inset;
  const float margin_left = 0.0;
  const float margin_right = 0.0;

  const float graph_width =
    allocation.width - margin_right - margin_left;   // align the right border on sliders
  const float graph_height =
    allocation.height - margin_bottom - margin_top; // give room to nodes
  g->graph_height = graph_height;

  gtk_render_background(context, cr, 0.0, 0.0, allocation.width, allocation.height);

  // draw x gradient as axis legend
  cairo_pattern_t *grad = cairo_pattern_create_linear(margin_left, 0.0, graph_width, 0.0);
  if(g->gamut_LUT)
  {
    for(int k = 0; k < 360; k++)
    {
      const float hue = _conventional_hue_deg_to_ucs_rad((float)k);
      dt_aligned_pixel_t RGB = { 1.f };
      _build_dt_UCS_HSB_gradients((dt_aligned_pixel_t){ hue, g->max_saturation,
                                                        SLIDER_BRIGHTNESS, 1.0f },
        RGB, g->white_adapted_profile, g->gamut_LUT);
      cairo_pattern_add_color_stop_rgba(grad, (double)k / 360.0, RGB[0], RGB[1], RGB[2], 1.0);
    }
  }

  cairo_set_line_width(cr, 0.0);
  cairo_rectangle(cr, margin_left, graph_height + 2 * inset, graph_width, line_height);
  cairo_set_source(cr, grad);
  cairo_fill(cr);
  cairo_pattern_destroy(grad);

  // set the graph as the origin of the coordinates
  cairo_translate(cr, margin_left, margin_top);

  // possibly recalculate and draw background
  if(!g->gradients_cached)
    _init_graph_backgrounds(g, graph_width, graph_height, g->gamut_LUT);

  cairo_rectangle(cr, 0.0, 0.0, graph_width, graph_height);
  cairo_set_source_surface(cr, g->b_surface[g->channel], 0.0, 0.0);
  cairo_fill(cr);

  cairo_rectangle(cr, 0, 0, graph_width, graph_height);
  cairo_clip(cr);

  // draw grid
  cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
  cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(0.5));
  set_color(cr, darktable.bauhaus->graph_border);
  dt_draw_grid(cr, 8, 0, 0, graph_width, graph_height);

  // draw ground level
  set_color(cr, darktable.bauhaus->graph_fg);
  cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.0));
  dt_draw_line(cr, 0.0, 0.5 * graph_height, graph_width, 0.5 * graph_height);
  cairo_stroke(cr);

  const GdkRGBA fg_color = darktable.bauhaus->graph_fg;
  cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(2.0));
  set_color(cr, fg_color);

  // Build the curve LUT and plotting params for the current channel
  g->LUT = dt_alloc_align_float(LUT_ELEM);
  float DT_ALIGNED_ARRAY values[NODES];
  float smoothing;
  float offset;
  float factor;
  gboolean clip;

  switch(g->channel)
  {
    case SATURATION:
    {
      _pack_saturation(p, values);
      smoothing = 1.0f;
      clip = TRUE;
      offset = 1.f;
      factor = 0.5f;
      break;
    }
    case HUE:
    {
      _pack_hue(p, values);
      smoothing = p->smoothing_hue;
      clip = FALSE;
      offset = 0.5f;
      factor = 1.f / DT_2PI_F;
      break;
    }
    case BRIGHTNESS:
    default:
    {
      _pack_brightness(p, values);
      smoothing = 1.0f;
      clip = TRUE;
      offset = 1.0f;
      factor = 0.5f;
      break;
    }
  }

  _periodic_RBF_interpolate(values, 1.f / smoothing * M_PI_F, g->LUT, 0.0f, clip);

  const float dx = p->hue_shift / 360.0f;
  const int first = -dx * 360;
  for(int k = first; k < (360 + first); k++)
  {
    const float x = ((float)k / (float)(360 - 1) + dx) * graph_width;
    float hue = _conventional_hue_deg_to_ucs_rad(k);
    hue = (hue < M_PI_F) ? hue : -DT_2PI_F + hue; // The LUT is defined in [-pi; pi[
    const float y = (offset - lookup_gamut(g->LUT, hue) * factor) * graph_height;

    if(k == first)
      cairo_move_to(cr, x, y);
    else
      cairo_line_to(cr, x, y);
  }
  cairo_stroke(cr);

  // draw nodes positions
  for(int k = 0; k < NODES + 1; k++)
  {
    float hue = _get_hue_node(k, 0.0f); // in radians
    const float xn = (k / ((float)NODES) + dx    ) * graph_width;
    hue = (hue < M_PI_F) ? hue : -DT_2PI_F + hue; // The LUT is defined in [-pi; pi[
    const float yn = (offset - lookup_gamut(g->LUT, hue) * factor) * graph_height;

    // fill bars
    cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(6));
    set_color(cr, darktable.bauhaus->color_fill);
    dt_draw_line(cr, xn, 0.5 * graph_height, xn, yn);
    cairo_stroke(cr);

    // bullets
    cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(3));
    cairo_arc(cr, xn, yn, DT_PIXEL_APPLY_DPI(4), 0, 2. * M_PI);
    set_color(cr, darktable.bauhaus->graph_fg);
    cairo_stroke_preserve(cr);

    // record nodes positions for motion events
    g->points[k][0] = xn;
    g->points[k][1] = yn;

    if(g->on_node && g->selected == k % NODES)
      set_color(cr, darktable.bauhaus->graph_fg);
    else
      set_color(cr, darktable.bauhaus->graph_bg);

    cairo_fill(cr);
  }

  // Draw a white vertical line showing the hue currently under the mouse
  // cursor on the main image (mirroring the tone equalizer's exposure
  // cursor line).  The graph x-axis is linear in conventional GUI degrees,
  // shifted by hue_shift, so the hue is mapped directly to x.
  if(self->enabled && g->cursor_valid)
  {
    float x_cursor = (g->cursor_hue / 360.0f + dx) * graph_width;
    x_cursor = fmodf(x_cursor, graph_width); // hue is periodic
    if(x_cursor < 0.0f) x_cursor += graph_width;

    cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.5));
    set_color(cr, darktable.bauhaus->graph_fg);
    cairo_move_to(cr, x_cursor, 0.0);
    cairo_line_to(cr, x_cursor, graph_height);
    cairo_stroke(cr);
  }

  dt_free_align(g->LUT);

  if(self->enabled && self->request_color_pick == DT_REQUEST_COLORPICK_MODULE)
    _draw_color_picker(self, cr, p, g, (double)graph_width, (double)graph_height);

  cairo_restore(cr);
  // restore font size
  pango_font_description_set_size(desc, font_size);
  pango_layout_set_font_description(layout, desc);

  cairo_destroy(cr);
  cairo_set_source_surface(crf, cst, 0, 0);
  cairo_paint(crf);
  cairo_surface_destroy(cst);
  g_object_unref(layout);
  pango_font_description_free(desc);
  return FALSE;
}

static void _pipe_RGB_to_Ych(dt_iop_module_t *self,
                             dt_dev_pixelpipe_t *pipe,
                             const dt_aligned_pixel_t RGB,
                             dt_aligned_pixel_t Ych)
{
  const struct dt_iop_order_iccprofile_info_t *const work_profile =
    dt_ioppr_get_pipe_current_profile_info(self, pipe);
  if(work_profile == NULL) return; // no point

  dt_aligned_pixel_t XYZ_D50 = { 0.0f, 0.0f, 0.0f, 0.0f };
  dt_aligned_pixel_t XYZ_D65 = { 0.0f, 0.0f, 0.0f, 0.0f };

  dt_ioppr_rgb_matrix_to_xyz(RGB, XYZ_D50, work_profile->matrix_in_transposed,
                             work_profile->lut_in,
                             work_profile->unbounded_coeffs_in,
                             work_profile->lutsize,
                             work_profile->nonlinearlut);
  XYZ_D50_to_D65(XYZ_D50, XYZ_D65);
  XYZ_to_Ych(XYZ_D65, Ych);

  if(Ych[2] < 0.f)
    Ych[2] = DT_2PI_F + Ych[2];
}

/* mouse_moved — updates the hue read under the mouse cursor.
 *
 * Reads directly from the preview buffer filled by process() to get
 * the UCS hue (radians) at the point under the cursor. No dependency
 * on the GTK color picker (asynchronous and unpredictable).
 *
 * The hue stored in the preview buffer (g->pd.buf) is in UCS radians [-π ; π]
 * (from atan2f(V, U) in dt_UCS_LUV_to_JCH).
 *
 * Conversion to GUI degrees:
 *   ucs_rad = deg2rad(gui_deg + ANGLE_SHIFT)
 *   → gui_deg = rad2deg(ucs_rad) − ANGLE_SHIFT
 *
 * Returns 0 to let darktable propagate the event normally.
 */
int mouse_moved(dt_iop_module_t *self,
                const float pzx,
                const float pzy,
                const double pressure,
                const int which,
                const float zoom_scale)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  if(!g) return 0;

  // Disable cursor tracking when mask editor is visible
  dt_iop_gui_enter_critical_section(self);
  if(in_mask_editing(self))
  {
    g->cursor_valid = FALSE;
    dt_iop_gui_leave_critical_section(self);
    _switch_cursors(self);
    return 0;
  }

  // Read hue (component 0 of the stored HSB) from the preview buffer
  float hue_rad = 0.f;
  gboolean have_hue = FALSE;
  const float *buf    = g->pd.buf;
  const int    bwidth  = g->pd.width;
  const int    bheight = g->pd.height;
  if(buf != NULL && bwidth > 0 && bheight > 0)
  {
    const int cx = CLAMP((int)(pzx * bwidth),  0, bwidth  - 1);
    const int cy = CLAMP((int)(pzy * bheight), 0, bheight - 1);
    hue_rad = buf[3 * ((size_t)cy * bwidth + cx)];
    have_hue = TRUE;
  }
  const gboolean pending = g->reprocess_pending;
  if(!have_hue)
  {
    g->cursor_valid = FALSE;
     if(!pending)
      g->reprocess_pending = TRUE;
  }
  dt_iop_gui_leave_critical_section(self);

  if(!have_hue)
  {
    // The buffer is missing entirely (e.g. gui_init() just reset it, or the
    // module was never reprocessed on the preview pipe yet). Nothing else
    // will refill it on its own — ask for a preview reprocess, debounced so
    // we don't flood the pipeline while hovering with no data available.
    if(!pending)
      dt_dev_reprocess_preview(self->dev, self->iop_order);
    _switch_cursors(self);
    return 0;
  }

  // UCS hue in radians (may be in [-π ; π])
  if(hue_rad < 0.f) hue_rad += DT_2PI_F;

  // Convert to GUI degrees: inverse of _conventional_hue_deg_to_ucs_rad()
  dt_iop_gui_enter_critical_section(self);
  g->cursor_hue = hue_rad * (180.f / M_PI_F) - ANGLE_SHIFT;

  // Wrap into [0 ; 360[
  if(g->cursor_hue < 0.f)    g->cursor_hue += 360.f;
  if(g->cursor_hue >= 360.f) g->cursor_hue -= 360.f;

  // Store normalized cursor position [0..1] for gui_post_expose
  g->cursor_pos_x = pzx;
  g->cursor_pos_y = pzy;

  // Validate buffer freshness against the cumulative pipe hash.
  // cursor_valid is set TRUE only when the hash matches, so the GUI
  // indicator (gui_post_expose) and the graph Gaussian mode
  // (_area_scrolled_callback) never see stale pipeline data.
  g->cursor_valid = dt_preview_data_is_fresh(&g->pd);
  const gboolean cursor_valid = g->cursor_valid;
  const gboolean reprocess_pending = g->reprocess_pending;
  if(cursor_valid)
    g->reprocess_pending = FALSE;
  else if(!reprocess_pending)
    g->reprocess_pending = TRUE;
  dt_iop_gui_leave_critical_section(self);

  if(cursor_valid)
  {
    dt_control_queue_redraw_center();
  }
  else if(!reprocess_pending)
  {
    // Buffer exists but is stale (params changed since it was filled) —
    // same debounced reprocess request as above so tracking self-heals
    // instead of staying frozen until an unrelated trigger (e.g. scroll)
    // happens to kick a reprocess.
    dt_dev_reprocess_preview(self->dev, self->iop_order);
  }
  _switch_cursors(self);
  return 0;
}

/* mouse_leave — invalidates hue tracking when the mouse leaves the image.
 *
 * Without this, cursor_valid would remain TRUE with stale hue data,
 * and the scroll wheel would continue affecting sliders even outside the image.
 */
int mouse_leave(dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  if(!g) return 0;

  dt_iop_gui_enter_critical_section(self);
  g->cursor_valid = FALSE;
  dt_iop_gui_leave_critical_section(self);

  _switch_cursors(self);
  gtk_widget_queue_draw(GTK_WIDGET(g->area));
  dt_control_queue_redraw_center();

  return 1;
}

// Forward declarations: defined further down in the file, alongside the
// rest of the node/Gaussian-weighting helpers they belong with, but also
// needed here to draw the "% from neutral" readout in gui_post_expose().
static float *_get_param_ptr(dt_iop_colorequal_params_t *p,
                             const dt_iop_colorequal_channel_t channel,
                             const int k,
                             float *out_min,
                             float *out_max);
static float _gaussian_interp_value(const dt_iop_colorequal_params_t *p,
                                    const dt_iop_colorequal_gui_data_t *g,
                                    const float ref_hue_deg);

/* gui_post_expose — draws a color indicator circle over the image
 * showing the color under the cursor, plus a "% from neutral" (or, for
 * the hue channel, "° from neutral") readout of the active channel's
 * current correction at that hue.
 */
void gui_post_expose(dt_iop_module_t *self,
                     cairo_t *cr,
                     const float width,
                     const float height,
                     const float pointerx,
                     const float pointery,
                     const float zoom_scale)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  if(!g || !g->cursor_valid) return;

  // Hide cursor indicator when mask editor is visible
  if(in_mask_editing(self)) return;

  // Sampled display color under the cursor + contrasting line color,
  // shared with the tone equalizer's cursor via gui/draw.h.
  float bg_rgb[3];
  float frame_color[3];
  dt_draw_backbuf_contrast(self->dev, g->cursor_pos_x, g->cursor_pos_y,
                           bg_rgb, frame_color, 16.0f / (zoom_scale * width));

  // Position in full image coordinates
  const float cx = g->cursor_pos_x * width;
  const float cy = g->cursor_pos_y * height;

  // "% from neutral" (hue: "° from neutral") readout for the active
  // channel, using the same Gaussian blend scrolling would apply here.
  const dt_iop_colorequal_params_t *p = self->params;
  const float value = _gaussian_interp_value(p, g, g->cursor_hue);

  char text[64];
  // Wedge magnitude/direction, scaled to the shared cursor's ±45° range:
  // hue is an offset in [-180° ; 180°], sat/bright a gain in [0 ; 2] with
  // 1.0 = neutral (so value - 1.0 is already in [-1 ; 1]).
  float correction_norm;
  if(g->channel == HUE)
  {
    snprintf(text, sizeof(text), "%+.1f°", value); // value is already an offset from neutral (0°)
    correction_norm = value / 180.0f;
  }
  else
  {
    snprintf(text, sizeof(text), "%+.1f%%", (value - 1.0f) * 100.0f); // 1.0 = neutral gain
    correction_norm = value - 1.0f;
  }

  // Module input/output colors at the cursor, read from the shared
  // preview buffer (same HSB of the module *input* pixel that mouse_moved
  // and scrolled use, stored by process() from the pre-correction values).
  // The "out" color replays the exact process() correction math — the three
  // RBF LUTs from the current params combined as in STEP 4/5 of process().
  float in_color[3]  = { bg_rgb[0], bg_rgb[1], bg_rgb[2] };
  float out_color[3] = { bg_rgb[0], bg_rgb[1], bg_rgb[2] };

  dt_iop_gui_enter_critical_section(self);
  if(g->pd.buf && g->pd.width > 0 && g->pd.height > 0 && g->gamut_LUT)
  {
    const int p_cx = CLAMP((int)(g->cursor_pos_x * g->pd.width),  0, (int)g->pd.width  - 1);
    const int p_cy = CLAMP((int)(g->cursor_pos_y * g->pd.height), 0, (int)g->pd.height - 1);

    // Read the 3 HSB components under one lock, like mouse_moved does.
    float hue_in = 0.f, sat_in = 0.f, bright_in = 0.f;
    gboolean have_hsb = FALSE;
    const float *buf = g->pd.buf;
    if(buf)
    {
      const size_t idx = (size_t)p_cy * g->pd.width + p_cx;
      hue_in    = buf[3 * idx + 0];
      sat_in    = buf[3 * idx + 1];
      bright_in = buf[3 * idx + 2];
      have_hsb = TRUE;
    }

    dt_iop_gui_leave_critical_section(self);
    if(have_hsb)
    {
      // Rebuild the three RBF LUTs from the current params, exactly as
      // commit_params() does for the pipe data.
      float DT_ALIGNED_ARRAY sat_values[NODES];
      float DT_ALIGNED_ARRAY hue_values[NODES];
      float DT_ALIGNED_ARRAY bright_values[NODES];
      float DT_ALIGNED_ARRAY LUT_hue[LUT_ELEM];
      float DT_ALIGNED_ARRAY LUT_sat[LUT_ELEM];
      float DT_ALIGNED_ARRAY LUT_bright[LUT_ELEM];

      _pack_saturation(p, sat_values);
      _periodic_RBF_interpolate(sat_values, M_PI_F, LUT_sat, p->hue_shift, TRUE);
      _pack_hue(p, hue_values);
      _periodic_RBF_interpolate(hue_values, 1.f / p->smoothing_hue * M_PI_F,
                                LUT_hue, p->hue_shift, FALSE);
      _pack_brightness(p, bright_values);
      _periodic_RBF_interpolate(bright_values, M_PI_F, LUT_bright, p->hue_shift, TRUE);

      // Corrections as in process() STEP 3/4 (hue is an offset, sat a gain,
      // brightness a gain applied through b_corrections).
      const float corr_hue  = lookup_gamut(LUT_hue, hue_in);
      const float corr_sat  = lookup_gamut(LUT_sat, hue_in);
      const float b_corr    = sat_in * (lookup_gamut(LUT_bright, hue_in) - 1.0f);

      const float hue_out    = hue_in + corr_hue;
      const float sat_out    = MAX(0.f, sat_in * (1.f + SAT_EFFECT * (corr_sat - 1.f)));
      const float bright_out = MAX(0.f, bright_in * (1.f + BRIGHT_EFFECT * b_corr));

      // gamut-map + convert to display RGB, same path as the module's
      // sliders/graphs (g->white_adapted_profile may be NULL → sRGB fallback
      // inside _build_dt_UCS_HSB_gradients).
      dt_aligned_pixel_t RGB = { 1.f };
      _build_dt_UCS_HSB_gradients((dt_aligned_pixel_t){ hue_in, sat_in, bright_in, 1.0f },
                                  RGB, g->white_adapted_profile, g->gamut_LUT);
      in_color[0] = RGB[0];
      in_color[1] = RGB[1];
      in_color[2] = RGB[2];

      _build_dt_UCS_HSB_gradients((dt_aligned_pixel_t){ hue_out, sat_out, bright_out, 1.0f },
                                  RGB, g->white_adapted_profile, g->gamut_LUT);
      out_color[0] = RGB[0];
      out_color[1] = RGB[1];
      out_color[2] = RGB[2];
    }
  }
  else
    dt_iop_gui_leave_critical_section(self);

  dt_draw_correction_cursor(cr, cx, cy, zoom_scale, correction_norm,
                            frame_color,
                            in_color, FALSE,   // outer: module input color
                            out_color, FALSE,  // inner: module output color
                            text);

  // keep the graph's cursor indicator (white vertical line) in sync
  gtk_widget_queue_draw(GTK_WIDGET(g->area));
}

/* _get_param_ptr — returns a direct pointer to the parameter value
 * of node k for the active channel, along with its min/max bounds.
 *
 * Uses offsetof() for copy-free access to struct fields,
 * consistent with _pack_saturation / _pack_hue / _pack_brightness.
 *
 * Bounds per channel:
 *   HUE        : [-180° ; +180°]
 *   SATURATION : [  0.0 ;   2.0]  (multiplier, 1.0 = neutral)
 *   BRIGHTNESS : [  0.0 ;   2.0]  (multiplier, 1.0 = neutral)
 */
static float *_get_param_ptr(dt_iop_colorequal_params_t *p,
                             const dt_iop_colorequal_channel_t channel,
                             const int k,
                             float *out_min,
                             float *out_max)
{
  // Offsets in the struct — same order as the _pack_*() functions
  static const size_t sat_off[NODES] = {
    offsetof(dt_iop_colorequal_params_t, sat_red),
    offsetof(dt_iop_colorequal_params_t, sat_orange),
    offsetof(dt_iop_colorequal_params_t, sat_yellow),
    offsetof(dt_iop_colorequal_params_t, sat_green),
    offsetof(dt_iop_colorequal_params_t, sat_cyan),
    offsetof(dt_iop_colorequal_params_t, sat_blue),
    offsetof(dt_iop_colorequal_params_t, sat_lavender),
    offsetof(dt_iop_colorequal_params_t, sat_magenta) };

  static const size_t hue_off[NODES] = {
    offsetof(dt_iop_colorequal_params_t, hue_red),
    offsetof(dt_iop_colorequal_params_t, hue_orange),
    offsetof(dt_iop_colorequal_params_t, hue_yellow),
    offsetof(dt_iop_colorequal_params_t, hue_green),
    offsetof(dt_iop_colorequal_params_t, hue_cyan),
    offsetof(dt_iop_colorequal_params_t, hue_blue),
    offsetof(dt_iop_colorequal_params_t, hue_lavender),
    offsetof(dt_iop_colorequal_params_t, hue_magenta) };

  static const size_t bright_off[NODES] = {
    offsetof(dt_iop_colorequal_params_t, bright_red),
    offsetof(dt_iop_colorequal_params_t, bright_orange),
    offsetof(dt_iop_colorequal_params_t, bright_yellow),
    offsetof(dt_iop_colorequal_params_t, bright_green),
    offsetof(dt_iop_colorequal_params_t, bright_cyan),
    offsetof(dt_iop_colorequal_params_t, bright_blue),
    offsetof(dt_iop_colorequal_params_t, bright_lavender),
    offsetof(dt_iop_colorequal_params_t, bright_magenta) };

  char *base = (char *)p;
  switch(channel)
  {
    case HUE:
      *out_min = -180.f; *out_max = 180.f;
      return (float *)(base + hue_off[k]);
    case SATURATION:
      *out_min = 0.f; *out_max = 2.f;
      return (float *)(base + sat_off[k]);
    case BRIGHTNESS:
    default:
      *out_min = 0.f; *out_max = 2.f;
      return (float *)(base + bright_off[k]);
  }
}

static GtkWidget *_get_slider(const dt_iop_colorequal_gui_data_t *g, const int selected)
{
  GtkWidget *w = NULL;

  switch(g->channel)
  {
    case(SATURATION):
      w = g->sat_sliders[selected];
      break;
    case(HUE):
      w = g->hue_sliders[selected];
      break;
    case(BRIGHTNESS):
    default:
      w = g->bright_sliders[selected];
      break;
  }

  return w;
}

// Sigma (degrees) of the Gaussian weighting used both when adjusting
// sliders around the cursor hue and when reading back the interpolated
// value under the cursor for display.
#define GAUSSIAN_SIGMA_DEG 35.0f

/* Angular position of node k in GUI degrees [0 ; 360[, accounting for hue_shift. */
static inline float _node_hue_deg(const int k, const float hue_shift)
{
  const float node_ucs_rad = _get_hue_node(k, hue_shift);
  float node_deg = node_ucs_rad * (180.f / M_PI_F) - ANGLE_SHIFT;
  if(node_deg < 0.f)    node_deg += 360.f;
  if(node_deg >= 360.f) node_deg -= 360.f;
  return node_deg;
}

/* Minimum circular distance between two hues in degrees, in [0 ; 180°]. */
static inline float _hue_circular_dist_deg(const float a, const float b)
{
  float dist = fabsf(a - b);
  if(dist > 180.f) dist = 360.f - dist;
  return dist;
}

/* Gaussian weight for a given circular hue distance: 1.0 at center,
 * decays to 0 at large distance. Shared by the slider-adjustment path
 * and the under-cursor value readout so they can't drift apart. */
static inline float _gaussian_weight(const float dist_deg)
{
  const float inv2s2 = 1.0f / (2.0f * GAUSSIAN_SIGMA_DEG * GAUSSIAN_SIGMA_DEG);
  return expf(-(dist_deg * dist_deg) * inv2s2);
}

/* Gaussian-weighted blend of the active channel's current per-node values
 * around ref_hue_deg — the same weighting scrolling would apply, read
 * back rather than written, for the on-canvas "value at cursor" readout.
 */
static float _gaussian_interp_value(const dt_iop_colorequal_params_t *p,
                                    const dt_iop_colorequal_gui_data_t *g,
                                    const float ref_hue_deg)
{
  float wsum = 0.f, vsum = 0.f;

  for(int k = 0; k < NODES; k++)
  {
    const float node_deg = _node_hue_deg(k, p->hue_shift);
    const float dist = _hue_circular_dist_deg(ref_hue_deg, node_deg);
    const float weight = _gaussian_weight(dist);

    float vmin, vmax;
    const float *val = _get_param_ptr((dt_iop_colorequal_params_t *)p, g->channel, k, &vmin, &vmax);
    wsum += weight;
    vsum += weight * (*val);
  }

  return (wsum > 1e-6f) ? (vsum / wsum) : 0.f;
}

/* Apply a Gaussian-weighted adjustment to all sliders of the active
 * channel, centered on ref_hue_deg.
 * Nodes farther than sigma (35°) receive diminishing influence;
 * contributions below 1% are skipped.
 * Returns TRUE if any slider value changed.
 */
static gboolean _adjust_params_gaussian(dt_iop_module_t *self,
                                        dt_iop_colorequal_params_t *p,
                                        dt_iop_colorequal_gui_data_t *g,
                                        const float move,
                                        const float ref_hue_deg)
{
  gboolean changed = FALSE;

  for(int k = 0; k < NODES; k++)
  {
    const float node_deg = _node_hue_deg(k, p->hue_shift);
    const float dist = _hue_circular_dist_deg(ref_hue_deg, node_deg);

    const float weight = _gaussian_weight(dist);
    if(weight < 0.01f) continue; // negligible contribution

    float vmin, vmax;
    float *val = _get_param_ptr(p, g->channel, k, &vmin, &vmax);
    *val = CLAMP(*val + move * weight, vmin, vmax);

    // Update the slider — let the callback fire for redraw
    GtkWidget *w = _get_slider(g, k);
    if(w) dt_bauhaus_slider_set(w, *val);

    changed = TRUE;
  }

  if(changed)
  {
    dt_dev_add_history_item(self->dev, self, TRUE);
    gtk_widget_queue_draw(GTK_WIDGET(g->area));
  }

  return changed;
}

/* scrolled — IOP hook called by darktable when the scroll wheel is used
 * WHILE THE MOUSE IS OVER THE IMAGE in the darkroom (not over the GUI panel).
 *
 * This function — not _area_scrolled_callback — intercepts the event
 * before darktable sends it to the zoom handler.
 * Returning 1 consumes the event and BLOCKS image zoom.
 * Returning 0 lets darktable zoom normally.
 *
 * Logic:
 *   - Reads the hue directly from the cached preview buffer
 *     (not via mouse_moved, to avoid gating on pipeline hash)
 *   - If a valid hue is available under the cursor
 *     → applies Gaussian weighting to the active channel's sliders
 *     → returns 1 to block zoom
 *   - Otherwise → returns 0, normal zoom
 */
int scrolled(dt_iop_module_t *self,
             const float x,
             const float y,
             const int up,
             const uint32_t state)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;

  if(!g) return 0;

  // Alt+scroll: switch channel tab, matching the graph's Alt+scroll
  // behavior (_area_scrolled_callback). Handled before the hue lookup
  // below since switching tabs doesn't need a hue reading.
  if(dt_modifier_is(state, GDK_MOD1_MASK))
  {
    const int pages = gtk_notebook_get_n_pages(g->notebook);
    const int current = gtk_notebook_get_current_page(g->notebook);
    const int next = (current + (up ? 1 : -1) + pages) % pages;
    gtk_notebook_set_current_page(g->notebook, next);
    return 1; // consumes the event → blocks image zoom, same as the normal path
  }

  // Read the hue directly from the cached preview buffer (race-safe).
  // We do NOT call mouse_moved() here because that would gate on the
  // pipeline hash — scroll-based adjustment should work even with
  // slightly stale data rather than falling through to image zoom.
  float hue_rad = 0.f;
  gboolean have_hue = FALSE;

  dt_iop_gui_enter_critical_section(self);
  if(g->pd.buf && g->pd.width > 0 && g->pd.height > 0)
  {
    const int cx = CLAMP((int)(x * g->pd.width),  0, (int)g->pd.width  - 1);
    const int cy = CLAMP((int)(y * g->pd.height), 0, (int)g->pd.height - 1);
    have_hue = dt_preview_data_get(&g->pd, cx, cy, 0, &hue_rad);
  }
  if(!have_hue)
  {
    dt_iop_gui_leave_critical_section(self);
    return 0;
  }

  // Convert UCS hue → GUI degrees
  if(hue_rad < 0.f) hue_rad += DT_2PI_F;
  float hue_deg = hue_rad * (180.f / M_PI_F) - ANGLE_SHIFT;
  if(hue_deg < 0.f)    hue_deg += 360.f;
  if(hue_deg >= 360.f) hue_deg -= 360.f;
  g->cursor_hue = hue_deg;
  g->cursor_pos_x = x;
  g->cursor_pos_y = y;
  g->cursor_valid = TRUE;

  // Step: 1.0 for hue (°), 0.01 for sat/bright (%)
  // Ctrl for fine precision (÷10)
  const float base_step = (g->channel == HUE) ? 1.0f : 0.01f;
  dt_iop_gui_leave_critical_section(self);
  const float step = dt_modifier_is(state, GDK_CONTROL_MASK) ? base_step * 0.1f : base_step;
  // up=1 → scroll up → increase value
  const float move = up ? +step : -step;

  _adjust_params_gaussian(self, self->params, g, move, g->cursor_hue);
  _switch_cursors(self);

  return 1; // consumes the event → BLOCKS image zoom
}

void color_picker_apply(dt_iop_module_t *self,
                        GtkWidget *picker,
                        dt_dev_pixelpipe_t *pipe)
{
  const dt_iop_colorequal_gui_data_t *g = self->gui_data;
  dt_iop_colorequal_params_t *p = self->params;

  if(picker == g->white_level)
  {
    dt_aligned_pixel_t max_Ych = { 0.0f, 0.0f, 0.0f, 0.0f };
    _pipe_RGB_to_Ych(self, pipe, (const float *)self->picked_color_max, max_Ych);

    DT_ENTER_GUI_UPDATE();
    p->white_level = log2f(max_Ych[0]);
    dt_bauhaus_slider_set(g->white_level, p->white_level);
    DT_LEAVE_GUI_UPDATE();

    gui_changed(self, picker, NULL);
    dt_dev_add_history_item(darktable.develop, self, TRUE);
  }
  else
    gtk_widget_queue_draw(GTK_WIDGET(g->area));
}

static void _masking_callback_p(GtkWidget *quad, dt_iop_module_t *self)
{
  DT_GUARD_GUI_UPDATE();
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  dt_bauhaus_widget_set_quad_active(g->threshold, FALSE);
  dt_iop_gui_enter_critical_section(self);
  g->mask_mode = (dt_bauhaus_widget_get_quad_active(quad)) ? g->channel + 1 : 0;
  dt_iop_gui_leave_critical_section(self);
  dt_dev_reprocess_center(self->dev, self->iop_order);
}

static void _masking_callback_t(GtkWidget *quad, dt_iop_module_t *self)
{
  DT_GUARD_GUI_UPDATE();
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  dt_bauhaus_widget_set_quad_active(g->param_size, FALSE);
  dt_iop_gui_enter_critical_section(self);
  g->mask_mode = (dt_bauhaus_widget_get_quad_active(quad)) ? GRAD_SWITCH + g->channel + 1 : 0;
  dt_iop_gui_leave_critical_section(self);
  dt_dev_reprocess_center(self->dev, self->iop_order);
}

static void _channel_tabs_switch_callback(GtkNotebook *notebook,
                                          GtkWidget *page,
                                          const guint page_num,
                                          dt_iop_module_t *self)
{
  DT_GUARD_GUI_UPDATE();
  dt_iop_colorequal_gui_data_t *g = self->gui_data;

  dt_iop_gui_enter_critical_section(self);
  // the tabs are only the color channels, so map the page straight to
  // the active channel and redraw the graph
  g->channel = (dt_iop_colorequal_channel_t)page_num;
  g->page_num = page_num;

  const int old_mask_mode = g->mask_mode;
  dt_iop_gui_leave_critical_section(self);

  const gboolean masking_p = dt_bauhaus_widget_get_quad_active(g->param_size);
  const gboolean masking_t = dt_bauhaus_widget_get_quad_active(g->threshold);
  gui_update(self);

  dt_bauhaus_widget_set_quad_active(g->param_size, masking_p);
  dt_bauhaus_widget_set_quad_active(g->threshold, masking_t);

  dt_iop_gui_enter_critical_section(self);
  g->mask_mode = masking_p ? g->channel + 1 : (masking_t ? GRAD_SWITCH + g->channel + 1 : 0);
  const gboolean changed_mode = g->mask_mode != old_mask_mode;
  dt_iop_gui_leave_critical_section(self);

  if(changed_mode)
    dt_dev_reprocess_center(self->dev, self->iop_order);

  gtk_widget_queue_draw(GTK_WIDGET(g->area));
}

static void _area_set_value(const dt_iop_colorequal_gui_data_t *g,
                            const float graph_height,
                            const float pos)
{
  float factor = .0f;
  float max = .0f;

  GtkWidget *w = _get_slider(g, g->selected);

  if(w)
  {
    switch(g->channel)
    {
       case(SATURATION):
         factor = 0.5f;
         max = 100.0f;
         break;
       case(HUE):
         factor = 1.f / DT_2PI_F;
         max = (100.0f / 180.0f) * 100.0f;
         break;
       case(BRIGHTNESS):
       default:
         factor = 0.5f;
         max = 100.0f;
         break;
    }

    const float val = (0.5f - (pos / graph_height)) * max / factor;
    dt_bauhaus_slider_set_val(w, val);
  }
}

static void _area_set_pos(const dt_iop_colorequal_gui_data_t *g,
                          const float pos)
{
  const float graph_height = MAX(1.0f, g->graph_height);
  const float y = CLAMP(pos, 0.0f, graph_height);

  _area_set_value(g, graph_height, y);
}

static void _area_reset_nodes(dt_iop_colorequal_gui_data_t *g)
{
  const float graph_height = MAX(1.0f, g->graph_height);
  const float y = graph_height / 2.0f;

  if(g->on_node)
  {
    _area_set_value(g, graph_height, y);
  }
  else
  {
    for(int k=0; k<NODES; k++)
    {
      g->selected = k;
      _area_set_value(g, graph_height, y);
    }
    g->on_node = FALSE;
  }
}

/* _graph_x_to_hue_deg — continuous hue (GUI degrees) under a given x
 * position within the graph widget. Nodes are laid out at evenly spaced x
 * positions independent of hue_shift (see the drawing loop populating
 * g->points[]), while their hue value is offset by hue_shift, so we
 * interpolate the fractional node index from x and apply the same
 * per-node hue mapping as _node_hue_deg() to it. Used so that scrolling
 * on the graph can weight around the hue actually under the cursor there,
 * rather than the last hue seen while hovering the main image.
 */
static float _graph_x_to_hue_deg(const dt_iop_colorequal_gui_data_t *g,
                                 const dt_iop_colorequal_params_t *p,
                                 const float x)
{
  const float span = g->points[1][0] - g->points[0][0];
  if(fabsf(span) < 1e-6f) return g->cursor_hue; // graph not laid out yet

  const float frac_k = (x - g->points[0][0]) / span;
  float hue_deg = _node_hue_deg(0, p->hue_shift) + frac_k * (360.f / (float)NODES);
  hue_deg = fmodf(hue_deg, 360.f);
  if(hue_deg < 0.f) hue_deg += 360.f;
  return hue_deg;
}

/* _area_scrolled_callback — scroll wheel handling on the graph.
 *
 * Behavior depending on preview buffer state:
 *
 * A) Buffer exists (g->pd.buf != NULL) → Gaussian mode
 *    The scroll wheel modifies all sliders of the active channel based on
 *    their angular distance to the hue under the cursor on the graph
 *    itself. Weight follows a Gaussian with sigma=35°: the closest node
 *    receives maximum movement, neighbors receive a decreasing fraction.
 *    This ensures smooth transitions with no dead zones.
 *
 * B) No buffer yet → classic single-node behavior
 *    The scroll wheel is forwarded to the slider of the selected node in the graph.
 *
 * C) Alt+scroll: switch page (original behavior unchanged).
 *
 * Modifiers:
 *   Ctrl  → fine step (0.001 instead of 0.01)
 */
static void _area_scrolled_callback(GtkEventControllerScroll *controller,
                                    gdouble dx,
                                    gdouble dy,
                                    dt_iop_module_t *self)
{
  GtkWidget *const widget = dt_gui_get_widget(controller);
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  dt_iop_colorequal_params_t   *p = self->params;

  const GdkModifierType state = dt_key_modifier_state();

  // Alt+scroll: switch page (original behavior unchanged)
  if(dt_modifier_is(state, GDK_MOD1_MASK))
  {
    // previously the scroll event was forwarded to the notebook;
    // event controllers cannot forward, so switch the page directly
    const int pages = gtk_notebook_get_n_pages(g->notebook);
    const int current = gtk_notebook_get_current_page(g->notebook);
    const int next = (current + (dy > 0.0 ? -1 : 1) + pages) % pages;
    gtk_notebook_set_current_page(g->notebook, next);
    return;
  }

  // The Gaussian mode below weights around the hue under the cursor on the
  // graph itself (g->graph_cursor_x, tracked by _area_motion_notify_callback),
  // falling back to the last hue seen on the main image (g->cursor_hue) only
  // if the graph has not seen a motion event yet.

  // If no preview buffer has been allocated yet, fall back to classic
  // single-node adjustment.  Otherwise use Gaussian weighting — we
  // check buffer existence rather than cursor_valid (which gates on
  // pipe hash) so that the graph remains usable with slightly stale data.
  dt_iop_gui_enter_critical_section(self);
  const gboolean no_pdbuf = g->pd.buf == NULL;
  dt_iop_gui_leave_critical_section(self);
  if(no_pdbuf)
  {
    const float base_step = (g->channel == HUE) ? 1.0f : 0.01f;
    const float step = dt_modifier_is(state, GDK_CONTROL_MASK)
                       ? base_step * 0.1f : base_step;
    // dy < 0 on scroll-up (darktable's canonical convention, see
    // src/gui/gtk.c) so negate it to make scroll-up increase, consistent
    // with scrolled() and with the tone equalizer.
    const float move = (float)(-dy) * step;

    float vmin, vmax;
    float *val = _get_param_ptr(p, g->channel, g->selected, &vmin, &vmax);
    const float old_val = *val;
    float new_val = *val + move;
    if(g->channel == HUE)
    {
      if(new_val > 180.f) new_val -= 360.f;
      else if(new_val < -180.f) new_val += 360.f;
    }
    else
      new_val = CLAMP(new_val, vmin, vmax);
    *val = new_val;

    GtkWidget *w = _get_slider(g, g->selected);
    if(w) dt_bauhaus_slider_set(w, *val);

    if(*val != old_val)
      dt_dev_add_history_item(self->dev, self, TRUE);
    gtk_widget_queue_draw(widget);
    return;
  }

  // --- Gaussian mode -------------------------------------------------------

  const float base_step = (g->channel == HUE) ? 1.0f : 0.01f;
  const float step = dt_modifier_is(state, GDK_CONTROL_MASK)
                     ? base_step * 0.1f
                     : base_step;
  // dy < 0 on scroll-up (darktable's canonical convention, see
  // src/gui/gtk.c) so negate it to make scroll-up increase, consistent
  // with scrolled() and with the tone equalizer.
  const float move = (float)(-dy) * step;

  const float ref_hue_deg = g->graph_cursor_valid
    ? _graph_x_to_hue_deg(g, p, g->graph_cursor_x)
    : g->cursor_hue;

  _adjust_params_gaussian(self, p, g, move, ref_hue_deg);

  gtk_widget_queue_draw(widget);
}

static void _area_motion_notify_callback(GtkEventControllerMotion *controller,
                                          gdouble x,
                                          gdouble y,
                                          dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;

  g->graph_cursor_x = (float)x;
  g->graph_cursor_valid = TRUE;

  if(g->dragging && g->on_node)
    _area_set_pos(g, y);
  else
  {
    // look if close to a node
    const float epsilon = DT_PIXEL_APPLY_DPI(10.0);
    const int oldsel = g->selected;
    const int oldon = g->on_node;
    g->selected = (((int)(((float)x - g->points[0][0])
                        / (g->points[1][0] - g->points[0][0]) + 0.5f) % NODES) + NODES) % NODES;
    g->on_node = fabsf(g->points[g->selected][1] - (float)y) < epsilon;
    darktable.control->element = g->selected;
    if(oldsel != g->selected || oldon != g->on_node)
      gtk_widget_queue_draw(GTK_WIDGET(g->area));
  }
}

static void _area_leave_callback(GtkEventControllerMotion *controller,
                                 dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  g->graph_cursor_valid = FALSE;
}

static void _area_button_press_callback(GtkGestureSingle *gesture,
                                         gint n_press,
                                         gdouble x,
                                         gdouble y,
                                         dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  const guint button = gtk_gesture_single_get_current_button(gesture);

  if(button == GDK_BUTTON_MIDDLE
     || (button == GDK_BUTTON_PRIMARY // Ctrl+Click alias for macOS
         && dt_modifier_is(dt_key_modifier_state(), GDK_CONTROL_MASK)))
  {
    GtkToggleButton *toggle = GTK_TOGGLE_BUTTON(g->cs_sliders.toggle);
    gtk_toggle_button_set_active(toggle, !gtk_toggle_button_get_active(toggle));
  }
  else if(button == GDK_BUTTON_PRIMARY)
  {
    if(n_press >= 2)
      _area_reset_nodes(g);
    else
      g->dragging = TRUE;
  }
  else
  {
    // other buttons (e.g. right-click): open the value-entry popup of the
    // selected node's slider.  The old button-press-event handler forwarded
    // the event to the slider widget; a gesture-based bauhaus widget cannot
    // be fed a synthetic press (the unmatched release would leave its
    // gesture with a stale sequence), so open the popup directly.  Claim the
    // event so it does not bubble up to the module menu.
    dt_bauhaus_widget_show_popup(_get_slider(g, g->selected));
    dt_gui_claim(gesture);
  }
}

static void _area_button_release_callback(GtkGestureSingle *gesture,
                                           gint n_press,
                                           gdouble x,
                                           gdouble y,
                                           dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  const guint button = gtk_gesture_single_get_current_button(gesture);

  if(button == GDK_BUTTON_PRIMARY)
    g->dragging = FALSE;
}

static void _area_size_callback(GtkWidget *widget,
                                GdkRectangle *allocation,
                                const dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  g->gradients_cached = FALSE;
}

void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  const dt_iop_colorequal_params_t *p = self->params;

  // Get the current display profile
  struct dt_iop_order_iccprofile_info_t *work_profile =
    dt_ioppr_get_pipe_output_profile_info(self->dev->full.pipe);

  // Check if it is different than the one in cache, and update it if needed
  if(work_profile != g->work_profile)
  {
    // Re-init the profiles
    dt_free_align(g->white_adapted_profile);
    g->white_adapted_profile = D65_adapt_iccprofile(work_profile);
    g->work_profile = work_profile;
    g->gradients_cached = FALSE;

    // Regenerate the display gamut LUT - Default to Rec709 D65 aka linear sRGB
    dt_colormatrix_t input_matrix = { { 0.4124564f, 0.3575761f, 0.1804375f, 0.f },
                                      { 0.2126729f, 0.7151522f, 0.0721750f, 0.f },
                                      { 0.0193339f, 0.1191920f, 0.9503041f, 0.f },
                                      { 0.f } };
    if(g->white_adapted_profile != NULL)
      memcpy(input_matrix, g->white_adapted_profile->matrix_in, sizeof(dt_colormatrix_t));
    else
      dt_print(DT_DEBUG_PIPE, "[colorequal] display color space falls back to sRGB");

    dt_UCS_22_build_gamut_LUT(input_matrix, g->gamut_LUT);
    g->max_saturation = get_minimum_saturation(g->gamut_LUT, 0.2f, 1.f);
  }

  // Show guided filter controls only if in use
  const gboolean guiding = p->use_filter;
  gtk_widget_set_visible(GTK_WIDGET(g->threshold), guiding);
  gtk_widget_set_visible(GTK_WIDGET(g->contrast), guiding);
  gtk_widget_set_visible(GTK_WIDGET(g->chroma_size), guiding);
  gtk_widget_set_visible(GTK_WIDGET(g->param_size), guiding);

  // Only show hue smoothing where effective
  gtk_widget_set_visible(GTK_WIDGET(g->smoothing_hue), g->channel == HUE);

  if(w == g->use_filter && !guiding)
    g->mask_mode = 0;

  if((work_profile != g->work_profile) || (w == g->hue_shift))
    _init_sliders(self);

  gtk_widget_queue_draw(GTK_WIDGET(g->area));
}

void gui_cleanup(dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  self->request_color_pick = DT_REQUEST_COLORPICK_OFF;

  if(g->white_adapted_profile)
  {
    dt_free_align(g->white_adapted_profile);
    g->white_adapted_profile = NULL;
  }

  dt_free_align(g->gamut_LUT);
  dt_preview_data_free(&g->pd);

  // Destroy the background cache
  for(dt_iop_colorequal_channel_t chan = 0; chan < NUM_CHANNELS; chan++)
  {
    if(g->b_data[chan])
      free(g->b_data[chan]);
    if(g->b_surface[chan])
      cairo_surface_destroy(g->b_surface[chan]);
  }

  dt_conf_set_int("plugins/darkroom/colorequal/gui_page",
                  gtk_notebook_get_current_page (g->notebook));
}

void gui_update(dt_iop_module_t *self)
{
  const dt_iop_colorequal_params_t *p = self->params;
  dt_iop_colorequal_gui_data_t *g = self->gui_data;
  dt_bauhaus_toggle_set(g->use_filter, p->use_filter);
  gui_changed(self, NULL, NULL);

  // reset masking
  g->mask_mode = 0;
  dt_bauhaus_widget_set_quad_active(g->param_size, FALSE);
  dt_bauhaus_widget_set_quad_active(g->threshold, FALSE);

  // show the slider page matching the selected channel tab
  const char numstr[] = {'0' + g->page_num, 0};
  gtk_stack_set_visible_child_name(g->stack, numstr);
}

static float _action_process_colorequal(const gpointer target,
                                        const dt_action_element_t element,
                                        const dt_action_effect_t effect,
                                        const float move_size)
{
  if(element >= NODES) return DT_ACTION_NOT_VALID;

  const dt_iop_module_t *self = g_object_get_data(G_OBJECT(target), "iop-instance");
  const dt_iop_colorequal_gui_data_t *g = self->gui_data;

  GtkWidget *w = _get_slider(g, element);
  const int index = dt_action_widget(w)->type - DT_ACTION_TYPE_WIDGET - 1;
  const dt_action_def_t *def = darktable.control->widget_definitions->pdata[index];

  return def->process(w, DT_ACTION_ELEMENT_DEFAULT, effect, move_size);
}

static const dt_action_element_def_t _action_elements_colorequal[]
  = { { N_("red"      ), dt_action_effect_value },
      { N_("orange"   ), dt_action_effect_value },
      { N_("yellow"   ), dt_action_effect_value },
      { N_("green"    ), dt_action_effect_value },
      { N_("cyan"     ), dt_action_effect_value },
      { N_("blue"     ), dt_action_effect_value },
      { N_("lavender" ), dt_action_effect_value },
      { N_("magenta"  ), dt_action_effect_value },
      { NULL } };

static const dt_action_def_t _action_def_coloreq
  = { N_("color equalizer"),
      _action_process_colorequal,
      _action_elements_colorequal };

void gui_init(dt_iop_module_t *self)
{
  dt_iop_colorequal_gui_data_t *g = IOP_GUI_ALLOC(colorequal);

  // Init the color profiles and cache them
  struct dt_iop_order_iccprofile_info_t *work_profile = NULL;
  if(self->dev)
    work_profile = dt_ioppr_get_pipe_output_profile_info(self->dev->full.pipe);
  if(g->white_adapted_profile)
    dt_free_align(g->white_adapted_profile);
  g->white_adapted_profile = D65_adapt_iccprofile(work_profile);
  g->work_profile = work_profile;
  g->gradients_cached = FALSE;
  g->on_node = FALSE;
  g->cursor_hue   = 0.f;
  g->cursor_valid = FALSE;
  g->cursor_pos_x = 0.f;
  g->cursor_pos_y = 0.f;
  g->reprocess_pending = FALSE;
  g->graph_cursor_x = 0.f;
  g->graph_cursor_valid = FALSE;
  dt_preview_data_alloc(&g->pd, self);
  g->pd.components = 3; // store the HSB of the module input pixel per sample
  for(dt_iop_colorequal_channel_t chan = 0; chan < NUM_CHANNELS; chan++)
  {
    g->b_data[chan] = NULL;
    g->b_surface[chan] = NULL;
  }

  // Init the display gamut LUT - Default to Rec709 D65 aka linear sRGB
  g->gamut_LUT = dt_alloc_align_float(LUT_ELEM);
  dt_colormatrix_t input_matrix = { { 0.4124564f, 0.3575761f, 0.1804375f, 0.f },
                                    { 0.2126729f, 0.7151522f, 0.0721750f, 0.f },
                                    { 0.0193339f, 0.1191920f, 0.9503041f, 0.f },
                                    { 0.f } };
  if(g->white_adapted_profile)
    memcpy(input_matrix, g->white_adapted_profile->matrix_in, sizeof(dt_colormatrix_t));

  dt_UCS_22_build_gamut_LUT(input_matrix, g->gamut_LUT);
  g->max_saturation = get_minimum_saturation(g->gamut_LUT, 0.2f, 1.f);

  // start building top level widget
  static dt_action_def_t notebook_def = { };
  g->notebook = dt_ui_notebook_new(&notebook_def);
  dt_action_define_iop(self, NULL, N_("page"), GTK_WIDGET(g->notebook), &notebook_def);

  // graph
  g->area = GTK_DRAWING_AREA
    (dt_ui_resize_wrap(NULL,
                       0,
                       "plugins/darkroom/colorequal/graphheight"));

  g_object_set_data(G_OBJECT(g->area), "iop-instance", self);
  dt_action_define_iop(self, NULL, N_("graph"), GTK_WIDGET(g->area), &_action_def_coloreq);
  gtk_widget_set_tooltip_text(GTK_WIDGET(g->area), _("double-click to reset the curve\nmiddle-click to toggle the control sliders\nalt+scroll to change page"));
  gtk_widget_set_can_focus(GTK_WIDGET(g->area), TRUE);
  g_signal_connect(G_OBJECT(g->area), "draw", G_CALLBACK(_iop_colorequalizer_draw), self);
  gtk_widget_add_events(GTK_WIDGET(g->area),
                        GDK_POINTER_MOTION_MASK | GDK_BUTTON_PRESS_MASK
                        | GDK_BUTTON_RELEASE_MASK
                        | darktable.gui->scroll_mask);
  dt_gui_connect_click_all(g->area, _area_button_press_callback, _area_button_release_callback, self);
  dt_gui_connect_motion(g->area, _area_motion_notify_callback, NULL, _area_leave_callback, self);
  dt_gui_connect_scroll(g->area, GTK_EVENT_CONTROLLER_SCROLL_BOTH_AXES
                                        | GTK_EVENT_CONTROLLER_SCROLL_DISCRETE,
                        _area_scrolled_callback, self);
  g_signal_connect(G_OBJECT(g->area), "size-allocate",
                   G_CALLBACK(_area_size_callback), self);

  GtkWidget *box = self->widget = dt_gui_vbox(g->notebook, g->area);
  g->hue_shift = dt_color_picker_new_with_cst(self, DT_COLOR_PICKER_POINT_AREA | DT_COLOR_PICKER_DENOISE,
                 dt_bauhaus_slider_from_params(self, "hue_shift"), IOP_CS_JZCZHZ);
  dt_bauhaus_slider_set_format(g->hue_shift, "°");
  dt_bauhaus_slider_set_digits(g->hue_shift, 0);
  gtk_widget_set_tooltip_text(g->hue_shift,
                              _("shift nodes to lower or higher hue"));

  dt_bauhaus_widget_set_quad_tooltip(g->hue_shift,
    _("pick hue from image and visualize it\nctrl+click to select an area"));
  gtk_widget_set_name(g->hue_shift, "keep-active");

  // the numeric sliders live in their own collapsible section, collapsed by
  // default, so the graph stays the primary interface
  dt_gui_new_collapsible_section
    (&g->cs_sliders,
     "plugins/darkroom/colorequal/expand_control_sliders",
     _("control sliders"),
     GTK_BOX(box),
     DT_ACTION(self));

  g->stack = GTK_STACK(gtk_stack_new());
  dt_gui_box_add(g->cs_sliders.container, g->stack);
  dt_action_define_iop(self, NULL, N_("sliders"), GTK_WIDGET(g->stack), NULL);
  gtk_stack_set_homogeneous(g->stack, FALSE);

  dt_iop_module_t *sect = NULL;
#define GROUP_SLIDERS(num, page, tooltip)                      \
  dt_ui_notebook_page(g->notebook, page, tooltip);             \
  sect = DT_IOP_SECTION_FOR_PARAMS(self, page, dt_gui_vbox()); \
  gtk_stack_add_named(g->stack, sect->widget, num);

  GROUP_SLIDERS("0", N_("hue"), _("change hue hue-wise"))
  g->hue_sliders[0] = g->hue_red =
    dt_bauhaus_slider_from_params(sect, "hue_red");
  g->hue_sliders[1] = g->hue_orange =
    dt_bauhaus_slider_from_params(sect, "hue_orange");
  g->hue_sliders[2] = g->hue_yellow =
    dt_bauhaus_slider_from_params(sect, "hue_yellow");
  g->hue_sliders[3] = g->hue_green =
    dt_bauhaus_slider_from_params(sect, "hue_green");
  g->hue_sliders[4] = g->hue_cyan =
    dt_bauhaus_slider_from_params(sect, "hue_cyan");
  g->hue_sliders[5] = g->hue_blue =
    dt_bauhaus_slider_from_params(sect, "hue_blue");
  g->hue_sliders[6] = g->hue_lavender =
    dt_bauhaus_slider_from_params(sect, "hue_lavender");
  g->hue_sliders[7] = g->hue_magenta =
    dt_bauhaus_slider_from_params(sect, "hue_magenta");

  GROUP_SLIDERS("1", N_("saturation"), _("change saturation hue-wise"))
  g->sat_sliders[0] = g->sat_red =
    dt_bauhaus_slider_from_params(sect, "sat_red");
  g->sat_sliders[1] = g->sat_orange =
    dt_bauhaus_slider_from_params(sect, "sat_orange");
  g->sat_sliders[2] = g->sat_yellow =
    dt_bauhaus_slider_from_params(sect, "sat_yellow");
  g->sat_sliders[3] = g->sat_green =
    dt_bauhaus_slider_from_params(sect, "sat_green");
  g->sat_sliders[4] = g->sat_cyan =
    dt_bauhaus_slider_from_params(sect, "sat_cyan");
  g->sat_sliders[5] = g->sat_blue =
    dt_bauhaus_slider_from_params(sect, "sat_blue");
  g->sat_sliders[6] = g->sat_lavender =
    dt_bauhaus_slider_from_params(sect, "sat_lavender");
  g->sat_sliders[7] = g->sat_magenta =
    dt_bauhaus_slider_from_params(sect, "sat_magenta");

  GROUP_SLIDERS("2", N_("brightness"), _("change brightness hue-wise"))
  g->bright_sliders[0] = g->bright_red =
    dt_bauhaus_slider_from_params(sect, "bright_red");
  g->bright_sliders[1] = g->bright_orange =
    dt_bauhaus_slider_from_params(sect, "bright_orange");
  g->bright_sliders[2] = g->bright_yellow =
    dt_bauhaus_slider_from_params(sect, "bright_yellow");
  g->bright_sliders[3] = g->bright_green =
    dt_bauhaus_slider_from_params(sect, "bright_green");
  g->bright_sliders[4] = g->bright_cyan =
    dt_bauhaus_slider_from_params(sect, "bright_cyan");
  g->bright_sliders[5] = g->bright_blue =
    dt_bauhaus_slider_from_params(sect, "bright_blue");
  g->bright_sliders[6] = g->bright_lavender =
    dt_bauhaus_slider_from_params(sect, "bright_lavender");
  g->bright_sliders[7] = g->bright_magenta =
    dt_bauhaus_slider_from_params(sect, "bright_magenta");

  dt_gui_new_collapsible_section
    (&g->cs,
     "plugins/darkroom/colorequal/expand_options",
     _("options"),
     GTK_BOX(box),
     DT_ACTION(self));
  self->widget = GTK_WIDGET(g->cs.container);

  g->white_level = dt_color_picker_new(self, DT_COLOR_PICKER_AREA,
                                       dt_bauhaus_slider_from_params(self, "white_level"));
  dt_bauhaus_slider_set_soft_range(g->white_level, -2., +2.);
  dt_bauhaus_slider_set_format(g->white_level, _(" EV"));
  gtk_widget_set_tooltip_text(g->white_level,
                              _("the white level set manually or via the picker restricts brightness corrections\n"
                                "to stay below the defined level. the default is fine for most images."));

  g->smoothing_hue = dt_bauhaus_slider_from_params(self, "smoothing_hue");
  gtk_widget_set_tooltip_text(g->smoothing_hue,
                              _("change for sharper or softer hue curve"));

  g->use_filter = dt_bauhaus_toggle_from_params(self, "use_filter");
  gtk_widget_set_tooltip_text(g->use_filter,
                              _("restrict effect by using a guided filter based on hue and saturation"));

  g->chroma_size = dt_bauhaus_slider_from_params(self, "chroma_size");
  dt_bauhaus_slider_set_digits(g->chroma_size, 1);
  dt_bauhaus_slider_set_format(g->chroma_size, _(_(" px")));
  gtk_widget_set_tooltip_text(g->chroma_size,
                              _("set radius of the guided filter chroma analysis (hue).\n"
                                "increase if there is large local variance of hue or strong chroma noise."));

  g->threshold = dt_bauhaus_slider_from_params(self, "threshold");
  dt_bauhaus_slider_set_digits(g->threshold, 3);
  dt_bauhaus_slider_set_format(g->threshold, "%");
  dt_bauhaus_widget_set_quad(g->threshold, self, dtgtk_cairo_paint_showmask, TRUE, _masking_callback_t,
    _("visualize weighting function on changed output and view weighting curve.\n"
      "red shows possibly changed data, blueish parts will not be changed."));

  gtk_widget_set_tooltip_text(g->threshold,
                              _("set saturation threshold for the guided filter.\n"
                                " - decrease to allow changes in areas with low chromaticity\n"
                                " - increase to restrict changes to higher chromaticities\n"
                                "   increases contrast and avoids brightness changes in low chromaticity areas"));

  g->contrast = dt_bauhaus_slider_from_params(self, "contrast");
  dt_bauhaus_slider_set_digits(g->contrast, 3);
  gtk_widget_set_tooltip_text(g->contrast,
                              _("set saturation contrast for the guided filter.\n"
                                " - increase to favor sharp transitions between saturations leading to higher contrast\n"
                                " - decrease for smoother transitions"));

  g->param_size = dt_bauhaus_slider_from_params(self, "param_size");
  dt_bauhaus_slider_set_digits(g->param_size, 1);
  dt_bauhaus_slider_set_format(g->param_size, _(_(" px")));
  gtk_widget_set_tooltip_text(g->param_size, _("set radius of applied parameters for the guided filter"));

  dt_bauhaus_widget_set_quad(g->param_size, self, dtgtk_cairo_paint_showmask, TRUE, _masking_callback_p,
    _("visualize changed output for the selected tab.\n"
      "red shows increased values, blue decreased."));

  _init_sliders(self);

  // restore the previously saved active tab. Clamp in case the stored value
  // points to the old "options" tab, which no longer exists.
  int active_page = dt_conf_get_int("plugins/darkroom/colorequal/gui_page");
  active_page = CLAMP(active_page, 0, NUM_CHANNELS - 1);
  gtk_widget_show(gtk_notebook_get_nth_page(g->notebook, active_page));
  gtk_notebook_set_current_page(g->notebook, active_page);
  g->channel = (dt_iop_colorequal_channel_t)active_page;
  g->page_num = active_page;

  self->widget = GTK_WIDGET(box);

  g_signal_connect(G_OBJECT(g->notebook), "switch_page",
                   G_CALLBACK(_channel_tabs_switch_callback), self);
}

// clang-format off
// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
// vim: shiftwidth=2 expandtab tabstop=2 cindent
// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
// clang-format on