From 5a24e886eabe5c35b2335bc4cf026bb92567770c Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:37:50 +0200 Subject: [PATCH 01/12] add moudule satcurvergb --- data/kernels/programs.conf | 1 + data/kernels/satcurve.cl | 157 ++++++ src/common/iop_order.c | 6 + src/iop/CMakeLists.txt | 1 + src/iop/satcurvergb.c | 1090 ++++++++++++++++++++++++++++++++++++ 5 files changed, 1255 insertions(+) create mode 100644 data/kernels/satcurve.cl create mode 100644 src/iop/satcurvergb.c diff --git a/data/kernels/programs.conf b/data/kernels/programs.conf index 15ddf324b645..16b3addde72d 100644 --- a/data/kernels/programs.conf +++ b/data/kernels/programs.conf @@ -42,3 +42,4 @@ capture.cl 38 agx.cl 39 colorharmonizer.cl 40 overlay.cl 41 +satcurve.cl 42 \ No newline at end of file diff --git a/data/kernels/satcurve.cl b/data/kernels/satcurve.cl new file mode 100644 index 000000000000..e73ac332311c --- /dev/null +++ b/data/kernels/satcurve.cl @@ -0,0 +1,157 @@ +/* + This file is part of darktable, + Copyright (C) 2009-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 . +*/ + +#include "common.h" +#include "colorspace.h" +#include "color_conversion.h" + + +#define DT_IOP_SATCURVE_RES 256 + +// periodic lookup in the hue-indexed gamut LUT; mirrors satcurve_lookup_gamut() on CPU +static inline float satcurve_lookup_gamut_cl(global const float *const gamut_lut, const float h) +{ + const float position = (h + M_PI_F) * (float)LUT_ELEM / (2.0f * M_PI_F); + const float position_floor = floor(position); + const int bin0 = ((int)position_floor) % LUT_ELEM; + const int bin1 = (bin0 + 1) % LUT_ELEM; + const float f = position - position_floor; + return gamut_lut[bin0] * (1.f - f) + gamut_lut[bin1] * f; +} + +// smoothly map x from knee onwards towards maximum; mirrors satcurve_soft_clip() on CPU +static inline float satcurve_soft_clip_cl(const float x, const float knee, const float maximum) +{ + if(x <= knee) return x; + const float range = maximum - knee; + if(range <= 0.f) return maximum; + return knee + range * (1.f - dtcl_exp(-(x - knee) / range)); +} + +// linear interpolation into the 256-entry curve LUT +static inline float satcurve_lookup_lut_cl(global const float *const lut, const float x) +{ + const float position = clamp(x, 0.f, 1.f) * (DT_IOP_SATCURVE_RES - 1); + const int i = min((int)position, DT_IOP_SATCURVE_RES - 2); + return lut[i] + (position - i) * (lut[i + 1] - lut[i]); +} + +// mirrors clip_jz_chroma() on CPU : test-converts to L'M'S' and clips Cz so +// the JzAzBz -> XYZ back-transform never produces negative LMS values +static inline float clip_jz_chroma_cl(const float Jz, const float Cz, const float ch, const float sh) +{ + const float d0 = 1.6295499532821566e-11f; + const float dd = -0.56f; + float Iz = (Jz + d0) / (1.f + dd - dd * (Jz + d0)); + Iz = fmax(Iz, 0.f); + + const float4 AI[3] = { { 1.0f, 0.1386050432715393f, 0.0580473161561189f, 0.0f }, + { 1.0f, -0.1386050432715393f, -0.0580473161561189f, 0.0f }, + { 1.0f, -0.0960192420263190f, -0.8118918960560390f, 0.0f } }; + const float4 izab = { Iz, Cz * ch, Cz * sh, 0.f }; + const float lms0 = dot(AI[0], izab); + const float lms1 = dot(AI[1], izab); + const float lms2 = dot(AI[2], izab); + + float max_c = Cz; + if(lms0 < 0.f) max_c = fmin(max_c, -Iz / (AI[0].y * ch + AI[0].z * sh)); + if(lms1 < 0.f) max_c = fmin(max_c, -Iz / (AI[1].y * ch + AI[1].z * sh)); + if(lms2 < 0.f) max_c = fmin(max_c, -Iz / (AI[2].y * ch + AI[2].z * sh)); + return fmax(max_c, 0.f); +} + +typedef enum dt_iop_satcurve_formula_t +{ + DT_IOP_SATCURVE_JZAZBZ = 0, + DT_IOP_SATCURVE_DTUCS = 1 +} dt_iop_satcurve_formula_t; + +kernel void +satcurvergb(read_only image2d_t in, write_only image2d_t out, + const int width, const int height, + constant const float *const matrix_in, constant const float *const matrix_out, + global const float *const lut, global const float *const gamut_lut, + const int formula, const float L_white) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + float4 pix_in = readpixel(in, x, y); + float4 rgb = fmax(pix_in, 0.f); + + float4 xyz = matrix_product_float4(rgb, matrix_in); + float4 pixout; + + if(formula == DT_IOP_SATCURVE_DTUCS) + { + float4 xyY = dt_D65_XYZ_to_xyY(xyz); + float4 JCH = xyY_to_dt_UCS_JCH(xyY, L_white); + float4 HCB = dt_UCS_JCH_to_HCB(JCH); + + const float max_colorfulness = fmax(satcurve_lookup_gamut_cl(gamut_lut, JCH.z), FLT_MIN); + const float max_chroma = 15.932993652962535f + * dtcl_pow(JCH.x / L_white, 0.6523997524738018f) + * dtcl_pow(max_colorfulness, 0.6007557017508491f) / L_white; + + const float4 JCH_gamut_boundary = { JCH.x, max_chroma, JCH.z, 0.f }; + const float4 HSB_gamut_boundary = dt_UCS_JCH_to_HSB(JCH_gamut_boundary); + + float4 HSB = { HCB.x, HCB.z > 0.f ? HCB.y / HCB.z : 0.f, HCB.z, 0.f }; + + const float gamut_s = fmax(HSB_gamut_boundary.y, FLT_MIN); + const float s_in_norm = HSB.y / gamut_s; + const float c = clamp(satcurve_lookup_lut_cl(lut, s_in_norm), 0.f, 1.f); + HSB.y = fmax(HSB.y * (2.f * c), 0.f); + HSB.y = satcurve_soft_clip_cl(HSB.y, 0.8f * gamut_s, gamut_s); + + JCH = dt_UCS_HSB_to_JCH(HSB); + xyY = dt_UCS_JCH_to_xyY(JCH, L_white); + xyz = dt_xyY_to_XYZ(xyY); + } + else + { + float4 jab = XYZ_to_JzAzBz(xyz); + const float Jz = fmax(jab.x, 0.f); + const float Cz = hypot(jab.y, jab.z); + const float h = atan2(jab.z, jab.y); + const float ch = dtcl_cos(h), sh = dtcl_sin(h); + const float gamut = fmax(satcurve_lookup_gamut_cl(gamut_lut, h), FLT_MIN); + const float s_in_norm = (Jz > 0.f ? Cz / Jz : 0.f) / gamut; + const float c = clamp(satcurve_lookup_lut_cl(lut, s_in_norm), 0.f, 1.f); + const float s_out = satcurve_soft_clip_cl(fmax(s_in_norm * (2.f * c), 0.f), 0.8f, 1.f) * gamut; + + const float r = hypot(Jz, Cz); + // T = atan(s_out); cos(T) = 1/sqrt(1+s_out^2), sin(T) = s_out/sqrt(1+s_out^2) + // avoids one atan + one cos + one sin per pixel + const float inv_norm = 1.f / sqrt(1.f + s_out * s_out); + const float Jz_out = r * inv_norm; + const float Cz_out = clip_jz_chroma_cl(Jz_out, r * s_out * inv_norm, ch, sh); + + jab.x = Jz_out; + jab.y = Cz_out * ch; + jab.z = Cz_out * sh; + xyz = JzAzBz_2_XYZ(jab); + } + + pixout = matrix_product_float4(xyz, matrix_out); + pixout = fmax(pixout, 0.f); + pixout.w = pix_in.w; + + write_imagef(out, (int2)(x, y), pixout); +} \ No newline at end of file diff --git a/src/common/iop_order.c b/src/common/iop_order.c index 1cb9effaa6f9..2c548318bb93 100644 --- a/src/common/iop_order.c +++ b/src/common/iop_order.c @@ -128,6 +128,7 @@ const dt_iop_order_entry_t legacy_order[] = { { {33.0f }, "colorbalance", 0}, { {33.2f }, "colorequal", 0}, { {33.5f }, "colorbalancergb", 0}, + { {33.7f }, "satcurvergb", 0}, { {34.0f }, "colorize", 0}, { {35.0f }, "colortransfer", 0}, { {36.0f }, "colormapping", 0}, @@ -250,6 +251,7 @@ const dt_iop_order_entry_t v30_order[] = { { {41.0f }, "colorbalance", 0}, // scene-referred color manipulation { {41.2f }, "colorequal", 0}, { {41.5f }, "colorbalancergb", 0}, // scene-referred color manipulation + { {41.7f }, "satcurvergb", 0}, // scene-referred saturation adjustment { {42.0f }, "rgbcurve", 0}, // really versatile way to edit colour in scene-referred and display-referred workflow { {43.0f }, "rgblevels", 0}, // same { {44.0f }, "basecurve", 0}, // conversion from scene-referred to display referred, reverse-engineered @@ -369,6 +371,7 @@ const dt_iop_order_entry_t v50_order[] = { { {41.0f }, "colorbalance", 0}, // scene-referred color manipulation { {41.2f }, "colorequal", 0}, { {41.5f }, "colorbalancergb", 0}, // scene-referred color manipulation + { {41.7f }, "satcurvergb", 0}, // scene-referred saturation adjustment { {42.0f }, "rgbcurve", 0}, // really versatile way to edit colour in scene-referred and display-referred workflow { {43.0f }, "rgblevels", 0}, // same { {44.0f }, "basecurve", 0}, // conversion from scene-referred to display referred, reverse-engineered @@ -488,6 +491,7 @@ const dt_iop_order_entry_t v30_jpg_order[] = { { { 41.0f }, "colorbalance", 0 }, // scene-referred color manipulation { { 41.2f }, "colorequal", 0 }, { { 41.5f }, "colorbalancergb", 0 }, // scene-referred color manipulation + { { 41.7f }, "satcurvergb", 0 }, // scene-referred saturation adjustment { { 42.0f }, "rgbcurve", 0 }, // really versatile way to edit colour in scene-referred and display-referred // workflow { { 43.0f }, "rgblevels", 0 }, // same @@ -610,6 +614,7 @@ const dt_iop_order_entry_t v50_jpg_order[] = { { { 41.0f }, "colorbalance", 0 }, // scene-referred color manipulation { { 41.2f }, "colorequal", 0 }, { { 41.5f }, "colorbalancergb", 0 }, // scene-referred color manipulation + { { 41.7f }, "satcurvergb", 0 }, // scene-referred saturation adjustment { { 42.0f }, "rgbcurve", 0 }, // really versatile way to edit colour in scene-referred and display-referred // workflow { { 43.0f }, "rgblevels", 0 }, // same @@ -727,6 +732,7 @@ void dt_ioppr_migrate_legacy_iop_order_list(GList *iop_order_list) _insert_before(iop_order_list, "negadoctor", "censorize"); _insert_before(iop_order_list, "negadoctor", "primaries"); _insert_before(iop_order_list, "rgbcurve", "colorbalancergb"); + _insert_before(iop_order_list, "rgbcurve", "satcurvergb"); _insert_before(iop_order_list, "ashift", "cacorrectrgb"); _insert_before(iop_order_list, "graduatednd", "crop"); _insert_before(iop_order_list, "flip", "enlargecanvas"); diff --git a/src/iop/CMakeLists.txt b/src/iop/CMakeLists.txt index 92a69c1037f9..5a00eeae8206 100644 --- a/src/iop/CMakeLists.txt +++ b/src/iop/CMakeLists.txt @@ -157,6 +157,7 @@ add_iop(agx "agx.c") add_iop(primaries "primaries.c") add_iop(colorequal "colorequal.c") add_iop(rasterfile "rasterfile.c") +add_iop(satcurvergb "satcurvergb.c") if(Rsvg2_FOUND) add_iop(watermark "watermark.c") diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c new file mode 100644 index 000000000000..51e55d45e743 --- /dev/null +++ b/src/iop/satcurvergb.c @@ -0,0 +1,1090 @@ +/* + This file is part of darktable, + Copyright (C) 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 . +*/ +// our includes go first: +#include "bauhaus/bauhaus.h" +#include "common/chromatic_adaptation.h" +#include "common/color_picker.h" +#include "common/darktable_ucs_22_helpers.h" +#include "common/dtpthread.h" +#include "common/gamut_mapping.h" +#include "common/imagebuf.h" +#include "common/math.h" +#include "develop/imageop.h" +#include "develop/imageop_gui.h" +#include "develop/openmp_maths.h" +#include "dtgtk/drawingarea.h" +#include "gui/color_picker_proxy.h" +#include "gui/draw.h" +#include "gui/gtk.h" +#include "gui/presets.h" +#include "iop/iop_api.h" + +#define DT_IOP_SATCURVE_MAXNODES 20 +#define DT_IOP_SATCURVE_RES 256 +#define DT_IOP_SATCURVE_HIST_RES 256 +#define DT_IOP_SATCURVE_INSET DT_PIXEL_APPLY_DPI(5) +// thickness of, and gap to, the decorative perceptual gradient bar +// drawn below the curve graph +#define DT_IOP_SATCURVE_GRADIENT_SIZE DT_PIXEL_APPLY_DPI(8) +#define DT_IOP_SATCURVE_GRADIENT_GAP DT_PIXEL_APPLY_DPI(2) +#define DT_IOP_SATCURVE_MIN_X_DISTANCE 0.0025f +#define DT_IOP_SATCURVE_GAMUT_STEPS 92 + +// version of the module's serialized parameters +DT_MODULE_INTROSPECTION(1, dt_iop_satcurve_params_t) + +// saturation definition used to compute S_in_norm and drive the curve +typedef enum dt_iop_satcurve_formula_t +{ + DT_IOP_SATCURVE_JZAZBZ = 0, + DT_IOP_SATCURVE_DTUCS = 1 +} dt_iop_satcurve_formula_t; + +typedef struct dt_iop_satcurve_node_t +{ + float x, y; +} dt_iop_satcurve_node_t; + +// module parameters, serialized to the database +typedef struct dt_iop_satcurve_params_t +{ + dt_iop_satcurve_node_t curve[DT_IOP_SATCURVE_MAXNODES]; + int curve_num_nodes; + int curve_type; + dt_iop_satcurve_formula_t formula; // $DEFAULT: 1 $DESCRIPTION: "saturation formula" +} dt_iop_satcurve_params_t; + +// per-pixelpipe runtime data, derived from params_t in commit_params() +typedef struct dt_iop_satcurve_data_t +{ + dt_draw_curve_t *curve; + int curve_num_nodes; + int curve_type; + dt_iop_satcurve_formula_t formula; + float *lut; + float *gamut_lut; + gboolean lut_inited; + const struct dt_iop_order_iccprofile_info_t *work_profile; +} dt_iop_satcurve_data_t; + +// GUI state and widget handles +typedef struct dt_iop_satcurve_gui_data_t +{ + GtkDrawingArea *area; + GtkWidget *colorpicker; + GtkWidget *formula; + dt_draw_curve_t *curve; + int curve_num_nodes; + int curve_type; + float draw_ys[DT_IOP_SATCURVE_RES]; + int selected; + gboolean dragging; + float mouse_x, mouse_y; + + // input saturation histogram (S_in_norm) + float histogram[DT_IOP_SATCURVE_HIST_RES]; + float histogram_max; + dt_pthread_mutex_t histogram_lock; + + // picked region: S_in_norm of mean/min/max + gboolean picker_valid; + float picked_s; + float picked_s_min; + float picked_s_max; +} dt_iop_satcurve_gui_data_t; + +typedef struct dt_iop_satcurve_global_data_t +{ + int kernel_satcurvergb; +} dt_iop_satcurve_global_data_t; + +const char *name() +{ + return _("saturation curve"); +} + +const char *aliases() +{ + return _("sat vs sat|saturation versus saturation|satcurve"); +} + +const char **description(dt_iop_module_t *self) +{ + return dt_iop_set_description(self, _("remap saturation with a curve"), + _("corrective or creative"), + _("linear, RGB, scene-referred"), + _("linear, RGB, scene-referred"), + _("linear, RGB, scene-referred")); +} + +int flags() +{ + return IOP_FLAGS_INCLUDE_IN_STYLES | IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_ALLOW_TILING; +} + +int default_group() +{ + return IOP_GROUP_COLOR | IOP_GROUP_GRADING; +} + +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; +} + +// intended to run before filmic rgb; pipeline order entry is added +// separately in iop_order.c + +static inline void reset_curve(dt_iop_satcurve_params_t *p) +{ + p->curve_num_nodes = 2; + p->curve_type = MONOTONE_HERMITE; + p->formula = DT_IOP_SATCURVE_DTUCS; + p->curve[0] = (dt_iop_satcurve_node_t){0.f, .5f}; + p->curve[1] = (dt_iop_satcurve_node_t){1.f, .5f}; +} + +static inline float lookup_lut(const float *lut, const float x) +{ + const float position = CLAMP(x, 0.f, 1.f) * (DT_IOP_SATCURVE_RES - 1); + const int i = MIN((int)position, DT_IOP_SATCURVE_RES - 2); + return lut[i] + (position - i) * (lut[i + 1] - lut[i]); +} + +// periodic lookup in the hue-indexed gamut LUT; h = atan2(b, a), in [-pi, pi] +static inline float satcurve_lookup_gamut(const float *const gamut_lut, const float h) +{ + const float position = (h + M_PI_F) * (float)LUT_ELEM / DT_2PI_F; + const int bin0 = ((int)floorf(position)) % LUT_ELEM; + const int bin1 = (bin0 + 1) % LUT_ELEM; + const float f = position - floorf(position); + + return gamut_lut[bin0] * (1.f - f) + gamut_lut[bin1] * f; +} + +// smoothly map x from knee onwards towards maximum (reached asymptotically); +// x <= knee is left unchanged +static inline float satcurve_soft_clip(const float x, const float knee, const float maximum) +{ + if(x <= knee) + return x; + + const float range = maximum - knee; + if(range <= 0.f) + return maximum; + + return knee + range * (1.f - expf(-(x - knee) / range)); +} + +// mirrors the JzAzBz chroma clip in color balance rgb (test conversion to +// L'M'S'): keeps the curve from producing colors that go negative on the +// JzAzBz -> XYZ back-transform +static inline float clip_jz_chroma(const float Jz, const float Cz, const float ch, const float sh) +{ + const float d0 = 1.6295499532821566e-11f; + const float dd = -0.56f; + float Iz = (Jz + d0) / (1.f + dd - dd * (Jz + d0)); + Iz = MAX(Iz, 0.f); + static const dt_colormatrix_t AI_trans = {{1.f, 1.f, 1.f, 0.f}, + {.1386050432715393f, -.1386050432715393f, -.0960192420263190f, 0.f}, + {.0580473161561189f, -.0580473161561189f, -.8118918960560390f, 0.f}}; + dt_aligned_pixel_t izab = {Iz, Cz * ch, Cz * sh, 0.f}, lms; + dt_apply_transposed_color_matrix(izab, AI_trans, lms); + float max_c = Cz; + if(lms[0] < 0.f) + max_c = MIN(max_c, -Iz / (AI_trans[1][0] * ch + AI_trans[2][0] * sh)); + if(lms[1] < 0.f) + max_c = MIN(max_c, -Iz / (AI_trans[1][1] * ch + AI_trans[2][1] * sh)); + if(lms[2] < 0.f) + max_c = MIN(max_c, -Iz / (AI_trans[1][2] * ch + AI_trans[2][2] * sh)); + return MAX(max_c, 0.f); +} + +// shared S_in_norm computation, used by process(), the histogram, and the +// picker, so all three use the exact same scene-referred math +static inline float pixel_s_in_norm_jzazbz(const float *const restrict rgb_in, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + float *const restrict h_out) +{ + dt_aligned_pixel_t rgb, xyz, jab; + copy_pixel(rgb, rgb_in); + dt_vector_clipneg(rgb); + dt_apply_transposed_color_matrix(rgb, inputmatrix_trans, xyz); + dt_XYZ_2_JzAzBz(xyz, jab); + + const float Jz = MAX(jab[0], 0.f); + const float Cz = dt_fast_hypotf(jab[1], jab[2]); + const float h = atan2f(jab[2], jab[1]); + const float gamut = MAX(satcurve_lookup_gamut(gamut_lut, h), FLT_MIN); + const float s_in = Jz > 0.f ? Cz / Jz : 0.f; + + if(h_out) *h_out = h; + return s_in / gamut; +} + +static inline float pixel_s_in_norm_ucs( + const float *const restrict rgb_in, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + const float L_white, + float *const restrict h_out) +{ + dt_aligned_pixel_t rgb, xyz, xyY, JCH, HCB; + dt_aligned_pixel_t JCH_gamut_boundary, HSB_gamut_boundary; + + copy_pixel(rgb, rgb_in); + dt_vector_clipneg(rgb); + dt_apply_transposed_color_matrix(rgb, inputmatrix_trans, xyz); + + dt_D65_XYZ_to_xyY(xyz, xyY); + xyY_to_dt_UCS_JCH(xyY, L_white, JCH); + dt_UCS_JCH_to_HCB(JCH, HCB); + + const float max_colorfulness + = MAX(satcurve_lookup_gamut(gamut_lut, JCH[2]), FLT_MIN); + + const float max_chroma + = 15.932993652962535f + * powf(JCH[0] / L_white, 0.6523997524738018f) + * powf(max_colorfulness, 0.6007557017508491f) + / L_white; + + JCH_gamut_boundary[0] = JCH[0]; + JCH_gamut_boundary[1] = max_chroma; + JCH_gamut_boundary[2] = JCH[2]; + JCH_gamut_boundary[3] = 0.f; + + dt_UCS_JCH_to_HSB(JCH_gamut_boundary, HSB_gamut_boundary); + + if(h_out) *h_out = JCH[2]; + + const float saturation = HCB[2] > 0.f ? HCB[1] / HCB[2] : 0.f; + return saturation / MAX(HSB_gamut_boundary[1], FLT_MIN); +} + +static inline float pixel_s_in_norm( + const dt_iop_satcurve_data_t *const d, + const float *const restrict rgb_in, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + const float L_white, + float *const restrict h_out) +{ + if(d->formula == DT_IOP_SATCURVE_DTUCS) + return pixel_s_in_norm_ucs(rgb_in, inputmatrix_trans, gamut_lut, L_white, h_out); + + return pixel_s_in_norm_jzazbz(rgb_in, inputmatrix_trans, gamut_lut, h_out); +} + +// updates the input-referred saturation histogram; GUI full-preview only, +// mirrors the display_mask gating used in colorzones.c +static void _update_sat_histogram(dt_iop_module_t *self, + const dt_iop_satcurve_data_t *d, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict in, + const size_t npixels) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + if(!g) return; + + float local_hist[DT_IOP_SATCURVE_HIST_RES] = { 0.f }; + const float L_white = Y_to_dt_UCS_L_star(1.f); // constant; compute once, not per pixel + + DT_OMP_FOR(reduction(+ : local_hist[:DT_IOP_SATCURVE_HIST_RES])) + for(size_t k = 0; k < npixels; k++) + { + const float s_in_norm = pixel_s_in_norm(d, in + 4 * k, inputmatrix_trans, d->gamut_lut, L_white, NULL); + const int bin = CLAMP((int)(s_in_norm * (DT_IOP_SATCURVE_HIST_RES - 1)), + 0, DT_IOP_SATCURVE_HIST_RES - 1); + local_hist[bin] += 1.f; + } + + // log1p scaling: S_in_norm is typically skewed heavily toward low values; + // the x-axis (S_in_norm) itself stays linear + float max_val = 0.f; + for(int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) + { + local_hist[i] = log1pf(local_hist[i]); + max_val = MAX(max_val, local_hist[i]); + } + + dt_pthread_mutex_lock(&g->histogram_lock); + memcpy(g->histogram, local_hist, sizeof(local_hist)); + g->histogram_max = MAX(max_val, 1e-6f); + dt_pthread_mutex_unlock(&g->histogram_lock); + + dt_control_queue_redraw_widget(GTK_WIDGET(g->area)); +} + +void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, + const void *const ivoid, void *const ovoid, + const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) +{ + dt_iop_satcurve_data_t *d = piece->data; + const gboolean neutral_curve = d->curve_num_nodes == 2 + && fabsf(d->lut[0] - .5f) < 1e-6f + && fabsf(d->lut[DT_IOP_SATCURVE_RES - 1] - .5f) < 1e-6f; + if(neutral_curve) + { + dt_iop_image_copy_by_size(ovoid, ivoid, roi_out->width, roi_out->height, piece->colors); + return; + } + + const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); + if(!work_profile || piece->colors != 4) + { + dt_iop_image_copy_by_size(ovoid, ivoid, roi_out->width, roi_out->height, piece->colors); + return; + } + + dt_colormatrix_t inputmatrix = {{ 0.0f }}; + dt_colormatrix_t outputmatrix = {{ 0.0f }}; + dt_colormatrix_t inputmatrix_trans; + dt_colormatrix_t outputmatrix_trans; + dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); + dt_colormatrix_transpose(inputmatrix_trans, inputmatrix); + dt_colormatrix_mul(outputmatrix, work_profile->matrix_out, XYZ_D65_to_D50_CAT16); + dt_colormatrix_transpose(outputmatrix_trans, outputmatrix); + + const float *const restrict in = DT_IS_ALIGNED((const float *)ivoid); + float *const restrict out = DT_IS_ALIGNED((float *)ovoid); + const size_t npixels = (size_t)roi_out->width * roi_out->height; + + if(self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) + && piece->pipe == self->dev->full.pipe) + _update_sat_histogram(self, d, inputmatrix_trans, in, npixels); + + // constant regardless of pixel data; hoisted out of the per-pixel loop + const float L_white = Y_to_dt_UCS_L_star(1.f); + + DT_OMP_FOR() + for(size_t k = 0; k < npixels; k++) + { + dt_aligned_pixel_t rgb, xyz, pixout; + copy_pixel(rgb, in + 4 * k); + dt_vector_clipneg(rgb); + dt_apply_transposed_color_matrix(rgb, inputmatrix_trans, xyz); + + if(d->formula == DT_IOP_SATCURVE_DTUCS) + { + dt_aligned_pixel_t xyY, JCH, HCB, HSB, JCH_gamut_boundary, HSB_gamut_boundary; + dt_D65_XYZ_to_xyY(xyz, xyY); + xyY_to_dt_UCS_JCH(xyY, L_white, JCH); + dt_UCS_JCH_to_HCB(JCH, HCB); + + const float max_colorfulness = MAX(satcurve_lookup_gamut(d->gamut_lut, JCH[2]), FLT_MIN); + const float max_chroma = 15.932993652962535f + * powf(JCH[0] / L_white, 0.6523997524738018f) + * powf(max_colorfulness, 0.6007557017508491f) + / L_white; + JCH_gamut_boundary[0] = JCH[0]; + JCH_gamut_boundary[1] = max_chroma; + JCH_gamut_boundary[2] = JCH[2]; + JCH_gamut_boundary[3] = 0.f; + dt_UCS_JCH_to_HSB(JCH_gamut_boundary, HSB_gamut_boundary); + + HSB[0] = HCB[0]; + HSB[1] = HCB[2] > 0.f ? HCB[1] / HCB[2] : 0.f; + HSB[2] = HCB[2]; + HSB[3] = 0.f; + + const float gamut_s = MAX(HSB_gamut_boundary[1], FLT_MIN); + const float s_in_norm = HSB[1] / gamut_s; + const float c = CLAMP(lookup_lut(d->lut, s_in_norm), 0.f, 1.f); + HSB[1] = MAX(HSB[1] * (2.f * c), 0.f); + HSB[1] = satcurve_soft_clip(HSB[1], .8f * gamut_s, gamut_s); + + dt_UCS_HSB_to_JCH(HSB, JCH); + dt_UCS_JCH_to_xyY(JCH, L_white, xyY); + dt_xyY_to_XYZ(xyY, xyz); + } + else + { + dt_aligned_pixel_t jab; + dt_XYZ_2_JzAzBz(xyz, jab); + const float Jz = MAX(jab[0], 0.f); + const float Cz = dt_fast_hypotf(jab[1], jab[2]); + const float h = atan2f(jab[2], jab[1]); + const float ch = cosf(h), sh = sinf(h); + const float gamut = MAX(satcurve_lookup_gamut(d->gamut_lut, h), FLT_MIN); + const float s_in_norm = (Jz > 0.f ? Cz / Jz : 0.f) / gamut; + const float c = CLAMP(lookup_lut(d->lut, s_in_norm), 0.f, 1.f); + const float s_out = satcurve_soft_clip(MAX(s_in_norm * (2.f * c), 0.f), .8f, 1.f) * gamut; + const float r = dt_fast_hypotf(Jz, Cz); + // T = atanf(s_out); cosf(T) = 1/sqrt(1+s_out^2), sinf(T) = s_out/sqrt(1+s_out^2) + // avoids one atanf + one cosf + one sinf per pixel + const float inv_norm = 1.f / sqrtf(1.f + s_out * s_out); + const float Jz_out = r * inv_norm; + const float Cz_out = clip_jz_chroma(Jz_out, r * s_out * inv_norm, ch, sh); + jab[0] = Jz_out; + jab[1] = Cz_out * ch; + jab[2] = Cz_out * sh; + dt_JzAzBz_2_XYZ(jab, xyz); + } + + dt_apply_transposed_color_matrix(xyz, outputmatrix_trans, pixout); + dt_vector_clipneg(pixout); + copy_pixel_nontemporal(out + 4 * k, pixout); + } + dt_omploop_sfence(); +} + +#if HAVE_OPENCL +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) +{ + dt_iop_satcurve_data_t *d = piece->data; + const dt_iop_satcurve_global_data_t *gd = self->global_data; + + cl_int err = DT_OPENCL_DEFAULT_ERROR; + if(piece->colors != 4) return err; + + const int devid = piece->pipe->devid; + const int width = roi_in->width; + const int height = roi_in->height; + + const gboolean neutral_curve = d->curve_num_nodes == 2 + && fabsf(d->lut[0] - .5f) < 1e-6f + && fabsf(d->lut[DT_IOP_SATCURVE_RES - 1] - .5f) < 1e-6f; + if(neutral_curve) + return dt_opencl_enqueue_copy_image(devid, dev_in, dev_out, (size_t[]){0,0,0}, + (size_t[]){0,0,0}, (size_t[]){width, height, 1}); + + const dt_iop_order_iccprofile_info_t *const work_profile + = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); + if(work_profile == NULL) return err; + + // Histogram needs a host-side copy of the input buffer; only do this + // when the GUI actually needs it, to avoid a needless GPU->CPU sync + // on every full-pipe run. + if(self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) + && piece->pipe == self->dev->full.pipe) + { + dt_colormatrix_t hist_input_matrix; + dt_colormatrix_t hist_input_matrix_trans; + dt_colormatrix_mul(hist_input_matrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); + dt_colormatrix_transpose(hist_input_matrix_trans, hist_input_matrix); + + float *host_in = dt_alloc_align_float((size_t)4 * width * height); + if(host_in) + { + cl_int hist_err = dt_opencl_copy_image_to_host(devid, host_in, dev_in, width, height, sizeof(float) * 4); + if(hist_err == CL_SUCCESS) + _update_sat_histogram(self, d, hist_input_matrix_trans, host_in, (size_t)width * height); + dt_free_align(host_in); + } + } + + cl_mem lut_cl = NULL; + cl_mem gamut_lut_cl = NULL; + cl_mem input_matrix_cl = NULL; + cl_mem output_matrix_cl = NULL; + + 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); + + input_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), input_matrix); + output_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), output_matrix); + lut_cl = dt_opencl_copy_host_to_device_constant(devid, DT_IOP_SATCURVE_RES * sizeof(float), d->lut); + gamut_lut_cl = dt_opencl_copy_host_to_device_constant(devid, LUT_ELEM * sizeof(float), d->gamut_lut); + + if(input_matrix_cl == NULL || output_matrix_cl == NULL || lut_cl == NULL || gamut_lut_cl == NULL) + { + err = CL_MEM_OBJECT_ALLOCATION_FAILURE; + goto error; + } + + // constant regardless of pixel data; compute once on the host instead of + // once per GPU thread inside the kernel + const float L_white = Y_to_dt_UCS_L_star(1.f); + + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_satcurvergb, width, height, + CLARG(dev_in), CLARG(dev_out), CLARG(width), CLARG(height), + CLARG(input_matrix_cl), CLARG(output_matrix_cl), + CLARG(lut_cl), CLARG(gamut_lut_cl), CLARG(d->formula), CLARG(L_white)); + +error: + dt_opencl_release_mem_object(input_matrix_cl); + dt_opencl_release_mem_object(output_matrix_cl); + dt_opencl_release_mem_object(lut_cl); + dt_opencl_release_mem_object(gamut_lut_cl); + return err; +} + +void init_global(dt_iop_module_so_t *self) +{ + const int program = 42; // satcurve.cl in programs.conf + dt_iop_satcurve_global_data_t *gd = malloc(sizeof(dt_iop_satcurve_global_data_t)); + self->data = gd; + gd->kernel_satcurvergb = dt_opencl_create_kernel(program, "satcurvergb"); +} + +void cleanup_global(dt_iop_module_so_t *self) +{ + const dt_iop_satcurve_global_data_t *gd = self->data; + dt_opencl_free_kernel(gd->kernel_satcurvergb); + free(self->data); + self->data = NULL; +} +#endif + + +void init_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece) +{ + dt_iop_satcurve_data_t *d = dt_calloc_align_type(dt_iop_satcurve_data_t, 1); + d->lut = dt_alloc_align_float(DT_IOP_SATCURVE_RES); + d->gamut_lut = dt_alloc_align_float(LUT_ELEM); + d->curve = dt_draw_curve_new(0.f, 1.f, MONOTONE_HERMITE); + d->lut_inited = FALSE; + piece->data = d; +} + +void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece) +{ + dt_iop_satcurve_data_t *d = piece->data; + dt_draw_curve_destroy(d->curve); + dt_free_align(d->lut); + dt_free_align(d->gamut_lut); + dt_free_align(d); + piece->data = NULL; +} + +// RGB/D50 -> XYZ/D65, the input expected by dt_XYZ_2_JzAzBz() +static void build_jzazbz_gamut_lut(dt_iop_satcurve_data_t *d, const dt_iop_order_iccprofile_info_t *work_profile) +{ + dt_colormatrix_t inputmatrix = { {0.0f} }; + + dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); + dt_colormatrix_t inputmatrix_trans; + dt_colormatrix_transpose(inputmatrix_trans, inputmatrix); + float *sampler = dt_calloc_align_float(LUT_ELEM); + + DT_OMP_FOR(reduction(max : sampler[:LUT_ELEM]) collapse(3)) + for(int r = 0; r < DT_IOP_SATCURVE_GAMUT_STEPS; r++) + for(int g = 0; g < DT_IOP_SATCURVE_GAMUT_STEPS; g++) + for(int b = 0; b < DT_IOP_SATCURVE_GAMUT_STEPS; b++) + { + const dt_aligned_pixel_t rgb = {r / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), + g / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), + b / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), 0.f}; + dt_aligned_pixel_t xyz, jab; + dt_apply_transposed_color_matrix(rgb, inputmatrix_trans, xyz); + dt_XYZ_2_JzAzBz(xyz, jab); + const float saturation = jab[0] > 0.f ? dt_fast_hypotf(jab[1], jab[2]) / jab[0] : 0.f; + int index = roundf((LUT_ELEM - 1) * (atan2f(jab[2], jab[1]) + M_PI_F) / DT_2PI_F); + index = index < 0 ? LUT_ELEM - 1 : (index >= LUT_ELEM ? 0 : index); + sampler[index] = MAX(sampler[index], saturation); + } + + for(size_t i = 2; i < LUT_ELEM - 2; i++) + d->gamut_lut[i] = (sampler[i - 2] + sampler[i - 1] + sampler[i] + sampler[i + 1] + sampler[i + 2]) / 5.f; + d->gamut_lut[0] = (sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0] + sampler[1] + sampler[2]) / 5.f; + d->gamut_lut[1] = (sampler[LUT_ELEM - 1] + sampler[0] + sampler[1] + sampler[2] + sampler[3]) / 5.f; + d->gamut_lut[LUT_ELEM - 2] = (sampler[LUT_ELEM - 4] + sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0]) / 5.f; + d->gamut_lut[LUT_ELEM - 1] = (sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0] + sampler[1]) / 5.f; + dt_free_align(sampler); +} + +static void build_gamut_lut(dt_iop_satcurve_data_t *d, + const dt_iop_order_iccprofile_info_t *work_profile) +{ + if(d->formula == DT_IOP_SATCURVE_DTUCS) + { + dt_colormatrix_t inputmatrix = {{ 0.0f }}; + dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); + dt_UCS_22_build_gamut_LUT(inputmatrix, d->gamut_lut); + } + else + build_jzazbz_gamut_lut(d, work_profile); +} + +void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_t *pipe, + dt_dev_pixelpipe_iop_t *piece) +{ + dt_iop_satcurve_data_t *d = piece->data; + const dt_iop_satcurve_params_t *p = (const dt_iop_satcurve_params_t *)p1; + if(d->formula != p->formula) + { + d->formula = p->formula; + d->lut_inited = FALSE; + } + if(d->curve_type != p->curve_type || d->curve_num_nodes != p->curve_num_nodes) + { + dt_draw_curve_destroy(d->curve); + d->curve = dt_draw_curve_new(0.f, 1.f, p->curve_type); + d->curve_type = p->curve_type; + d->curve_num_nodes = p->curve_num_nodes; + for(int i = 0; i < p->curve_num_nodes; i++) + dt_draw_curve_add_point(d->curve, p->curve[i].x, p->curve[i].y); + } + else + for(int i = 0; i < p->curve_num_nodes; i++) + dt_draw_curve_set_point(d->curve, i, p->curve[i].x, p->curve[i].y); + dt_draw_curve_calc_values(d->curve, 0.f, 1.f, DT_IOP_SATCURVE_RES, NULL, d->lut); + + const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); + if(work_profile && (!d->lut_inited || work_profile != d->work_profile)) + { + build_gamut_lut(d, work_profile); + d->work_profile = work_profile; + d->lut_inited = TRUE; + } +} + +void init_presets(dt_iop_module_so_t *self) +{ + dt_iop_satcurve_params_t p = {0}; + reset_curve(&p); + dt_gui_presets_add_generic(_("neutral"), self->op, self->version(), &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + + p.curve[0].y = .60f; + p.curve[1].y = .55f; + dt_gui_presets_add_generic(_("gentle saturation"), self->op, self->version(), &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + + reset_curve(&p); + p.curve_num_nodes = 3; + p.curve[1] = (dt_iop_satcurve_node_t){.45f, .62f}; + p.curve[2] = (dt_iop_satcurve_node_t){1.f, .42f}; + dt_gui_presets_add_generic(_("protect saturated colors"), self->op, self->version(), &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + + reset_curve(&p); + p.curve_num_nodes = 3; + p.curve[0].y = .38f; + p.curve[1] = (dt_iop_satcurve_node_t){.35f, .48f}; + p.curve[2] = (dt_iop_satcurve_node_t){1.f, .58f}; + dt_gui_presets_add_generic(_("boost saturated colors"), self->op, self->version(), &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); +} + +static inline int add_node(dt_iop_satcurve_params_t *p, const float x, const float y) +{ + int at = 0; + while(at < p->curve_num_nodes && p->curve[at].x < x) + at++; + if((at && x - p->curve[at - 1].x < DT_IOP_SATCURVE_MIN_X_DISTANCE) || + (at < p->curve_num_nodes && p->curve[at].x - x < DT_IOP_SATCURVE_MIN_X_DISTANCE)) + return -1; + for(int i = p->curve_num_nodes; i > at; i--) + p->curve[i] = p->curve[i - 1]; + p->curve[at] = (dt_iop_satcurve_node_t){x, y}; + p->curve_num_nodes++; + return at; +} + +// paints the input-referred saturation histogram as a filled area behind +// the curve; similar to the histogram overlay in colorzones.c, but backed +// by our own float accumulator instead of the Lab-based histogram +static void _draw_sat_histogram(cairo_t *cr, dt_iop_satcurve_gui_data_t *g, const int w, const int h) +{ + dt_pthread_mutex_lock(&g->histogram_lock); + const float hist_max = g->histogram_max; + cairo_save(cr); + cairo_set_source_rgba(cr, .8, .8, .8, .35); + cairo_move_to(cr, 0, h); + for(int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) + { + const float x = w * i / (float)(DT_IOP_SATCURVE_HIST_RES - 1); + const float y = h * (1.f - g->histogram[i] / hist_max); + cairo_line_to(cr, x, y); + } + cairo_line_to(cr, w, h); + cairo_close_path(cr); + cairo_fill(cr); + cairo_restore(cr); + dt_pthread_mutex_unlock(&g->histogram_lock); +} + +// paints the picker overlay (mean/min/max S_in_norm of the picked region), +// analogous to colorequal.c / colorzones.c but using our own S_in_norm +// metric instead of Lab/LCh or dt UCS HSB +static void _draw_sat_picker(cairo_t *cr, dt_iop_module_t *self, const int w, const int h) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + if(!g->picker_valid) return; + if(self->request_color_pick != DT_REQUEST_COLORPICK_MODULE) return; + + const float x_mean = CLAMP(g->picked_s, 0.f, 1.f) * w; + const float x_min = CLAMP(g->picked_s_min, 0.f, 1.f) * w; + const float x_max = CLAMP(g->picked_s_max, 0.f, 1.f) * w; + + cairo_save(cr); + cairo_set_source_rgba(cr, 0.5, 0.7, 0.5, 0.25); + cairo_rectangle(cr, x_min, 0, fmaxf(x_max - x_min, 0.f), h); + cairo_fill(cr); + + cairo_set_source_rgba(cr, 0.5, 0.9, 0.5, 0.9); + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(2.)); + cairo_move_to(cr, x_mean, 0); + cairo_line_to(cr, x_mean, h); + cairo_stroke(cr); + cairo_restore(cr); +} + +// shared geometry for the curve graph itself: origin is offset from the +// widget's top-left by the inset; only the bottom reserves extra space +// for the decorative gradient bar, so area_draw and the pointer +// hit-testing functions always agree on where (0,0)-(1,1) map to +static inline void _get_graph_geometry(const GtkAllocation *a, float *x0, float *y0, float *w, float *h) +{ + const float inset = DT_IOP_SATCURVE_INSET; + const float reserved = DT_IOP_SATCURVE_GRADIENT_SIZE + DT_IOP_SATCURVE_GRADIENT_GAP; + *x0 = inset; + *y0 = inset; + *w = a->width - 2.f * inset; + *h = a->height - 2.f * inset - reserved; +} + +static gboolean area_draw(GtkWidget *widget, cairo_t *cr, dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + dt_iop_satcurve_params_t *p = self->params; + GtkAllocation a; + gtk_widget_get_allocation(widget, &a); + float gx0, gy0, gw, gh; + _get_graph_geometry(&a, &gx0, &gy0, &gw, &gh); + const int w = (int)gw, h = (int)gh; + if(g->curve_type != p->curve_type || g->curve_num_nodes != p->curve_num_nodes) + { + dt_draw_curve_destroy(g->curve); + g->curve = dt_draw_curve_new(0.f, 1.f, p->curve_type); + g->curve_type = p->curve_type; + g->curve_num_nodes = p->curve_num_nodes; + for(int i = 0; i < p->curve_num_nodes; i++) + dt_draw_curve_add_point(g->curve, p->curve[i].x, p->curve[i].y); + } + else + for(int i = 0; i < p->curve_num_nodes; i++) + dt_draw_curve_set_point(g->curve, i, p->curve[i].x, p->curve[i].y); + dt_draw_curve_calc_values(g->curve, 0.f, 1.f, DT_IOP_SATCURVE_RES, NULL, g->draw_ys); + + // background first: cairo_paint() fills the whole widget regardless of + // any later cairo_translate(), so it must happen before the gradient + // bars are drawn, or it would overpaint them + cairo_set_source_rgb(cr, .18, .18, .18); + cairo_paint(cr); + + // purely decorative perceptual gradient bar, below the graph; same + // cairo_pattern_create_linear + dt_cairo_perceptual_gradient combo as + // used in toneequal.c, just without any axis legend + cairo_pattern_t *grad; + + // horizontal bar, below the graph + grad = cairo_pattern_create_linear(gx0, 0.0, gx0 + gw, 0.0); + dt_cairo_perceptual_gradient(grad, 1.0); + cairo_set_line_width(cr, 0.0); + cairo_rectangle(cr, gx0, gy0 + gh + DT_IOP_SATCURVE_GRADIENT_GAP, + gw, DT_IOP_SATCURVE_GRADIENT_SIZE); + cairo_set_source(cr, grad); + cairo_fill(cr); + cairo_pattern_destroy(grad); + + cairo_translate(cr, gx0, gy0); + cairo_set_source_rgba(cr, .4, .4, .4, .5); + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1)); + for(int i = 1; i < 4; i++) + { + cairo_move_to(cr, w * i / 4.f, 0); + cairo_line_to(cr, w * i / 4.f, h); + cairo_move_to(cr, 0, h * i / 4.f); + cairo_line_to(cr, w, h * i / 4.f); + } + cairo_stroke(cr); + + // input histogram first, so it stays in the background + _draw_sat_histogram(cr, g, w, h); + _draw_sat_picker(cr, self, w, h); + + cairo_set_source_rgb(cr, .85, .85, .85); + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(2)); + cairo_move_to(cr, 0, h * (1.f - g->draw_ys[0])); + for(int i = 1; i < DT_IOP_SATCURVE_RES; i++) + cairo_line_to(cr, w * i / (float)(DT_IOP_SATCURVE_RES - 1), h * (1.f - g->draw_ys[i])); + cairo_stroke(cr); + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1)); + for(int i = 0; i < p->curve_num_nodes; i++) + { + cairo_arc(cr, w * p->curve[i].x, h * (1.f - p->curve[i].y), DT_PIXEL_APPLY_DPI(i == g->selected ? 5 : 3), 0, DT_2PI_F); + cairo_stroke(cr); + } + return FALSE; +} + +static gboolean area_button(GtkWidget *widget, GdkEventButton *event, dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + dt_iop_satcurve_params_t *p = self->params; + GtkAllocation a; + gtk_widget_get_allocation(widget, &a); + float gx0, gy0, w, h; + _get_graph_geometry(&a, &gx0, &gy0, &w, &h); + const float x = CLAMP((event->x - gx0) / w, 0.f, 1.f), y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); + int hit = -1; + for(int i = 0; i < p->curve_num_nodes; i++) + if(sqf(x - p->curve[i].x) + sqf(y - p->curve[i].y) < .0025f) + { + hit = i; + break; + } + + if(event->type == GDK_2BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) + { + reset_curve(p); + g->selected = -1; + dt_dev_add_history_item(darktable.develop, self, TRUE); + gtk_widget_queue_draw(widget); + return TRUE; + } + if(event->button == GDK_BUTTON_SECONDARY && hit >= 0) + { + if((event->state & GDK_CONTROL_MASK) || p->curve_num_nodes <= 2) + p->curve[hit].y = .5f; + else + { + for(int i = hit; i < p->curve_num_nodes - 1; i++) + p->curve[i] = p->curve[i + 1]; + p->curve_num_nodes--; + } + g->selected = -1; + dt_dev_add_history_item(darktable.develop, self, TRUE); + gtk_widget_queue_draw(widget); + return TRUE; + } + if(event->button == GDK_BUTTON_PRIMARY) + { + // clicking/dragging outside an existing node inserts a new one on the curve + if(hit < 0 && p->curve_num_nodes < DT_IOP_SATCURVE_MAXNODES) + hit = add_node(p, x, CLAMP(dt_draw_curve_calc_value(g->curve, x), 0.f, 1.f)); + g->selected = hit; + g->dragging = hit >= 0; + return TRUE; + } + return FALSE; +} + +static gboolean area_motion(GtkWidget *widget, GdkEventMotion *event, dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + dt_iop_satcurve_params_t *p = self->params; + if(!g->dragging || g->selected < 0) + return FALSE; + GtkAllocation a; + gtk_widget_get_allocation(widget, &a); + float gx0, gy0, w, h; + _get_graph_geometry(&a, &gx0, &gy0, &w, &h); + const float x = CLAMP((event->x - gx0) / w, 0.f, 1.f); + const int n = g->selected; + if(n == 0) + p->curve[n].x = 0.f; + else if(n == p->curve_num_nodes - 1) + p->curve[n].x = 1.f; + else + p->curve[n].x = CLAMP(x, p->curve[n - 1].x + DT_IOP_SATCURVE_MIN_X_DISTANCE, p->curve[n + 1].x - DT_IOP_SATCURVE_MIN_X_DISTANCE); + p->curve[n].y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); + gtk_widget_queue_draw(widget); + return TRUE; +} + +static gboolean area_release(GtkWidget *widget, GdkEventButton *event, dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + if(g->dragging) + { + g->dragging = FALSE; + dt_dev_add_history_item(darktable.develop, self, TRUE); + } + return TRUE; +} + +// returns the module's committed gamut_lut if already available (module +// processed at least once); otherwise falls back to a neutral unity LUT +// (gamut = 1 for all hues) so the picker cannot crash right after loading, +// though it then shows unnormalized S values +static const float *_get_gamut_lut_for_picker(dt_iop_module_t *self) +{ + static float unity_gamut_lut[LUT_ELEM]; + static gboolean unity_inited = FALSE; + if(!unity_inited) + { + for(int i = 0; i < LUT_ELEM; i++) unity_gamut_lut[i] = 1.f; + unity_inited = TRUE; + } + + for(GList *nodes = self->dev->full.pipe ? self->dev->full.pipe->nodes : NULL; nodes; nodes = g_list_next(nodes)) + { + dt_dev_pixelpipe_iop_t *piece = nodes->data; + if(piece->module == self && piece->data) + { + const dt_iop_satcurve_data_t *d = piece->data; + if(d->lut_inited && d->gamut_lut) return d->gamut_lut; + } + } + return unity_gamut_lut; +} + +// called automatically after a color pick while g->colorpicker is active. +// self->picked_color/-min/-max are already in pipeline RGB (default_colorspace +// is IOP_CS_RGB), so they go straight through pixel_s_in_norm() using the +// same metric as the curve and histogram +void color_picker_apply(dt_iop_module_t *self, GtkWidget *widget, dt_dev_pixelpipe_t *pipe) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + if(!g) return; + + const dt_iop_order_iccprofile_info_t *work_profile = + dt_ioppr_get_iop_work_profile_info(self, self->dev->iop); + if(!work_profile) return; + + dt_colormatrix_t inputmatrix = {{ 0.0f }}; + dt_colormatrix_t inputmatrix_trans; + dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); + dt_colormatrix_transpose(inputmatrix_trans, inputmatrix); + + const float *const gamut_lut = _get_gamut_lut_for_picker(self); + const float L_white = Y_to_dt_UCS_L_star(1.f); + + const dt_aligned_pixel_t mean_rgb = { self->picked_color[0], self->picked_color[1], self->picked_color[2], 0.f }; + const dt_aligned_pixel_t min_rgb = { self->picked_color_min[0], self->picked_color_min[1], self->picked_color_min[2], 0.f }; + const dt_aligned_pixel_t max_rgb = { self->picked_color_max[0], self->picked_color_max[1], self->picked_color_max[2], 0.f }; + + dt_iop_satcurve_data_t picker_data = { 0 }; + picker_data.formula = ((const dt_iop_satcurve_params_t *)self->params)->formula; + g->picked_s = pixel_s_in_norm(&picker_data, mean_rgb, inputmatrix_trans, gamut_lut, L_white, NULL); + g->picked_s_min = pixel_s_in_norm(&picker_data, min_rgb, inputmatrix_trans, gamut_lut, L_white, NULL); + g->picked_s_max = pixel_s_in_norm(&picker_data, max_rgb, inputmatrix_trans, gamut_lut, L_white, NULL); + g->picker_valid = TRUE; + + gtk_widget_queue_draw(GTK_WIDGET(g->area)); +} + +// reset the picker state when the module loses GUI focus, so no stale +// overlay lingers in another module +void gui_focus(dt_iop_module_t *self, gboolean in) +{ + if(!in) + dt_iop_color_picker_reset(self, FALSE); +} + +// S_in_norm is defined differently per formula (JzAzBz vs darktable UCS), so +// a histogram computed under the previous formula is stale and must not +// linger on screen after switching. The bauhaus binding for the formula +// combobox already updates self->params and adds a history item, but not +// necessarily a *forced* one (see the explicit TRUE argument used for curve +// node edits below) - force it here as well so process() always re-runs +// and _update_sat_histogram() refreshes the histogram in either direction. +void gui_changed(dt_iop_module_t *self, GtkWidget *widget, void *previous) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + if(!g) return; + + if(widget == g->formula) + dt_dev_add_history_item(darktable.develop, self, TRUE); +} + +void gui_update(dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + const dt_iop_satcurve_params_t *p = self->params; + if(!g) return; + + if(g->formula) dt_bauhaus_combobox_set(g->formula, p->formula); + if(g->area) gtk_widget_queue_draw(GTK_WIDGET(g->area)); +} + +void gui_cleanup(dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + dt_draw_curve_destroy(g->curve); + dt_pthread_mutex_destroy(&g->histogram_lock); +} + +void gui_init(dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = IOP_GUI_ALLOC(satcurve); + const dt_iop_satcurve_params_t *p = self->default_params; + g->selected = -1; + g->curve_type = p->curve_type; + g->curve_num_nodes = p->curve_num_nodes; + g->curve = dt_draw_curve_new(0.f, 1.f, p->curve_type); + for(int i = 0; i < p->curve_num_nodes; i++) + dt_draw_curve_add_point(g->curve, p->curve[i].x, p->curve[i].y); + + memset(g->histogram, 0, sizeof(g->histogram)); + g->histogram_max = 1e-6f; + dt_pthread_mutex_init(&g->histogram_lock, NULL); + + g->picker_valid = FALSE; + g->picked_s = g->picked_s_min = g->picked_s_max = 0.f; + + self->widget = dt_gui_vbox(); + + g->area = GTK_DRAWING_AREA(dt_ui_resize_wrap(NULL, 0, "plugins/darkroom/satcurve/graph_height")); + gtk_widget_set_size_request(GTK_WIDGET(g->area), -1, DT_PIXEL_APPLY_DPI(180)); + gtk_widget_add_events(GTK_WIDGET(g->area), GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK); + g_signal_connect(G_OBJECT(g->area), "draw", G_CALLBACK(area_draw), self); + g_signal_connect(G_OBJECT(g->area), "button-press-event", G_CALLBACK(area_button), self); + g_signal_connect(G_OBJECT(g->area), "button-release-event", G_CALLBACK(area_release), self); + g_signal_connect(G_OBJECT(g->area), "motion-notify-event", G_CALLBACK(area_motion), self); + dt_gui_box_add(self->widget, GTK_WIDGET(g->area)); + + // Row below the draw area: picker button on the left, formula combobox on + // the right. dt_bauhaus_combobox_from_params() auto-packs the widget into + // self->widget, so it is detached here and re-packed into the shared hbox + // (pack_start/pack_end keep both widgets at their natural width with a + // flexible gap in between). + // dt_color_picker_new_with_cst() is used instead of dt_color_picker_new() + // because the latter attaches the picker icon to an existing bauhaus + // widget rather than creating a standalone button. + g->formula = dt_bauhaus_combobox_from_params(self, "formula"); + dt_bauhaus_combobox_add(g->formula, _("JzAzBz")); + dt_bauhaus_combobox_add(g->formula, _("darktable UCS")); + gtk_widget_set_tooltip_text(g->formula, + _("choose the perceptual saturation definition used by the curve")); + + g_object_ref(g->formula); + gtk_container_remove(GTK_CONTAINER(self->widget), g->formula); + + GtkWidget *controls_hbox = dt_gui_hbox(); + gtk_box_set_spacing(GTK_BOX(controls_hbox), DT_PIXEL_APPLY_DPI(5)); + + g->colorpicker = dt_color_picker_new_with_cst(self, DT_COLOR_PICKER_POINT, NULL, IOP_CS_RGB); + gtk_widget_set_tooltip_text(g->colorpicker, _("pick saturation from image")); + gtk_box_pack_start(GTK_BOX(controls_hbox), g->colorpicker, FALSE, FALSE, 0); + gtk_box_pack_end(GTK_BOX(controls_hbox), g->formula, FALSE, FALSE, 0); + g_object_unref(g->formula); + + dt_gui_box_add(self->widget, controls_hbox); +} + +void init(dt_iop_module_t *self) +{ + self->params = calloc(1, sizeof(dt_iop_satcurve_params_t)); + self->default_params = calloc(1, sizeof(dt_iop_satcurve_params_t)); + self->params_size = sizeof(dt_iop_satcurve_params_t); + reset_curve(self->params); + reset_curve(self->default_params); +} + +// 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 \ No newline at end of file From 79f7bdde547bb1c068ffcc004e230ac408761951 Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:57:24 +0200 Subject: [PATCH 02/12] Initialize hist_input_matrix to zero --- src/iop/satcurvergb.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c index 51e55d45e743..38201f112178 100644 --- a/src/iop/satcurvergb.c +++ b/src/iop/satcurvergb.c @@ -478,7 +478,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, if(self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) && piece->pipe == self->dev->full.pipe) { - dt_colormatrix_t hist_input_matrix; + dt_colormatrix_t hist_input_matrix = {{ 0.0f }}; dt_colormatrix_t hist_input_matrix_trans; dt_colormatrix_mul(hist_input_matrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); dt_colormatrix_transpose(hist_input_matrix_trans, hist_input_matrix); @@ -1087,4 +1087,4 @@ void init(dt_iop_module_t *self) // 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 \ No newline at end of file +// clang-format on From 7a265530c0d52ddb590bf78257bab5d885efb068 Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:48:13 +0200 Subject: [PATCH 03/12] added brilliance curve --- data/kernels/satcurve.cl | 127 +++-- src/iop/satcurvergb.c | 1113 ++++++++++++++++++++++++-------------- 2 files changed, 790 insertions(+), 450 deletions(-) mode change 100644 => 100755 data/kernels/satcurve.cl diff --git a/data/kernels/satcurve.cl b/data/kernels/satcurve.cl old mode 100644 new mode 100755 index e73ac332311c..3abe25a56bfd --- a/data/kernels/satcurve.cl +++ b/data/kernels/satcurve.cl @@ -1,26 +1,22 @@ /* - This file is part of darktable, - Copyright (C) 2009-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 . + This file is part of darktable, + Copyright (C) 2009-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. */ #include "common.h" #include "colorspace.h" #include "color_conversion.h" - #define DT_IOP_SATCURVE_RES 256 // periodic lookup in the hue-indexed gamut LUT; mirrors satcurve_lookup_gamut() on CPU @@ -60,9 +56,12 @@ static inline float clip_jz_chroma_cl(const float Jz, const float Cz, const floa float Iz = (Jz + d0) / (1.f + dd - dd * (Jz + d0)); Iz = fmax(Iz, 0.f); - const float4 AI[3] = { { 1.0f, 0.1386050432715393f, 0.0580473161561189f, 0.0f }, - { 1.0f, -0.1386050432715393f, -0.0580473161561189f, 0.0f }, - { 1.0f, -0.0960192420263190f, -0.8118918960560390f, 0.0f } }; + const float4 AI[3] = { + { 1.0f, 0.1386050432715393f, 0.0580473161561189f, 0.0f }, + { 1.0f, -0.1386050432715393f, -0.0580473161561189f, 0.0f }, + { 1.0f, -0.0960192420263190f, -0.8118918960560390f, 0.0f } + }; + const float4 izab = { Iz, Cz * ch, Cz * sh, 0.f }; const float lms0 = dot(AI[0], izab); const float lms1 = dot(AI[1], izab); @@ -78,14 +77,39 @@ static inline float clip_jz_chroma_cl(const float Jz, const float Cz, const floa typedef enum dt_iop_satcurve_formula_t { DT_IOP_SATCURVE_JZAZBZ = 0, - DT_IOP_SATCURVE_DTUCS = 1 + DT_IOP_SATCURVE_DTUCS = 1 } dt_iop_satcurve_formula_t; +static inline float curve_to_factor_cl(const float c) +{ + return fmax(2.f * c, 0.f); +} + +// maximum HSB saturation reachable at the gamut boundary for given J, h in dt UCS; +// mirrors satcurve_ucs_gamut_saturation() on CPU -- single source of truth for the +// gamut-boundary fit, do not re-derive it inline in the kernel. +static inline float satcurve_ucs_gamut_saturation_cl(const float J, const float h, + const float L_white, + global const float *const gamut_lut) +{ + const float max_colorfulness = fmax(satcurve_lookup_gamut_cl(gamut_lut, h), FLT_MIN); + const float max_chroma = 15.932993652962535f + * dtcl_pow(J / L_white, 0.6523997524738018f) + * dtcl_pow(max_colorfulness, 0.6007557017508491f) + / L_white; + + const float4 JCH_gamut_boundary = { J, max_chroma, h, 0.f }; + const float4 HSB_gamut_boundary = dt_UCS_JCH_to_HSB(JCH_gamut_boundary); + + return fmax(HSB_gamut_boundary.y, FLT_MIN); +} + kernel void satcurvergb(read_only image2d_t in, write_only image2d_t out, const int width, const int height, constant const float *const matrix_in, constant const float *const matrix_out, - global const float *const lut, global const float *const gamut_lut, + global const float *const sat_lut, global const float *const bri_lut, + global const float *const gamut_lut, const int formula, const float L_white) { const int x = get_global_id(0); @@ -104,48 +128,67 @@ satcurvergb(read_only image2d_t in, write_only image2d_t out, float4 JCH = xyY_to_dt_UCS_JCH(xyY, L_white); float4 HCB = dt_UCS_JCH_to_HCB(JCH); - const float max_colorfulness = fmax(satcurve_lookup_gamut_cl(gamut_lut, JCH.z), FLT_MIN); - const float max_chroma = 15.932993652962535f - * dtcl_pow(JCH.x / L_white, 0.6523997524738018f) - * dtcl_pow(max_colorfulness, 0.6007557017508491f) / L_white; - - const float4 JCH_gamut_boundary = { JCH.x, max_chroma, JCH.z, 0.f }; - const float4 HSB_gamut_boundary = dt_UCS_JCH_to_HSB(JCH_gamut_boundary); - float4 HSB = { HCB.x, HCB.z > 0.f ? HCB.y / HCB.z : 0.f, HCB.z, 0.f }; - const float gamut_s = fmax(HSB_gamut_boundary.y, FLT_MIN); + const float gamut_s = satcurve_ucs_gamut_saturation_cl(JCH.x, JCH.z, L_white, gamut_lut); const float s_in_norm = HSB.y / gamut_s; - const float c = clamp(satcurve_lookup_lut_cl(lut, s_in_norm), 0.f, 1.f); - HSB.y = fmax(HSB.y * (2.f * c), 0.f); + + const float sat_c = clamp(satcurve_lookup_lut_cl(sat_lut, s_in_norm), 0.f, 1.f); + const float bri_c = clamp(satcurve_lookup_lut_cl(bri_lut, s_in_norm), 0.f, 1.f); + const float sat_factor = curve_to_factor_cl(sat_c); + const float bri_factor = curve_to_factor_cl(bri_c); + + HSB.y = fmax(HSB.y * sat_factor, 0.f); HSB.y = satcurve_soft_clip_cl(HSB.y, 0.8f * gamut_s, gamut_s); JCH = dt_UCS_HSB_to_JCH(HSB); + HCB = dt_UCS_JCH_to_HCB(JCH); + + HCB.y = fmax(HCB.y * bri_factor, 0.f); + HCB.z = fmax(HCB.z * bri_factor, 0.f); + + JCH = dt_UCS_HCB_to_JCH(HCB); + + const float gamut_s_out = satcurve_ucs_gamut_saturation_cl(JCH.x, JCH.z, L_white, gamut_lut); + + float4 HSB_out = { HCB.x, HCB.z > 0.f ? HCB.y / HCB.z : 0.f, HCB.z, 0.f }; + HSB_out.y = satcurve_soft_clip_cl(HSB_out.y, 0.8f * gamut_s_out, gamut_s_out); + + JCH = dt_UCS_HSB_to_JCH(HSB_out); xyY = dt_UCS_JCH_to_xyY(JCH, L_white); xyz = dt_xyY_to_XYZ(xyY); } else { float4 jab = XYZ_to_JzAzBz(xyz); + const float Jz = fmax(jab.x, 0.f); const float Cz = hypot(jab.y, jab.z); - const float h = atan2(jab.z, jab.y); + const float h = atan2(jab.z, jab.y); const float ch = dtcl_cos(h), sh = dtcl_sin(h); const float gamut = fmax(satcurve_lookup_gamut_cl(gamut_lut, h), FLT_MIN); + const float s_in_norm = (Jz > 0.f ? Cz / Jz : 0.f) / gamut; - const float c = clamp(satcurve_lookup_lut_cl(lut, s_in_norm), 0.f, 1.f); - const float s_out = satcurve_soft_clip_cl(fmax(s_in_norm * (2.f * c), 0.f), 0.8f, 1.f) * gamut; + + const float sat_c = clamp(satcurve_lookup_lut_cl(sat_lut, s_in_norm), 0.f, 1.f); + const float bri_c = clamp(satcurve_lookup_lut_cl(bri_lut, s_in_norm), 0.f, 1.f); + const float sat_factor = curve_to_factor_cl(sat_c); + const float bri_factor = curve_to_factor_cl(bri_c); + + const float s_out = satcurve_soft_clip_cl(fmax(s_in_norm * sat_factor, 0.f), 0.8f, 1.f) * gamut; const float r = hypot(Jz, Cz); - // T = atan(s_out); cos(T) = 1/sqrt(1+s_out^2), sin(T) = s_out/sqrt(1+s_out^2) - // avoids one atan + one cos + one sin per pixel const float inv_norm = 1.f / sqrt(1.f + s_out * s_out); - const float Jz_out = r * inv_norm; - const float Cz_out = clip_jz_chroma_cl(Jz_out, r * s_out * inv_norm, ch, sh); - jab.x = Jz_out; - jab.y = Cz_out * ch; - jab.z = Cz_out * sh; + float Jz_tmp = r * inv_norm; + float Cz_tmp = clip_jz_chroma_cl(Jz_tmp, r * s_out * inv_norm, ch, sh); + + Jz_tmp *= bri_factor; + Cz_tmp = clip_jz_chroma_cl(Jz_tmp, Cz_tmp * bri_factor, ch, sh); + + jab.x = Jz_tmp; + jab.y = Cz_tmp * ch; + jab.z = Cz_tmp * sh; xyz = JzAzBz_2_XYZ(jab); } diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c index 38201f112178..5b8822c60b34 100644 --- a/src/iop/satcurvergb.c +++ b/src/iop/satcurvergb.c @@ -1,20 +1,18 @@ /* - This file is part of darktable, - Copyright (C) 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 . + This file is part of darktable, + Copyright (C) 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. */ + // our includes go first: #include "bauhaus/bauhaus.h" #include "common/chromatic_adaptation.h" @@ -38,70 +36,93 @@ #define DT_IOP_SATCURVE_RES 256 #define DT_IOP_SATCURVE_HIST_RES 256 #define DT_IOP_SATCURVE_INSET DT_PIXEL_APPLY_DPI(5) -// thickness of, and gap to, the decorative perceptual gradient bar -// drawn below the curve graph #define DT_IOP_SATCURVE_GRADIENT_SIZE DT_PIXEL_APPLY_DPI(8) #define DT_IOP_SATCURVE_GRADIENT_GAP DT_PIXEL_APPLY_DPI(2) #define DT_IOP_SATCURVE_MIN_X_DISTANCE 0.0025f #define DT_IOP_SATCURVE_GAMUT_STEPS 92 +#define DT_IOP_SATCURVE_CHANNELS 2 -// version of the module's serialized parameters -DT_MODULE_INTROSPECTION(1, dt_iop_satcurve_params_t) +DT_MODULE_INTROSPECTION(2, dt_iop_satcurve_params_t) -// saturation definition used to compute S_in_norm and drive the curve typedef enum dt_iop_satcurve_formula_t { DT_IOP_SATCURVE_JZAZBZ = 0, DT_IOP_SATCURVE_DTUCS = 1 } dt_iop_satcurve_formula_t; +typedef enum dt_iop_satcurve_channel_t +{ + DT_IOP_SATCURVE_CHANNEL_SATURATION = 0, + DT_IOP_SATCURVE_CHANNEL_BRILLIANCE = 1 +} dt_iop_satcurve_channel_t; + typedef struct dt_iop_satcurve_node_t { float x, y; } dt_iop_satcurve_node_t; -// module parameters, serialized to the database -typedef struct dt_iop_satcurve_params_t +typedef struct dt_iop_satcurve_channel_params_t { dt_iop_satcurve_node_t curve[DT_IOP_SATCURVE_MAXNODES]; int curve_num_nodes; int curve_type; +} dt_iop_satcurve_channel_params_t; + +typedef struct dt_iop_satcurve_params_t +{ + dt_iop_satcurve_channel_params_t channel[DT_IOP_SATCURVE_CHANNELS]; dt_iop_satcurve_formula_t formula; // $DEFAULT: 1 $DESCRIPTION: "saturation formula" } dt_iop_satcurve_params_t; -// per-pixelpipe runtime data, derived from params_t in commit_params() -typedef struct dt_iop_satcurve_data_t +typedef struct dt_iop_satcurve_params_v1_t { - dt_draw_curve_t *curve; + dt_iop_satcurve_node_t curve[DT_IOP_SATCURVE_MAXNODES]; int curve_num_nodes; int curve_type; dt_iop_satcurve_formula_t formula; +} dt_iop_satcurve_params_v1_t; + +typedef struct dt_iop_satcurve_channel_data_t +{ + dt_draw_curve_t *curve; + int curve_num_nodes; + int curve_type; float *lut; +} dt_iop_satcurve_channel_data_t; + +typedef struct dt_iop_satcurve_data_t +{ + dt_iop_satcurve_formula_t formula; + dt_iop_satcurve_channel_data_t channel[DT_IOP_SATCURVE_CHANNELS]; float *gamut_lut; gboolean lut_inited; const struct dt_iop_order_iccprofile_info_t *work_profile; } dt_iop_satcurve_data_t; -// GUI state and widget handles -typedef struct dt_iop_satcurve_gui_data_t +typedef struct dt_iop_satcurve_gui_channel_t { - GtkDrawingArea *area; - GtkWidget *colorpicker; - GtkWidget *formula; dt_draw_curve_t *curve; int curve_num_nodes; int curve_type; float draw_ys[DT_IOP_SATCURVE_RES]; +} dt_iop_satcurve_gui_channel_t; + +typedef struct dt_iop_satcurve_gui_data_t +{ + GtkDrawingArea *area; + GtkWidget *colorpicker; + GtkWidget *formula; + GtkNotebook *notebook; + + dt_iop_satcurve_gui_channel_t channel[DT_IOP_SATCURVE_CHANNELS]; + dt_iop_satcurve_channel_t active_channel; int selected; gboolean dragging; - float mouse_x, mouse_y; - // input saturation histogram (S_in_norm) float histogram[DT_IOP_SATCURVE_HIST_RES]; float histogram_max; dt_pthread_mutex_t histogram_lock; - // picked region: S_in_norm of mean/min/max gboolean picker_valid; float picked_s; float picked_s_min; @@ -113,6 +134,12 @@ typedef struct dt_iop_satcurve_global_data_t int kernel_satcurvergb; } dt_iop_satcurve_global_data_t; +typedef struct dt_iop_satcurve_factors_t +{ + float sat_factor; + float bri_factor; +} dt_iop_satcurve_factors_t; + const char *name() { return _("saturation curve"); @@ -120,16 +147,16 @@ const char *name() const char *aliases() { - return _("sat vs sat|saturation versus saturation|satcurve"); + return _("sat vs sat|saturation versus saturation|brilliance curve|satcurve"); } const char **description(dt_iop_module_t *self) { - return dt_iop_set_description(self, _("remap saturation with a curve"), - _("corrective or creative"), - _("linear, RGB, scene-referred"), - _("linear, RGB, scene-referred"), - _("linear, RGB, scene-referred")); + return dt_iop_set_description(self, _("remap saturation and brilliance with curves"), + _("corrective or creative"), + _("linear, RGB, scene-referred"), + _("linear, RGB, scene-referred"), + _("linear, RGB, scene-referred")); } int flags() @@ -142,22 +169,26 @@ int default_group() return IOP_GROUP_COLOR | IOP_GROUP_GRADING; } -dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, - dt_dev_pixelpipe_iop_t *piece) +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; } -// intended to run before filmic rgb; pipeline order entry is added -// separately in iop_order.c +static inline void reset_channel_curve(dt_iop_satcurve_channel_params_t *c) +{ + c->curve_num_nodes = 2; + c->curve_type = MONOTONE_HERMITE; + c->curve[0] = (dt_iop_satcurve_node_t){ 0.f, .5f }; + c->curve[1] = (dt_iop_satcurve_node_t){ 1.f, .5f }; +} -static inline void reset_curve(dt_iop_satcurve_params_t *p) +static inline void reset_params(dt_iop_satcurve_params_t *p) { - p->curve_num_nodes = 2; - p->curve_type = MONOTONE_HERMITE; + reset_channel_curve(&p->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); + reset_channel_curve(&p->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); p->formula = DT_IOP_SATCURVE_DTUCS; - p->curve[0] = (dt_iop_satcurve_node_t){0.f, .5f}; - p->curve[1] = (dt_iop_satcurve_node_t){1.f, .5f}; } static inline float lookup_lut(const float *lut, const float x) @@ -167,61 +198,58 @@ static inline float lookup_lut(const float *lut, const float x) return lut[i] + (position - i) * (lut[i + 1] - lut[i]); } -// periodic lookup in the hue-indexed gamut LUT; h = atan2(b, a), in [-pi, pi] +static inline float curve_to_factor(const float c) +{ + return MAX(2.f * c, 0.f); +} + static inline float satcurve_lookup_gamut(const float *const gamut_lut, const float h) { const float position = (h + M_PI_F) * (float)LUT_ELEM / DT_2PI_F; const int bin0 = ((int)floorf(position)) % LUT_ELEM; const int bin1 = (bin0 + 1) % LUT_ELEM; const float f = position - floorf(position); - return gamut_lut[bin0] * (1.f - f) + gamut_lut[bin1] * f; } -// smoothly map x from knee onwards towards maximum (reached asymptotically); -// x <= knee is left unchanged static inline float satcurve_soft_clip(const float x, const float knee, const float maximum) { - if(x <= knee) - return x; + if(x <= knee) return x; const float range = maximum - knee; - if(range <= 0.f) - return maximum; + if(range <= 0.f) return maximum; return knee + range * (1.f - expf(-(x - knee) / range)); } -// mirrors the JzAzBz chroma clip in color balance rgb (test conversion to -// L'M'S'): keeps the curve from producing colors that go negative on the -// JzAzBz -> XYZ back-transform static inline float clip_jz_chroma(const float Jz, const float Cz, const float ch, const float sh) { const float d0 = 1.6295499532821566e-11f; const float dd = -0.56f; float Iz = (Jz + d0) / (1.f + dd - dd * (Jz + d0)); Iz = MAX(Iz, 0.f); - static const dt_colormatrix_t AI_trans = {{1.f, 1.f, 1.f, 0.f}, - {.1386050432715393f, -.1386050432715393f, -.0960192420263190f, 0.f}, - {.0580473161561189f, -.0580473161561189f, -.8118918960560390f, 0.f}}; - dt_aligned_pixel_t izab = {Iz, Cz * ch, Cz * sh, 0.f}, lms; + + static const dt_colormatrix_t AI_trans = { + { 1.f, 1.f, 1.f, 0.f }, + { .1386050432715393f, -.1386050432715393f, -.0960192420263190f, 0.f }, + { .0580473161561189f, -.0580473161561189f, -.8118918960560390f, 0.f } + }; + + dt_aligned_pixel_t izab = { Iz, Cz * ch, Cz * sh, 0.f }, lms; dt_apply_transposed_color_matrix(izab, AI_trans, lms); + float max_c = Cz; - if(lms[0] < 0.f) - max_c = MIN(max_c, -Iz / (AI_trans[1][0] * ch + AI_trans[2][0] * sh)); - if(lms[1] < 0.f) - max_c = MIN(max_c, -Iz / (AI_trans[1][1] * ch + AI_trans[2][1] * sh)); - if(lms[2] < 0.f) - max_c = MIN(max_c, -Iz / (AI_trans[1][2] * ch + AI_trans[2][2] * sh)); + if(lms[0] < 0.f) max_c = MIN(max_c, -Iz / (AI_trans[1][0] * ch + AI_trans[2][0] * sh)); + if(lms[1] < 0.f) max_c = MIN(max_c, -Iz / (AI_trans[1][1] * ch + AI_trans[2][1] * sh)); + if(lms[2] < 0.f) max_c = MIN(max_c, -Iz / (AI_trans[1][2] * ch + AI_trans[2][2] * sh)); + return MAX(max_c, 0.f); } -// shared S_in_norm computation, used by process(), the histogram, and the -// picker, so all three use the exact same scene-referred math static inline float pixel_s_in_norm_jzazbz(const float *const restrict rgb_in, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict gamut_lut, - float *const restrict h_out) + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + float *const restrict h_out) { dt_aligned_pixel_t rgb, xyz, jab; copy_pixel(rgb, rgb_in); @@ -239,15 +267,34 @@ static inline float pixel_s_in_norm_jzazbz(const float *const restrict rgb_in, return s_in / gamut; } -static inline float pixel_s_in_norm_ucs( - const float *const restrict rgb_in, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict gamut_lut, - const float L_white, - float *const restrict h_out) +// maximum HSB saturation reachable at the gamut boundary for a given J, h in dt UCS; +// fit constants (15.93.../0.652.../0.600...) come from the gamut-boundary polynomial +// model used throughout darktable_ucs_22_helpers. Single source of truth for this +// computation -- do not re-derive it inline at call sites. +static inline float satcurve_ucs_gamut_saturation(const float J, const float h, + const float L_white, + const float *const restrict gamut_lut) +{ + const float max_colorfulness = MAX(satcurve_lookup_gamut(gamut_lut, h), FLT_MIN); + const float max_chroma = 15.932993652962535f + * powf(J / L_white, 0.6523997524738018f) + * powf(max_colorfulness, 0.6007557017508491f) + / L_white; + + const dt_aligned_pixel_t JCH_gamut_boundary = { J, max_chroma, h, 0.f }; + dt_aligned_pixel_t HSB_gamut_boundary; + dt_UCS_JCH_to_HSB(JCH_gamut_boundary, HSB_gamut_boundary); + + return MAX(HSB_gamut_boundary[1], FLT_MIN); +} + +static inline float pixel_s_in_norm_ucs(const float *const restrict rgb_in, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + const float L_white, + float *const restrict h_out) { dt_aligned_pixel_t rgb, xyz, xyY, JCH, HCB; - dt_aligned_pixel_t JCH_gamut_boundary, HSB_gamut_boundary; copy_pixel(rgb, rgb_in); dt_vector_clipneg(rgb); @@ -257,67 +304,80 @@ static inline float pixel_s_in_norm_ucs( xyY_to_dt_UCS_JCH(xyY, L_white, JCH); dt_UCS_JCH_to_HCB(JCH, HCB); - const float max_colorfulness - = MAX(satcurve_lookup_gamut(gamut_lut, JCH[2]), FLT_MIN); - - const float max_chroma - = 15.932993652962535f - * powf(JCH[0] / L_white, 0.6523997524738018f) - * powf(max_colorfulness, 0.6007557017508491f) - / L_white; - - JCH_gamut_boundary[0] = JCH[0]; - JCH_gamut_boundary[1] = max_chroma; - JCH_gamut_boundary[2] = JCH[2]; - JCH_gamut_boundary[3] = 0.f; - - dt_UCS_JCH_to_HSB(JCH_gamut_boundary, HSB_gamut_boundary); + const float gamut_s = satcurve_ucs_gamut_saturation(JCH[0], JCH[2], L_white, gamut_lut); if(h_out) *h_out = JCH[2]; const float saturation = HCB[2] > 0.f ? HCB[1] / HCB[2] : 0.f; - return saturation / MAX(HSB_gamut_boundary[1], FLT_MIN); + return saturation / gamut_s; } -static inline float pixel_s_in_norm( - const dt_iop_satcurve_data_t *const d, - const float *const restrict rgb_in, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict gamut_lut, - const float L_white, - float *const restrict h_out) +static inline float pixel_s_in_norm(const dt_iop_satcurve_formula_t formula, + const float *const restrict rgb_in, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + const float L_white, + float *const restrict h_out) { - if(d->formula == DT_IOP_SATCURVE_DTUCS) + if(formula == DT_IOP_SATCURVE_DTUCS) return pixel_s_in_norm_ucs(rgb_in, inputmatrix_trans, gamut_lut, L_white, h_out); return pixel_s_in_norm_jzazbz(rgb_in, inputmatrix_trans, gamut_lut, h_out); } -// updates the input-referred saturation histogram; GUI full-preview only, -// mirrors the display_mask gating used in colorzones.c +static inline dt_iop_satcurve_channel_params_t *get_active_channel_params(dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + dt_iop_satcurve_params_t *p = self->params; + return &p->channel[g->active_channel]; +} + +static inline dt_iop_satcurve_gui_channel_t *get_active_gui_channel(dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + return &g->channel[g->active_channel]; +} + +static inline gboolean channel_is_neutral(const dt_iop_satcurve_channel_data_t *c) +{ + return c->curve_num_nodes == 2 + && fabsf(c->lut[0] - .5f) < 1e-6f + && fabsf(c->lut[DT_IOP_SATCURVE_RES - 1] - .5f) < 1e-6f; +} + +static inline dt_iop_satcurve_factors_t eval_curve_factors(const dt_iop_satcurve_data_t *d, + const float s_in_norm) +{ + const float sat_c = CLAMP(lookup_lut(d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].lut, s_in_norm), 0.f, 1.f); + const float bri_c = CLAMP(lookup_lut(d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].lut, s_in_norm), 0.f, 1.f); + + return (dt_iop_satcurve_factors_t){ + .sat_factor = curve_to_factor(sat_c), + .bri_factor = curve_to_factor(bri_c) + }; +} + static void _update_sat_histogram(dt_iop_module_t *self, - const dt_iop_satcurve_data_t *d, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict in, - const size_t npixels) + const dt_iop_satcurve_data_t *d, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict in, + const size_t npixels) { dt_iop_satcurve_gui_data_t *g = self->gui_data; if(!g) return; float local_hist[DT_IOP_SATCURVE_HIST_RES] = { 0.f }; - const float L_white = Y_to_dt_UCS_L_star(1.f); // constant; compute once, not per pixel + const float L_white = Y_to_dt_UCS_L_star(1.f); DT_OMP_FOR(reduction(+ : local_hist[:DT_IOP_SATCURVE_HIST_RES])) for(size_t k = 0; k < npixels; k++) { - const float s_in_norm = pixel_s_in_norm(d, in + 4 * k, inputmatrix_trans, d->gamut_lut, L_white, NULL); + const float s_in_norm = pixel_s_in_norm(d->formula, in + 4 * k, inputmatrix_trans, d->gamut_lut, L_white, NULL); const int bin = CLAMP((int)(s_in_norm * (DT_IOP_SATCURVE_HIST_RES - 1)), - 0, DT_IOP_SATCURVE_HIST_RES - 1); + 0, DT_IOP_SATCURVE_HIST_RES - 1); local_hist[bin] += 1.f; } - // log1p scaling: S_in_norm is typically skewed heavily toward low values; - // the x-axis (S_in_norm) itself stays linear float max_val = 0.f; for(int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) { @@ -333,15 +393,94 @@ static void _update_sat_histogram(dt_iop_module_t *self, dt_control_queue_redraw_widget(GTK_WIDGET(g->area)); } +static inline void apply_sat_and_brilliance_ucs(const dt_iop_satcurve_data_t *d, + dt_aligned_pixel_t xyz, + const float L_white) +{ + dt_aligned_pixel_t xyY, JCH, HCB, HSB; + + dt_D65_XYZ_to_xyY(xyz, xyY); + xyY_to_dt_UCS_JCH(xyY, L_white, JCH); + dt_UCS_JCH_to_HCB(JCH, HCB); + + HSB[0] = HCB[0]; + HSB[1] = HCB[2] > 0.f ? HCB[1] / HCB[2] : 0.f; + HSB[2] = HCB[2]; + HSB[3] = 0.f; + + const float gamut_s = satcurve_ucs_gamut_saturation(JCH[0], JCH[2], L_white, d->gamut_lut); + const float s_in_norm = HSB[1] / gamut_s; + const dt_iop_satcurve_factors_t f = eval_curve_factors(d, s_in_norm); + + HSB[1] = MAX(HSB[1] * f.sat_factor, 0.f); + HSB[1] = satcurve_soft_clip(HSB[1], .8f * gamut_s, gamut_s); + + dt_UCS_HSB_to_JCH(HSB, JCH); + dt_UCS_JCH_to_HCB(JCH, HCB); + + HCB[1] = MAX(HCB[1] * f.bri_factor, 0.f); + HCB[2] = MAX(HCB[2] * f.bri_factor, 0.f); + + dt_UCS_HCB_to_JCH(HCB, JCH); + + const float gamut_s_out = satcurve_ucs_gamut_saturation(JCH[0], JCH[2], L_white, d->gamut_lut); + + dt_aligned_pixel_t HSB_out; + HSB_out[0] = HCB[0]; + HSB_out[1] = HCB[2] > 0.f ? HCB[1] / HCB[2] : 0.f; + HSB_out[2] = HCB[2]; + HSB_out[3] = 0.f; + + HSB_out[1] = satcurve_soft_clip(HSB_out[1], .8f * gamut_s_out, gamut_s_out); + + dt_UCS_HSB_to_JCH(HSB_out, JCH); + dt_UCS_JCH_to_xyY(JCH, L_white, xyY); + dt_xyY_to_XYZ(xyY, xyz); +} + +static inline void apply_sat_and_brilliance_jzazbz(const dt_iop_satcurve_data_t *d, + dt_aligned_pixel_t xyz) +{ + dt_aligned_pixel_t jab; + dt_XYZ_2_JzAzBz(xyz, jab); + + const float Jz = MAX(jab[0], 0.f); + const float Cz = dt_fast_hypotf(jab[1], jab[2]); + const float h = atan2f(jab[2], jab[1]); + const float ch = cosf(h), sh = sinf(h); + const float gamut = MAX(satcurve_lookup_gamut(d->gamut_lut, h), FLT_MIN); + + const float s_in_norm = (Jz > 0.f ? Cz / Jz : 0.f) / gamut; + const dt_iop_satcurve_factors_t f = eval_curve_factors(d, s_in_norm); + + const float s_out = satcurve_soft_clip(MAX(s_in_norm * f.sat_factor, 0.f), .8f, 1.f) * gamut; + + const float r = dt_fast_hypotf(Jz, Cz); + const float inv_norm = 1.f / sqrtf(1.f + s_out * s_out); + + float Jz_tmp = r * inv_norm; + float Cz_tmp = clip_jz_chroma(Jz_tmp, r * s_out * inv_norm, ch, sh); + + Jz_tmp *= f.bri_factor; + Cz_tmp = clip_jz_chroma(Jz_tmp, Cz_tmp * f.bri_factor, ch, sh); + + jab[0] = Jz_tmp; + jab[1] = Cz_tmp * ch; + jab[2] = Cz_tmp * sh; + + dt_JzAzBz_2_XYZ(jab, xyz); +} + void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *const ivoid, void *const ovoid, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) { dt_iop_satcurve_data_t *d = piece->data; - const gboolean neutral_curve = d->curve_num_nodes == 2 - && fabsf(d->lut[0] - .5f) < 1e-6f - && fabsf(d->lut[DT_IOP_SATCURVE_RES - 1] - .5f) < 1e-6f; - if(neutral_curve) + + const gboolean neutral_sat = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); + const gboolean neutral_bri = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); + + if(neutral_sat && neutral_bri) { dt_iop_image_copy_by_size(ovoid, ivoid, roi_out->width, roi_out->height, piece->colors); return; @@ -358,6 +497,7 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, dt_colormatrix_t outputmatrix = {{ 0.0f }}; dt_colormatrix_t inputmatrix_trans; dt_colormatrix_t outputmatrix_trans; + dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); dt_colormatrix_transpose(inputmatrix_trans, inputmatrix); dt_colormatrix_mul(outputmatrix, work_profile->matrix_out, XYZ_D65_to_D50_CAT16); @@ -371,7 +511,6 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, && piece->pipe == self->dev->full.pipe) _update_sat_histogram(self, d, inputmatrix_trans, in, npixels); - // constant regardless of pixel data; hoisted out of the per-pixel loop const float L_white = Y_to_dt_UCS_L_star(1.f); DT_OMP_FOR() @@ -383,66 +522,16 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, dt_apply_transposed_color_matrix(rgb, inputmatrix_trans, xyz); if(d->formula == DT_IOP_SATCURVE_DTUCS) - { - dt_aligned_pixel_t xyY, JCH, HCB, HSB, JCH_gamut_boundary, HSB_gamut_boundary; - dt_D65_XYZ_to_xyY(xyz, xyY); - xyY_to_dt_UCS_JCH(xyY, L_white, JCH); - dt_UCS_JCH_to_HCB(JCH, HCB); - - const float max_colorfulness = MAX(satcurve_lookup_gamut(d->gamut_lut, JCH[2]), FLT_MIN); - const float max_chroma = 15.932993652962535f - * powf(JCH[0] / L_white, 0.6523997524738018f) - * powf(max_colorfulness, 0.6007557017508491f) - / L_white; - JCH_gamut_boundary[0] = JCH[0]; - JCH_gamut_boundary[1] = max_chroma; - JCH_gamut_boundary[2] = JCH[2]; - JCH_gamut_boundary[3] = 0.f; - dt_UCS_JCH_to_HSB(JCH_gamut_boundary, HSB_gamut_boundary); - - HSB[0] = HCB[0]; - HSB[1] = HCB[2] > 0.f ? HCB[1] / HCB[2] : 0.f; - HSB[2] = HCB[2]; - HSB[3] = 0.f; - - const float gamut_s = MAX(HSB_gamut_boundary[1], FLT_MIN); - const float s_in_norm = HSB[1] / gamut_s; - const float c = CLAMP(lookup_lut(d->lut, s_in_norm), 0.f, 1.f); - HSB[1] = MAX(HSB[1] * (2.f * c), 0.f); - HSB[1] = satcurve_soft_clip(HSB[1], .8f * gamut_s, gamut_s); - - dt_UCS_HSB_to_JCH(HSB, JCH); - dt_UCS_JCH_to_xyY(JCH, L_white, xyY); - dt_xyY_to_XYZ(xyY, xyz); - } + apply_sat_and_brilliance_ucs(d, xyz, L_white); else - { - dt_aligned_pixel_t jab; - dt_XYZ_2_JzAzBz(xyz, jab); - const float Jz = MAX(jab[0], 0.f); - const float Cz = dt_fast_hypotf(jab[1], jab[2]); - const float h = atan2f(jab[2], jab[1]); - const float ch = cosf(h), sh = sinf(h); - const float gamut = MAX(satcurve_lookup_gamut(d->gamut_lut, h), FLT_MIN); - const float s_in_norm = (Jz > 0.f ? Cz / Jz : 0.f) / gamut; - const float c = CLAMP(lookup_lut(d->lut, s_in_norm), 0.f, 1.f); - const float s_out = satcurve_soft_clip(MAX(s_in_norm * (2.f * c), 0.f), .8f, 1.f) * gamut; - const float r = dt_fast_hypotf(Jz, Cz); - // T = atanf(s_out); cosf(T) = 1/sqrt(1+s_out^2), sinf(T) = s_out/sqrt(1+s_out^2) - // avoids one atanf + one cosf + one sinf per pixel - const float inv_norm = 1.f / sqrtf(1.f + s_out * s_out); - const float Jz_out = r * inv_norm; - const float Cz_out = clip_jz_chroma(Jz_out, r * s_out * inv_norm, ch, sh); - jab[0] = Jz_out; - jab[1] = Cz_out * ch; - jab[2] = Cz_out * sh; - dt_JzAzBz_2_XYZ(jab, xyz); - } + apply_sat_and_brilliance_jzazbz(d, xyz); dt_apply_transposed_color_matrix(xyz, outputmatrix_trans, pixout); dt_vector_clipneg(pixout); + pixout[3] = in[4 * k + 3]; copy_pixel_nontemporal(out + 4 * k, pixout); } + dt_omploop_sfence(); } @@ -457,24 +546,22 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_int err = DT_OPENCL_DEFAULT_ERROR; if(piece->colors != 4) return err; + const gboolean neutral_sat = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); + const gboolean neutral_bri = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); + if(neutral_sat && neutral_bri) + return dt_opencl_enqueue_copy_image(piece->pipe->devid, dev_in, dev_out, + (size_t[]){ 0, 0, 0 }, + (size_t[]){ 0, 0, 0 }, + (size_t[]){ roi_in->width, roi_in->height, 1 }); + + const dt_iop_order_iccprofile_info_t *const work_profile = + dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); + if(work_profile == NULL) return err; + const int devid = piece->pipe->devid; const int width = roi_in->width; const int height = roi_in->height; - const gboolean neutral_curve = d->curve_num_nodes == 2 - && fabsf(d->lut[0] - .5f) < 1e-6f - && fabsf(d->lut[DT_IOP_SATCURVE_RES - 1] - .5f) < 1e-6f; - if(neutral_curve) - return dt_opencl_enqueue_copy_image(devid, dev_in, dev_out, (size_t[]){0,0,0}, - (size_t[]){0,0,0}, (size_t[]){width, height, 1}); - - const dt_iop_order_iccprofile_info_t *const work_profile - = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); - if(work_profile == NULL) return err; - - // Histogram needs a host-side copy of the input buffer; only do this - // when the GUI actually needs it, to avoid a needless GPU->CPU sync - // on every full-pipe run. if(self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) && piece->pipe == self->dev->full.pipe) { @@ -492,41 +579,51 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, dt_free_align(host_in); } } - - cl_mem lut_cl = NULL; - cl_mem gamut_lut_cl = NULL; + cl_mem input_matrix_cl = NULL; cl_mem output_matrix_cl = NULL; + cl_mem sat_lut_cl = NULL; + cl_mem bri_lut_cl = NULL; + cl_mem gamut_lut_cl = NULL; - dt_colormatrix_t input_matrix; - dt_colormatrix_t output_matrix; + dt_colormatrix_t input_matrix = {{ 0.0f }}; + dt_colormatrix_t output_matrix = {{ 0.0f }}; 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); - input_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), input_matrix); + input_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), input_matrix); output_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), output_matrix); - lut_cl = dt_opencl_copy_host_to_device_constant(devid, DT_IOP_SATCURVE_RES * sizeof(float), d->lut); - gamut_lut_cl = dt_opencl_copy_host_to_device_constant(devid, LUT_ELEM * sizeof(float), d->gamut_lut); - - if(input_matrix_cl == NULL || output_matrix_cl == NULL || lut_cl == NULL || gamut_lut_cl == NULL) + sat_lut_cl = dt_opencl_copy_host_to_device_constant( + devid, DT_IOP_SATCURVE_RES * sizeof(float), + d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].lut); + bri_lut_cl = dt_opencl_copy_host_to_device_constant( + devid, DT_IOP_SATCURVE_RES * sizeof(float), + d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].lut); + gamut_lut_cl = dt_opencl_copy_host_to_device_constant( + devid, LUT_ELEM * sizeof(float), d->gamut_lut); + + if(input_matrix_cl == NULL || output_matrix_cl == NULL + || sat_lut_cl == NULL || bri_lut_cl == NULL || gamut_lut_cl == NULL) { err = CL_MEM_OBJECT_ALLOCATION_FAILURE; goto error; } - // constant regardless of pixel data; compute once on the host instead of - // once per GPU thread inside the kernel const float L_white = Y_to_dt_UCS_L_star(1.f); - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_satcurvergb, width, height, - CLARG(dev_in), CLARG(dev_out), CLARG(width), CLARG(height), + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_satcurvergb, width, height, + CLARG(dev_in), CLARG(dev_out), + CLARG(width), CLARG(height), CLARG(input_matrix_cl), CLARG(output_matrix_cl), - CLARG(lut_cl), CLARG(gamut_lut_cl), CLARG(d->formula), CLARG(L_white)); + CLARG(sat_lut_cl), CLARG(bri_lut_cl), CLARG(gamut_lut_cl), + CLARG(d->formula), CLARG(L_white)); error: dt_opencl_release_mem_object(input_matrix_cl); dt_opencl_release_mem_object(output_matrix_cl); - dt_opencl_release_mem_object(lut_cl); + dt_opencl_release_mem_object(sat_lut_cl); + dt_opencl_release_mem_object(bri_lut_cl); dt_opencl_release_mem_object(gamut_lut_cl); return err; } @@ -548,35 +645,49 @@ void cleanup_global(dt_iop_module_so_t *self) } #endif - void init_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece) { dt_iop_satcurve_data_t *d = dt_calloc_align_type(dt_iop_satcurve_data_t, 1); - d->lut = dt_alloc_align_float(DT_IOP_SATCURVE_RES); + + for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + { + d->channel[ch].lut = dt_alloc_align_float(DT_IOP_SATCURVE_RES); + d->channel[ch].curve = dt_draw_curve_new(0.f, 1.f, MONOTONE_HERMITE); + d->channel[ch].curve_type = MONOTONE_HERMITE; + d->channel[ch].curve_num_nodes = 0; + } + d->gamut_lut = dt_alloc_align_float(LUT_ELEM); - d->curve = dt_draw_curve_new(0.f, 1.f, MONOTONE_HERMITE); d->lut_inited = FALSE; + d->work_profile = NULL; piece->data = d; } void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece) { dt_iop_satcurve_data_t *d = piece->data; - dt_draw_curve_destroy(d->curve); - dt_free_align(d->lut); + if(!d) return; + + for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + { + if(d->channel[ch].curve) dt_draw_curve_destroy(d->channel[ch].curve); + dt_free_align(d->channel[ch].lut); + } + dt_free_align(d->gamut_lut); dt_free_align(d); piece->data = NULL; } -// RGB/D50 -> XYZ/D65, the input expected by dt_XYZ_2_JzAzBz() -static void build_jzazbz_gamut_lut(dt_iop_satcurve_data_t *d, const dt_iop_order_iccprofile_info_t *work_profile) +static void build_jzazbz_gamut_lut(dt_iop_satcurve_data_t *d, + const dt_iop_order_iccprofile_info_t *work_profile) { - dt_colormatrix_t inputmatrix = { {0.0f} }; - + dt_colormatrix_t inputmatrix = {{ 0.0f }}; dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); + dt_colormatrix_t inputmatrix_trans; dt_colormatrix_transpose(inputmatrix_trans, inputmatrix); + float *sampler = dt_calloc_align_float(LUT_ELEM); DT_OMP_FOR(reduction(max : sampler[:LUT_ELEM]) collapse(3)) @@ -584,24 +695,32 @@ static void build_jzazbz_gamut_lut(dt_iop_satcurve_data_t *d, const dt_iop_order for(int g = 0; g < DT_IOP_SATCURVE_GAMUT_STEPS; g++) for(int b = 0; b < DT_IOP_SATCURVE_GAMUT_STEPS; b++) { - const dt_aligned_pixel_t rgb = {r / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), - g / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), - b / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), 0.f}; + const dt_aligned_pixel_t rgb = { + r / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), + g / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), + b / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), + 0.f + }; + dt_aligned_pixel_t xyz, jab; dt_apply_transposed_color_matrix(rgb, inputmatrix_trans, xyz); dt_XYZ_2_JzAzBz(xyz, jab); - const float saturation = jab[0] > 0.f ? dt_fast_hypotf(jab[1], jab[2]) / jab[0] : 0.f; + + const float sat = jab[0] > 0.f ? dt_fast_hypotf(jab[1], jab[2]) / jab[0] : 0.f; int index = roundf((LUT_ELEM - 1) * (atan2f(jab[2], jab[1]) + M_PI_F) / DT_2PI_F); index = index < 0 ? LUT_ELEM - 1 : (index >= LUT_ELEM ? 0 : index); - sampler[index] = MAX(sampler[index], saturation); + sampler[index] = MAX(sampler[index], sat); } for(size_t i = 2; i < LUT_ELEM - 2; i++) d->gamut_lut[i] = (sampler[i - 2] + sampler[i - 1] + sampler[i] + sampler[i + 1] + sampler[i + 2]) / 5.f; d->gamut_lut[0] = (sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0] + sampler[1] + sampler[2]) / 5.f; d->gamut_lut[1] = (sampler[LUT_ELEM - 1] + sampler[0] + sampler[1] + sampler[2] + sampler[3]) / 5.f; - d->gamut_lut[LUT_ELEM - 2] = (sampler[LUT_ELEM - 4] + sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0]) / 5.f; - d->gamut_lut[LUT_ELEM - 1] = (sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0] + sampler[1]) / 5.f; + d->gamut_lut[LUT_ELEM - 2] = (sampler[LUT_ELEM - 4] + sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + + sampler[LUT_ELEM - 1] + sampler[0]) / 5.f; + d->gamut_lut[LUT_ELEM - 1] = (sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + + sampler[0] + sampler[1]) / 5.f; + dt_free_align(sampler); } @@ -615,34 +734,51 @@ static void build_gamut_lut(dt_iop_satcurve_data_t *d, dt_UCS_22_build_gamut_LUT(inputmatrix, d->gamut_lut); } else + { build_jzazbz_gamut_lut(d, work_profile); + } +} + +static void sync_channel_curve(dt_iop_satcurve_channel_data_t *dst, + const dt_iop_satcurve_channel_params_t *src) +{ + if(dst->curve_type != src->curve_type || dst->curve_num_nodes != src->curve_num_nodes) + { + if(dst->curve) dt_draw_curve_destroy(dst->curve); + dst->curve = dt_draw_curve_new(0.f, 1.f, src->curve_type); + dst->curve_type = src->curve_type; + dst->curve_num_nodes = src->curve_num_nodes; + + for(int i = 0; i < src->curve_num_nodes; i++) + dt_draw_curve_add_point(dst->curve, src->curve[i].x, src->curve[i].y); + } + else + { + for(int i = 0; i < src->curve_num_nodes; i++) + dt_draw_curve_set_point(dst->curve, i, src->curve[i].x, src->curve[i].y); + } + + dt_draw_curve_calc_values(dst->curve, 0.f, 1.f, DT_IOP_SATCURVE_RES, NULL, dst->lut); } void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_t *pipe, - dt_dev_pixelpipe_iop_t *piece) + dt_dev_pixelpipe_iop_t *piece) { dt_iop_satcurve_data_t *d = piece->data; const dt_iop_satcurve_params_t *p = (const dt_iop_satcurve_params_t *)p1; + if(d->formula != p->formula) { d->formula = p->formula; d->lut_inited = FALSE; } - if(d->curve_type != p->curve_type || d->curve_num_nodes != p->curve_num_nodes) - { - dt_draw_curve_destroy(d->curve); - d->curve = dt_draw_curve_new(0.f, 1.f, p->curve_type); - d->curve_type = p->curve_type; - d->curve_num_nodes = p->curve_num_nodes; - for(int i = 0; i < p->curve_num_nodes; i++) - dt_draw_curve_add_point(d->curve, p->curve[i].x, p->curve[i].y); - } - else - for(int i = 0; i < p->curve_num_nodes; i++) - dt_draw_curve_set_point(d->curve, i, p->curve[i].x, p->curve[i].y); - dt_draw_curve_calc_values(d->curve, 0.f, 1.f, DT_IOP_SATCURVE_RES, NULL, d->lut); - const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); + for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + sync_channel_curve(&d->channel[ch], &p->channel[ch]); + + const dt_iop_order_iccprofile_info_t *work_profile = + dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); + if(work_profile && (!d->lut_inited || work_profile != d->work_profile)) { build_gamut_lut(d, work_profile); @@ -651,71 +787,121 @@ void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_ } } +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_satcurve_params_v1_t *o = old_params; + dt_iop_satcurve_params_t *n = calloc(1, sizeof(dt_iop_satcurve_params_t)); + + reset_params(n); + + n->formula = o->formula; + n->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve_num_nodes = o->curve_num_nodes; + n->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve_type = o->curve_type; + + for(int i = 0; i < o->curve_num_nodes; i++) + n->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[i] = o->curve[i]; + + *new_params = n; + *new_params_size = sizeof(dt_iop_satcurve_params_t); + *new_version = 2; + return 0; + } + + return 1; +} + void init_presets(dt_iop_module_so_t *self) { - dt_iop_satcurve_params_t p = {0}; - reset_curve(&p); - dt_gui_presets_add_generic(_("neutral"), self->op, self->version(), &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); - - p.curve[0].y = .60f; - p.curve[1].y = .55f; - dt_gui_presets_add_generic(_("gentle saturation"), self->op, self->version(), &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); - - reset_curve(&p); - p.curve_num_nodes = 3; - p.curve[1] = (dt_iop_satcurve_node_t){.45f, .62f}; - p.curve[2] = (dt_iop_satcurve_node_t){1.f, .42f}; - dt_gui_presets_add_generic(_("protect saturated colors"), self->op, self->version(), &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); - - reset_curve(&p); - p.curve_num_nodes = 3; - p.curve[0].y = .38f; - p.curve[1] = (dt_iop_satcurve_node_t){.35f, .48f}; - p.curve[2] = (dt_iop_satcurve_node_t){1.f, .58f}; - dt_gui_presets_add_generic(_("boost saturated colors"), self->op, self->version(), &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + dt_iop_satcurve_params_t p = { 0 }; + + reset_params(&p); + dt_gui_presets_add_generic(_("neutral"), self->op, self->version(), + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + + reset_params(&p); + p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[0].y = .60f; + p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[1].y = .55f; + dt_gui_presets_add_generic(_("gentle saturation"), self->op, self->version(), + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + + reset_params(&p); + p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve_num_nodes = 3; + p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[1] = (dt_iop_satcurve_node_t){ .45f, .62f }; + p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[2] = (dt_iop_satcurve_node_t){ 1.f, .42f }; + dt_gui_presets_add_generic(_("protect saturated colors"), self->op, self->version(), + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + + reset_params(&p); + p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[0].y = .56f; + p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[1].y = .54f; + dt_gui_presets_add_generic(_("gentle brilliance"), self->op, self->version(), + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + + reset_params(&p); + p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve_num_nodes = 3; + p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[1] = (dt_iop_satcurve_node_t){ .40f, .58f }; + p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[2] = (dt_iop_satcurve_node_t){ 1.f, .48f }; + dt_gui_presets_add_generic(_("bright mids, protect extremes"), self->op, self->version(), + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); } -static inline int add_node(dt_iop_satcurve_params_t *p, const float x, const float y) +static inline int add_node_to_channel(dt_iop_satcurve_channel_params_t *c, const float x, const float y) { + if(c->curve_num_nodes >= DT_IOP_SATCURVE_MAXNODES) return -1; + int at = 0; - while(at < p->curve_num_nodes && p->curve[at].x < x) - at++; - if((at && x - p->curve[at - 1].x < DT_IOP_SATCURVE_MIN_X_DISTANCE) || - (at < p->curve_num_nodes && p->curve[at].x - x < DT_IOP_SATCURVE_MIN_X_DISTANCE)) + while(at < c->curve_num_nodes && c->curve[at].x < x) at++; + + if((at && x - c->curve[at - 1].x < DT_IOP_SATCURVE_MIN_X_DISTANCE) + || (at < c->curve_num_nodes && c->curve[at].x - x < DT_IOP_SATCURVE_MIN_X_DISTANCE)) return -1; - for(int i = p->curve_num_nodes; i > at; i--) - p->curve[i] = p->curve[i - 1]; - p->curve[at] = (dt_iop_satcurve_node_t){x, y}; - p->curve_num_nodes++; + + for(int i = c->curve_num_nodes; i > at; i--) c->curve[i] = c->curve[i - 1]; + c->curve[at] = (dt_iop_satcurve_node_t){ x, y }; + c->curve_num_nodes++; return at; } -// paints the input-referred saturation histogram as a filled area behind -// the curve; similar to the histogram overlay in colorzones.c, but backed -// by our own float accumulator instead of the Lab-based histogram +static void _get_graph_geometry(const GtkAllocation *a, float *x0, float *y0, float *w, float *h) +{ + *x0 = DT_IOP_SATCURVE_INSET; + *y0 = DT_IOP_SATCURVE_INSET; + *w = MAX(1, a->width - 2 * DT_IOP_SATCURVE_INSET); + *h = MAX(1, a->height - 2 * DT_IOP_SATCURVE_INSET - DT_IOP_SATCURVE_GRADIENT_GAP + - DT_IOP_SATCURVE_GRADIENT_SIZE); +} + static void _draw_sat_histogram(cairo_t *cr, dt_iop_satcurve_gui_data_t *g, const int w, const int h) { dt_pthread_mutex_lock(&g->histogram_lock); + const float hist_max = g->histogram_max; cairo_save(cr); - cairo_set_source_rgba(cr, .8, .8, .8, .35); + cairo_set_source_rgba(cr, .8, .8, .8, .30); cairo_move_to(cr, 0, h); + for(int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) { const float x = w * i / (float)(DT_IOP_SATCURVE_HIST_RES - 1); const float y = h * (1.f - g->histogram[i] / hist_max); cairo_line_to(cr, x, y); } + cairo_line_to(cr, w, h); cairo_close_path(cr); cairo_fill(cr); cairo_restore(cr); + dt_pthread_mutex_unlock(&g->histogram_lock); } -// paints the picker overlay (mean/min/max S_in_norm of the picked region), -// analogous to colorequal.c / colorzones.c but using our own S_in_norm -// metric instead of Lab/LCh or dt UCS HSB static void _draw_sat_picker(cairo_t *cr, dt_iop_module_t *self, const int w, const int h) { dt_iop_satcurve_gui_data_t *g = self->gui_data; @@ -727,6 +913,7 @@ static void _draw_sat_picker(cairo_t *cr, dt_iop_module_t *self, const int w, co const float x_max = CLAMP(g->picked_s_max, 0.f, 1.f) * w; cairo_save(cr); + cairo_set_source_rgba(cr, 0.5, 0.7, 0.5, 0.25); cairo_rectangle(cr, x_min, 0, fmaxf(x_max - x_min, 0.f), h); cairo_fill(cr); @@ -736,169 +923,228 @@ static void _draw_sat_picker(cairo_t *cr, dt_iop_module_t *self, const int w, co cairo_move_to(cr, x_mean, 0); cairo_line_to(cr, x_mean, h); cairo_stroke(cr); + cairo_restore(cr); } -// shared geometry for the curve graph itself: origin is offset from the -// widget's top-left by the inset; only the bottom reserves extra space -// for the decorative gradient bar, so area_draw and the pointer -// hit-testing functions always agree on where (0,0)-(1,1) map to -static inline void _get_graph_geometry(const GtkAllocation *a, float *x0, float *y0, float *w, float *h) -{ - const float inset = DT_IOP_SATCURVE_INSET; - const float reserved = DT_IOP_SATCURVE_GRADIENT_SIZE + DT_IOP_SATCURVE_GRADIENT_GAP; - *x0 = inset; - *y0 = inset; - *w = a->width - 2.f * inset; - *h = a->height - 2.f * inset - reserved; +static void _draw_channel_curve(cairo_t *cr, + const dt_iop_satcurve_channel_params_t *cp, + const dt_iop_satcurve_gui_channel_t *gc, + const int selected, + const gboolean active, + const int w, const int h, + const double r, const double g, const double b) +{ + cairo_save(cr); + + cairo_set_source_rgba(cr, r, g, b, active ? .95 : .50); + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(active ? 2.0 : 1.2)); + cairo_move_to(cr, 0, h * (1.f - gc->draw_ys[0])); + for(int i = 1; i < DT_IOP_SATCURVE_RES; i++) + { + const float x = w * i / (float)(DT_IOP_SATCURVE_RES - 1); + const float y = h * (1.f - gc->draw_ys[i]); + cairo_line_to(cr, x, y); + } + cairo_stroke(cr); + + if(active) + { + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.0)); + for(int i = 0; i < cp->curve_num_nodes; i++) + { + const float px = w * cp->curve[i].x; + const float py = h * (1.f - cp->curve[i].y); + cairo_arc(cr, px, py, DT_PIXEL_APPLY_DPI(i == selected ? 5 : 3), 0, DT_2PI_F); + cairo_stroke(cr); + } + } + + cairo_restore(cr); +} + +static void _sync_gui_curve(dt_iop_satcurve_gui_channel_t *gc, + const dt_iop_satcurve_channel_params_t *cp) +{ + if(gc->curve_type != cp->curve_type || gc->curve_num_nodes != cp->curve_num_nodes) + { + if(gc->curve) dt_draw_curve_destroy(gc->curve); + gc->curve = dt_draw_curve_new(0.f, 1.f, cp->curve_type); + gc->curve_type = cp->curve_type; + gc->curve_num_nodes = cp->curve_num_nodes; + + for(int i = 0; i < cp->curve_num_nodes; i++) + dt_draw_curve_add_point(gc->curve, cp->curve[i].x, cp->curve[i].y); + } + else + { + for(int i = 0; i < cp->curve_num_nodes; i++) + dt_draw_curve_set_point(gc->curve, i, cp->curve[i].x, cp->curve[i].y); + } + + dt_draw_curve_calc_values(gc->curve, 0.f, 1.f, DT_IOP_SATCURVE_RES, NULL, gc->draw_ys); } static gboolean area_draw(GtkWidget *widget, cairo_t *cr, dt_iop_module_t *self) { dt_iop_satcurve_gui_data_t *g = self->gui_data; dt_iop_satcurve_params_t *p = self->params; + GtkAllocation a; gtk_widget_get_allocation(widget, &a); + float gx0, gy0, gw, gh; _get_graph_geometry(&a, &gx0, &gy0, &gw, &gh); - const int w = (int)gw, h = (int)gh; - if(g->curve_type != p->curve_type || g->curve_num_nodes != p->curve_num_nodes) - { - dt_draw_curve_destroy(g->curve); - g->curve = dt_draw_curve_new(0.f, 1.f, p->curve_type); - g->curve_type = p->curve_type; - g->curve_num_nodes = p->curve_num_nodes; - for(int i = 0; i < p->curve_num_nodes; i++) - dt_draw_curve_add_point(g->curve, p->curve[i].x, p->curve[i].y); - } - else - for(int i = 0; i < p->curve_num_nodes; i++) - dt_draw_curve_set_point(g->curve, i, p->curve[i].x, p->curve[i].y); - dt_draw_curve_calc_values(g->curve, 0.f, 1.f, DT_IOP_SATCURVE_RES, NULL, g->draw_ys); - - // background first: cairo_paint() fills the whole widget regardless of - // any later cairo_translate(), so it must happen before the gradient - // bars are drawn, or it would overpaint them - cairo_set_source_rgb(cr, .18, .18, .18); + + cairo_set_source_rgb(cr, .15, .15, .15); cairo_paint(cr); - // purely decorative perceptual gradient bar, below the graph; same - // cairo_pattern_create_linear + dt_cairo_perceptual_gradient combo as - // used in toneequal.c, just without any axis legend - cairo_pattern_t *grad; + cairo_save(cr); + cairo_translate(cr, gx0, gy0); - // horizontal bar, below the graph - grad = cairo_pattern_create_linear(gx0, 0.0, gx0 + gw, 0.0); - dt_cairo_perceptual_gradient(grad, 1.0); - cairo_set_line_width(cr, 0.0); - cairo_rectangle(cr, gx0, gy0 + gh + DT_IOP_SATCURVE_GRADIENT_GAP, - gw, DT_IOP_SATCURVE_GRADIENT_SIZE); - cairo_set_source(cr, grad); - cairo_fill(cr); - cairo_pattern_destroy(grad); + for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + _sync_gui_curve(&g->channel[ch], &p->channel[ch]); - cairo_translate(cr, gx0, gy0); - cairo_set_source_rgba(cr, .4, .4, .4, .5); - cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1)); - for(int i = 1; i < 4; i++) + _draw_sat_histogram(cr, g, (int)gw, (int)gh); + _draw_sat_picker(cr, self, (int)gw, (int)gh); + + cairo_set_source_rgba(cr, 1, 1, 1, .08); + cairo_set_line_width(cr, 1.0); + for(int i = 0; i <= 4; i++) { - cairo_move_to(cr, w * i / 4.f, 0); - cairo_line_to(cr, w * i / 4.f, h); - cairo_move_to(cr, 0, h * i / 4.f); - cairo_line_to(cr, w, h * i / 4.f); + const float y = gh * i / 4.f; + cairo_move_to(cr, 0, y); + cairo_line_to(cr, gw, y); + } + for(int i = 0; i <= 4; i++) + { + const float x = gw * i / 4.f; + cairo_move_to(cr, x, 0); + cairo_line_to(cr, x, gh); } cairo_stroke(cr); - // input histogram first, so it stays in the background - _draw_sat_histogram(cr, g, w, h); - _draw_sat_picker(cr, self, w, h); + _draw_channel_curve(cr, + &p->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], + &g->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], + g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION ? g->selected : -1, + g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION, + (int)gw, (int)gh, + .90, .90, .90); + + _draw_channel_curve(cr, + &p->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], + &g->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], + g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? g->selected : -1, + g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE, + (int)gw, (int)gh, + 1.00, .65, .20); + + cairo_restore(cr); + + cairo_pattern_t *grad = cairo_pattern_create_linear(gx0, 0.0, gx0 + gw, 0.0); + dt_cairo_perceptual_gradient(grad, 1.0); + cairo_rectangle(cr, gx0, gy0 + gh + DT_IOP_SATCURVE_GRADIENT_GAP, gw, DT_IOP_SATCURVE_GRADIENT_SIZE); + cairo_set_source(cr, grad); + cairo_fill(cr); + cairo_pattern_destroy(grad); - cairo_set_source_rgb(cr, .85, .85, .85); - cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(2)); - cairo_move_to(cr, 0, h * (1.f - g->draw_ys[0])); - for(int i = 1; i < DT_IOP_SATCURVE_RES; i++) - cairo_line_to(cr, w * i / (float)(DT_IOP_SATCURVE_RES - 1), h * (1.f - g->draw_ys[i])); - cairo_stroke(cr); - cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1)); - for(int i = 0; i < p->curve_num_nodes; i++) - { - cairo_arc(cr, w * p->curve[i].x, h * (1.f - p->curve[i].y), DT_PIXEL_APPLY_DPI(i == g->selected ? 5 : 3), 0, DT_2PI_F); - cairo_stroke(cr); - } return FALSE; } static gboolean area_button(GtkWidget *widget, GdkEventButton *event, dt_iop_module_t *self) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - dt_iop_satcurve_params_t *p = self->params; + dt_iop_satcurve_channel_params_t *cp = get_active_channel_params(self); + dt_iop_satcurve_gui_channel_t *gc = get_active_gui_channel(self); + GtkAllocation a; gtk_widget_get_allocation(widget, &a); + float gx0, gy0, w, h; _get_graph_geometry(&a, &gx0, &gy0, &w, &h); - const float x = CLAMP((event->x - gx0) / w, 0.f, 1.f), y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); + + const float x = CLAMP((event->x - gx0) / w, 0.f, 1.f); + const float y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); + int hit = -1; - for(int i = 0; i < p->curve_num_nodes; i++) - if(sqf(x - p->curve[i].x) + sqf(y - p->curve[i].y) < .0025f) + for(int i = 0; i < cp->curve_num_nodes; i++) + { + const float dx = x - cp->curve[i].x; + const float dy = y - cp->curve[i].y; + if(dx * dx + dy * dy < .0025f) { hit = i; break; } + } if(event->type == GDK_2BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) { - reset_curve(p); + reset_channel_curve(cp); g->selected = -1; dt_dev_add_history_item(darktable.develop, self, TRUE); gtk_widget_queue_draw(widget); return TRUE; } + if(event->button == GDK_BUTTON_SECONDARY && hit >= 0) { - if((event->state & GDK_CONTROL_MASK) || p->curve_num_nodes <= 2) - p->curve[hit].y = .5f; + if((event->state & GDK_CONTROL_MASK) || cp->curve_num_nodes <= 2) + { + cp->curve[hit].y = .5f; + } else { - for(int i = hit; i < p->curve_num_nodes - 1; i++) - p->curve[i] = p->curve[i + 1]; - p->curve_num_nodes--; + for(int i = hit; i < cp->curve_num_nodes - 1; i++) cp->curve[i] = cp->curve[i + 1]; + cp->curve_num_nodes--; } + g->selected = -1; dt_dev_add_history_item(darktable.develop, self, TRUE); gtk_widget_queue_draw(widget); return TRUE; } + if(event->button == GDK_BUTTON_PRIMARY) { - // clicking/dragging outside an existing node inserts a new one on the curve - if(hit < 0 && p->curve_num_nodes < DT_IOP_SATCURVE_MAXNODES) - hit = add_node(p, x, CLAMP(dt_draw_curve_calc_value(g->curve, x), 0.f, 1.f)); + if(hit < 0 && cp->curve_num_nodes < DT_IOP_SATCURVE_MAXNODES) + hit = add_node_to_channel(cp, x, CLAMP(dt_draw_curve_calc_value(gc->curve, x), 0.f, 1.f)); + g->selected = hit; g->dragging = hit >= 0; return TRUE; } + return FALSE; } static gboolean area_motion(GtkWidget *widget, GdkEventMotion *event, dt_iop_module_t *self) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - dt_iop_satcurve_params_t *p = self->params; - if(!g->dragging || g->selected < 0) - return FALSE; + dt_iop_satcurve_channel_params_t *cp = get_active_channel_params(self); + + if(!g->dragging || g->selected < 0) return FALSE; + GtkAllocation a; gtk_widget_get_allocation(widget, &a); + float gx0, gy0, w, h; _get_graph_geometry(&a, &gx0, &gy0, &w, &h); + const float x = CLAMP((event->x - gx0) / w, 0.f, 1.f); const int n = g->selected; + if(n == 0) - p->curve[n].x = 0.f; - else if(n == p->curve_num_nodes - 1) - p->curve[n].x = 1.f; + cp->curve[n].x = 0.f; + else if(n == cp->curve_num_nodes - 1) + cp->curve[n].x = 1.f; else - p->curve[n].x = CLAMP(x, p->curve[n - 1].x + DT_IOP_SATCURVE_MIN_X_DISTANCE, p->curve[n + 1].x - DT_IOP_SATCURVE_MIN_X_DISTANCE); - p->curve[n].y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); + cp->curve[n].x = CLAMP(x, + cp->curve[n - 1].x + DT_IOP_SATCURVE_MIN_X_DISTANCE, + cp->curve[n + 1].x - DT_IOP_SATCURVE_MIN_X_DISTANCE); + + cp->curve[n].y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); gtk_widget_queue_draw(widget); return TRUE; } @@ -914,21 +1160,20 @@ static gboolean area_release(GtkWidget *widget, GdkEventButton *event, dt_iop_mo return TRUE; } -// returns the module's committed gamut_lut if already available (module -// processed at least once); otherwise falls back to a neutral unity LUT -// (gamut = 1 for all hues) so the picker cannot crash right after loading, -// though it then shows unnormalized S values static const float *_get_gamut_lut_for_picker(dt_iop_module_t *self) { static float unity_gamut_lut[LUT_ELEM]; static gboolean unity_inited = FALSE; + if(!unity_inited) { for(int i = 0; i < LUT_ELEM; i++) unity_gamut_lut[i] = 1.f; unity_inited = TRUE; } - for(GList *nodes = self->dev->full.pipe ? self->dev->full.pipe->nodes : NULL; nodes; nodes = g_list_next(nodes)) + for(GList *nodes = self->dev->full.pipe ? self->dev->full.pipe->nodes : NULL; + nodes; + nodes = g_list_next(nodes)) { dt_dev_pixelpipe_iop_t *piece = nodes->data; if(piece->module == self && piece->data) @@ -937,20 +1182,17 @@ static const float *_get_gamut_lut_for_picker(dt_iop_module_t *self) if(d->lut_inited && d->gamut_lut) return d->gamut_lut; } } + return unity_gamut_lut; } -// called automatically after a color pick while g->colorpicker is active. -// self->picked_color/-min/-max are already in pipeline RGB (default_colorspace -// is IOP_CS_RGB), so they go straight through pixel_s_in_norm() using the -// same metric as the curve and histogram void color_picker_apply(dt_iop_module_t *self, GtkWidget *widget, dt_dev_pixelpipe_t *pipe) { dt_iop_satcurve_gui_data_t *g = self->gui_data; if(!g) return; const dt_iop_order_iccprofile_info_t *work_profile = - dt_ioppr_get_iop_work_profile_info(self, self->dev->iop); + dt_ioppr_get_iop_work_profile_info(self, self->dev->iop); if(!work_profile) return; dt_colormatrix_t inputmatrix = {{ 0.0f }}; @@ -962,34 +1204,42 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *widget, dt_dev_pixelpi const float L_white = Y_to_dt_UCS_L_star(1.f); const dt_aligned_pixel_t mean_rgb = { self->picked_color[0], self->picked_color[1], self->picked_color[2], 0.f }; - const dt_aligned_pixel_t min_rgb = { self->picked_color_min[0], self->picked_color_min[1], self->picked_color_min[2], 0.f }; - const dt_aligned_pixel_t max_rgb = { self->picked_color_max[0], self->picked_color_max[1], self->picked_color_max[2], 0.f }; - - dt_iop_satcurve_data_t picker_data = { 0 }; - picker_data.formula = ((const dt_iop_satcurve_params_t *)self->params)->formula; - g->picked_s = pixel_s_in_norm(&picker_data, mean_rgb, inputmatrix_trans, gamut_lut, L_white, NULL); - g->picked_s_min = pixel_s_in_norm(&picker_data, min_rgb, inputmatrix_trans, gamut_lut, L_white, NULL); - g->picked_s_max = pixel_s_in_norm(&picker_data, max_rgb, inputmatrix_trans, gamut_lut, L_white, NULL); + const dt_aligned_pixel_t min_rgb = { self->picked_color_min[0], self->picked_color_min[1], self->picked_color_min[2], 0.f }; + const dt_aligned_pixel_t max_rgb = { self->picked_color_max[0], self->picked_color_max[1], self->picked_color_max[2], 0.f }; + + const dt_iop_satcurve_formula_t formula = ((const dt_iop_satcurve_params_t *)self->params)->formula; + + g->picked_s = pixel_s_in_norm(formula, mean_rgb, inputmatrix_trans, gamut_lut, L_white, NULL); + g->picked_s_min = pixel_s_in_norm(formula, min_rgb, inputmatrix_trans, gamut_lut, L_white, NULL); + g->picked_s_max = pixel_s_in_norm(formula, max_rgb, inputmatrix_trans, gamut_lut, L_white, NULL); g->picker_valid = TRUE; gtk_widget_queue_draw(GTK_WIDGET(g->area)); } -// reset the picker state when the module loses GUI focus, so no stale -// overlay lingers in another module void gui_focus(dt_iop_module_t *self, gboolean in) { - if(!in) - dt_iop_color_picker_reset(self, FALSE); + if(!in) dt_iop_color_picker_reset(self, FALSE); +} + +static void _channel_tabs_switch_callback(GtkNotebook *notebook, + GtkWidget *page, + guint page_num, + dt_iop_module_t *self) +{ + DT_GUARD_GUI_UPDATE(); + + dt_iop_satcurve_gui_data_t *g = self->gui_data; + if(!g) return; + + g->active_channel = (page_num == 1) + ? DT_IOP_SATCURVE_CHANNEL_BRILLIANCE + : DT_IOP_SATCURVE_CHANNEL_SATURATION; + g->selected = -1; + + gtk_widget_queue_draw(GTK_WIDGET(g->area)); } -// S_in_norm is defined differently per formula (JzAzBz vs darktable UCS), so -// a histogram computed under the previous formula is stale and must not -// linger on screen after switching. The bauhaus binding for the formula -// combobox already updates self->params and adds a history item, but not -// necessarily a *forced* one (see the explicit TRUE argument used for curve -// node edits below) - force it here as well so process() always re-runs -// and _update_sat_histogram() refreshes the histogram in either direction. void gui_changed(dt_iop_module_t *self, GtkWidget *widget, void *previous) { dt_iop_satcurve_gui_data_t *g = self->gui_data; @@ -1006,13 +1256,22 @@ void gui_update(dt_iop_module_t *self) if(!g) return; if(g->formula) dt_bauhaus_combobox_set(g->formula, p->formula); + + if(g->notebook) + gtk_notebook_set_current_page(GTK_NOTEBOOK(g->notebook), + g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? 1 : 0); + if(g->area) gtk_widget_queue_draw(GTK_WIDGET(g->area)); } void gui_cleanup(dt_iop_module_t *self) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - dt_draw_curve_destroy(g->curve); + if(!g) return; + + for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + if(g->channel[ch].curve) dt_draw_curve_destroy(g->channel[ch].curve); + dt_pthread_mutex_destroy(&g->histogram_lock); } @@ -1020,12 +1279,22 @@ void gui_init(dt_iop_module_t *self) { dt_iop_satcurve_gui_data_t *g = IOP_GUI_ALLOC(satcurve); const dt_iop_satcurve_params_t *p = self->default_params; + g->selected = -1; - g->curve_type = p->curve_type; - g->curve_num_nodes = p->curve_num_nodes; - g->curve = dt_draw_curve_new(0.f, 1.f, p->curve_type); - for(int i = 0; i < p->curve_num_nodes; i++) - dt_draw_curve_add_point(g->curve, p->curve[i].x, p->curve[i].y); + g->dragging = FALSE; + g->active_channel = DT_IOP_SATCURVE_CHANNEL_SATURATION; + + for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + { + g->channel[ch].curve_type = p->channel[ch].curve_type; + g->channel[ch].curve_num_nodes = p->channel[ch].curve_num_nodes; + g->channel[ch].curve = dt_draw_curve_new(0.f, 1.f, p->channel[ch].curve_type); + + for(int i = 0; i < p->channel[ch].curve_num_nodes; i++) + dt_draw_curve_add_point(g->channel[ch].curve, + p->channel[ch].curve[i].x, + p->channel[ch].curve[i].y); + } memset(g->histogram, 0, sizeof(g->histogram)); g->histogram_max = 1e-6f; @@ -1036,28 +1305,46 @@ void gui_init(dt_iop_module_t *self) self->widget = dt_gui_vbox(); + g->notebook = GTK_NOTEBOOK(gtk_notebook_new()); + gtk_notebook_set_show_border(g->notebook, FALSE); + gtk_notebook_set_scrollable(g->notebook, FALSE); + gtk_notebook_popup_disable(g->notebook); + + GtkWidget *page_sat = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *page_bri = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_size_request(page_sat, -1, 0); + gtk_widget_set_size_request(page_bri, -1, 0); + +GtkWidget *tab_sat = gtk_label_new(_("saturation")); +GtkWidget *tab_bri = gtk_label_new(_("brilliance")); + +gtk_widget_set_size_request(tab_sat, DT_PIXEL_APPLY_DPI(130), -1); +gtk_widget_set_size_request(tab_bri, DT_PIXEL_APPLY_DPI(130), -1); + +gtk_notebook_append_page(g->notebook, page_sat, tab_sat); +gtk_notebook_append_page(g->notebook, page_bri, tab_bri); + gtk_notebook_set_current_page(g->notebook, 0); + + g_signal_connect(G_OBJECT(g->notebook), "switch-page", + G_CALLBACK(_channel_tabs_switch_callback), self); + + dt_gui_box_add(self->widget, GTK_WIDGET(g->notebook)); + g->area = GTK_DRAWING_AREA(dt_ui_resize_wrap(NULL, 0, "plugins/darkroom/satcurve/graph_height")); gtk_widget_set_size_request(GTK_WIDGET(g->area), -1, DT_PIXEL_APPLY_DPI(180)); - gtk_widget_add_events(GTK_WIDGET(g->area), GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK); + gtk_widget_add_events(GTK_WIDGET(g->area), + GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK); g_signal_connect(G_OBJECT(g->area), "draw", G_CALLBACK(area_draw), self); g_signal_connect(G_OBJECT(g->area), "button-press-event", G_CALLBACK(area_button), self); g_signal_connect(G_OBJECT(g->area), "button-release-event", G_CALLBACK(area_release), self); g_signal_connect(G_OBJECT(g->area), "motion-notify-event", G_CALLBACK(area_motion), self); dt_gui_box_add(self->widget, GTK_WIDGET(g->area)); - // Row below the draw area: picker button on the left, formula combobox on - // the right. dt_bauhaus_combobox_from_params() auto-packs the widget into - // self->widget, so it is detached here and re-packed into the shared hbox - // (pack_start/pack_end keep both widgets at their natural width with a - // flexible gap in between). - // dt_color_picker_new_with_cst() is used instead of dt_color_picker_new() - // because the latter attaches the picker icon to an existing bauhaus - // widget rather than creating a standalone button. g->formula = dt_bauhaus_combobox_from_params(self, "formula"); dt_bauhaus_combobox_add(g->formula, _("JzAzBz")); dt_bauhaus_combobox_add(g->formula, _("darktable UCS")); gtk_widget_set_tooltip_text(g->formula, - _("choose the perceptual saturation definition used by the curve")); + _("choose the perceptual saturation definition used by both curves")); g_object_ref(g->formula); gtk_container_remove(GTK_CONTAINER(self->widget), g->formula); @@ -1067,6 +1354,7 @@ void gui_init(dt_iop_module_t *self) g->colorpicker = dt_color_picker_new_with_cst(self, DT_COLOR_PICKER_POINT, NULL, IOP_CS_RGB); gtk_widget_set_tooltip_text(g->colorpicker, _("pick saturation from image")); + gtk_box_pack_start(GTK_BOX(controls_hbox), g->colorpicker, FALSE, FALSE, 0); gtk_box_pack_end(GTK_BOX(controls_hbox), g->formula, FALSE, FALSE, 0); g_object_unref(g->formula); @@ -1079,12 +1367,21 @@ void init(dt_iop_module_t *self) self->params = calloc(1, sizeof(dt_iop_satcurve_params_t)); self->default_params = calloc(1, sizeof(dt_iop_satcurve_params_t)); self->params_size = sizeof(dt_iop_satcurve_params_t); - reset_curve(self->params); - reset_curve(self->default_params); + + reset_params(self->params); + reset_params(self->default_params); +} + +void cleanup(dt_iop_module_t *self) +{ + free(self->params); + self->params = NULL; + free(self->default_params); + self->default_params = NULL; } // 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 +// clang-format on \ No newline at end of file From deb528ebb010a3a4eb629fcaf6827a151cabc21a Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:42:48 +0200 Subject: [PATCH 04/12] performance optimization --- data/kernels/satcurve.cl | 58 ++++++++++++++++++- src/iop/satcurvergb.c | 120 +++++++++++++++++++++++++-------------- 2 files changed, 135 insertions(+), 43 deletions(-) diff --git a/data/kernels/satcurve.cl b/data/kernels/satcurve.cl index 3abe25a56bfd..3d2c85c3d924 100755 --- a/data/kernels/satcurve.cl +++ b/data/kernels/satcurve.cl @@ -104,6 +104,62 @@ static inline float satcurve_ucs_gamut_saturation_cl(const float J, const float return fmax(HSB_gamut_boundary.y, FLT_MIN); } +// s_in_norm only (no full pixel transform) -- used by the histogram +// reduction kernel below. Mirrors the s_in_norm computation embedded in each +// branch of satcurvergb() below and pixel_s_in_norm() on the CPU side. +static inline float satcurve_s_in_norm_cl(const float4 rgb_in, + constant const float *const matrix_in, + global const float *const gamut_lut, + const int formula, + const float L_white) +{ + const float4 rgb = fmax(rgb_in, 0.f); + const float4 xyz = matrix_product_float4(rgb, matrix_in); + + if(formula == DT_IOP_SATCURVE_DTUCS) + { + const float4 xyY = dt_D65_XYZ_to_xyY(xyz); + const float4 JCH = xyY_to_dt_UCS_JCH(xyY, L_white); + const float4 HCB = dt_UCS_JCH_to_HCB(JCH); + const float gamut_s = satcurve_ucs_gamut_saturation_cl(JCH.x, JCH.z, L_white, gamut_lut); + const float saturation = HCB.z > 0.f ? HCB.y / HCB.z : 0.f; + return saturation / gamut_s; + } + else + { + const float4 jab = XYZ_to_JzAzBz(xyz); + const float Jz = fmax(jab.x, 0.f); + const float Cz = hypot(jab.y, jab.z); + const float h = atan2(jab.z, jab.y); + const float gamut = fmax(satcurve_lookup_gamut_cl(gamut_lut, h), FLT_MIN); + return (Jz > 0.f ? Cz / Jz : 0.f) / gamut; + } +} + +#define DT_IOP_SATCURVE_HIST_RES 256 + +// on-device histogram reduction: accumulates per-pixel s_in_norm into +// hist_bins so only DT_IOP_SATCURVE_HIST_RES ints need to cross the PCIe +// bus, instead of copying the entire image back to the host. hist_bins must +// be zeroed by the caller before this kernel runs. +kernel void +satcurve_histogram(read_only image2d_t in, const int width, const int height, + constant const float *const matrix_in, + global const float *const gamut_lut, + const int formula, const float L_white, + global int *const hist_bins) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + const float4 rgb_in = Areadpixel(in, x, y); + const float s_in_norm = satcurve_s_in_norm_cl(rgb_in, matrix_in, gamut_lut, formula, L_white); + + const int bin = clamp((int)(s_in_norm * (DT_IOP_SATCURVE_HIST_RES - 1)), 0, DT_IOP_SATCURVE_HIST_RES - 1); + atomic_inc(hist_bins + bin); +} + kernel void satcurvergb(read_only image2d_t in, write_only image2d_t out, const int width, const int height, @@ -116,7 +172,7 @@ satcurvergb(read_only image2d_t in, write_only image2d_t out, const int y = get_global_id(1); if(x >= width || y >= height) return; - float4 pix_in = readpixel(in, x, y); + float4 pix_in = Areadpixel(in, x, y); float4 rgb = fmax(pix_in, 0.f); float4 xyz = matrix_product_float4(rgb, matrix_in); diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c index 5b8822c60b34..d7fc82c49eca 100644 --- a/src/iop/satcurvergb.c +++ b/src/iop/satcurvergb.c @@ -132,6 +132,7 @@ typedef struct dt_iop_satcurve_gui_data_t typedef struct dt_iop_satcurve_global_data_t { int kernel_satcurvergb; + int kernel_satcurve_histogram; } dt_iop_satcurve_global_data_t; typedef struct dt_iop_satcurve_factors_t @@ -357,6 +358,30 @@ static inline dt_iop_satcurve_factors_t eval_curve_factors(const dt_iop_satcurve }; } +// applies log1p compression and publishes bin counts to the GUI histogram; +// shared tail for both the CPU path (_update_sat_histogram) and the GPU path +// (process_cl), which only differ in how they arrive at per-bin pixel counts +static void _commit_sat_histogram(dt_iop_module_t *self, const int *const bins) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + if(!g) return; + + float local_hist[DT_IOP_SATCURVE_HIST_RES]; + float max_val = 0.f; + for(int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) + { + local_hist[i] = log1pf((float)bins[i]); + max_val = MAX(max_val, local_hist[i]); + } + + dt_pthread_mutex_lock(&g->histogram_lock); + memcpy(g->histogram, local_hist, sizeof(local_hist)); + g->histogram_max = MAX(max_val, 1e-6f); + dt_pthread_mutex_unlock(&g->histogram_lock); + + dt_control_queue_redraw_widget(GTK_WIDGET(g->area)); +} + static void _update_sat_histogram(dt_iop_module_t *self, const dt_iop_satcurve_data_t *d, const dt_colormatrix_t inputmatrix_trans, @@ -366,7 +391,7 @@ static void _update_sat_histogram(dt_iop_module_t *self, dt_iop_satcurve_gui_data_t *g = self->gui_data; if(!g) return; - float local_hist[DT_IOP_SATCURVE_HIST_RES] = { 0.f }; + int local_hist[DT_IOP_SATCURVE_HIST_RES] = { 0 }; const float L_white = Y_to_dt_UCS_L_star(1.f); DT_OMP_FOR(reduction(+ : local_hist[:DT_IOP_SATCURVE_HIST_RES])) @@ -375,24 +400,13 @@ static void _update_sat_histogram(dt_iop_module_t *self, const float s_in_norm = pixel_s_in_norm(d->formula, in + 4 * k, inputmatrix_trans, d->gamut_lut, L_white, NULL); const int bin = CLAMP((int)(s_in_norm * (DT_IOP_SATCURVE_HIST_RES - 1)), 0, DT_IOP_SATCURVE_HIST_RES - 1); - local_hist[bin] += 1.f; + local_hist[bin]++; } - float max_val = 0.f; - for(int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) - { - local_hist[i] = log1pf(local_hist[i]); - max_val = MAX(max_val, local_hist[i]); - } - - dt_pthread_mutex_lock(&g->histogram_lock); - memcpy(g->histogram, local_hist, sizeof(local_hist)); - g->histogram_max = MAX(max_val, 1e-6f); - dt_pthread_mutex_unlock(&g->histogram_lock); - - dt_control_queue_redraw_widget(GTK_WIDGET(g->area)); + _commit_sat_histogram(self, local_hist); } + static inline void apply_sat_and_brilliance_ucs(const dt_iop_satcurve_data_t *d, dt_aligned_pixel_t xyz, const float L_white) @@ -562,29 +576,16 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const int width = roi_in->width; const int height = roi_in->height; - if(self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) - && piece->pipe == self->dev->full.pipe) - { - dt_colormatrix_t hist_input_matrix = {{ 0.0f }}; - dt_colormatrix_t hist_input_matrix_trans; - dt_colormatrix_mul(hist_input_matrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); - dt_colormatrix_transpose(hist_input_matrix_trans, hist_input_matrix); - - float *host_in = dt_alloc_align_float((size_t)4 * width * height); - if(host_in) - { - cl_int hist_err = dt_opencl_copy_image_to_host(devid, host_in, dev_in, width, height, sizeof(float) * 4); - if(hist_err == CL_SUCCESS) - _update_sat_histogram(self, d, hist_input_matrix_trans, host_in, (size_t)width * height); - dt_free_align(host_in); - } - } + const gboolean want_histogram = + self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) + && piece->pipe == self->dev->full.pipe; cl_mem input_matrix_cl = NULL; cl_mem output_matrix_cl = NULL; cl_mem sat_lut_cl = NULL; cl_mem bri_lut_cl = NULL; cl_mem gamut_lut_cl = NULL; + cl_mem hist_bins_cl = NULL; dt_colormatrix_t input_matrix = {{ 0.0f }}; dt_colormatrix_t output_matrix = {{ 0.0f }}; @@ -611,6 +612,8 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const float L_white = Y_to_dt_UCS_L_star(1.f); + // pipeline-critical kernel goes first: the histogram below is pure UI + // feedback and must never delay the actual image processing on the queue err = dt_opencl_enqueue_kernel_2d_args( devid, gd->kernel_satcurvergb, width, height, CLARG(dev_in), CLARG(dev_out), @@ -619,12 +622,43 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, CLARG(sat_lut_cl), CLARG(bri_lut_cl), CLARG(gamut_lut_cl), CLARG(d->formula), CLARG(L_white)); + if(err != CL_SUCCESS) goto error; + + // GPU-side histogram reduction: only DT_IOP_SATCURVE_HIST_RES ints cross + // the PCIe bus, instead of the full width*height*4 floats of the old + // copy-to-host approach. Reuses input_matrix_cl/gamut_lut_cl already + // uploaded above -- no second matrix upload needed. + if(want_histogram) + { + int hist_bins_host[DT_IOP_SATCURVE_HIST_RES] = { 0 }; + const size_t hist_bins_size = sizeof(int) * DT_IOP_SATCURVE_HIST_RES; + + hist_bins_cl = dt_opencl_alloc_device_buffer(devid, hist_bins_size); + if(hist_bins_cl + && dt_opencl_write_buffer_to_device(devid, hist_bins_host, hist_bins_cl, 0, + hist_bins_size, TRUE) == CL_SUCCESS) + { + const cl_int hist_err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_satcurve_histogram, width, height, + CLARG(dev_in), CLARG(width), CLARG(height), + CLARG(input_matrix_cl), CLARG(gamut_lut_cl), + CLARG(d->formula), CLARG(L_white), + CLARG(hist_bins_cl)); + + if(hist_err == CL_SUCCESS + && dt_opencl_read_buffer_from_device(devid, hist_bins_host, hist_bins_cl, 0, + hist_bins_size, TRUE) == CL_SUCCESS) + _commit_sat_histogram(self, hist_bins_host); + } + } + error: dt_opencl_release_mem_object(input_matrix_cl); dt_opencl_release_mem_object(output_matrix_cl); dt_opencl_release_mem_object(sat_lut_cl); dt_opencl_release_mem_object(bri_lut_cl); dt_opencl_release_mem_object(gamut_lut_cl); + dt_opencl_release_mem_object(hist_bins_cl); return err; } @@ -634,12 +668,14 @@ void init_global(dt_iop_module_so_t *self) dt_iop_satcurve_global_data_t *gd = malloc(sizeof(dt_iop_satcurve_global_data_t)); self->data = gd; gd->kernel_satcurvergb = dt_opencl_create_kernel(program, "satcurvergb"); + gd->kernel_satcurve_histogram = dt_opencl_create_kernel(program, "satcurve_histogram"); } void cleanup_global(dt_iop_module_so_t *self) { const dt_iop_satcurve_global_data_t *gd = self->data; dt_opencl_free_kernel(gd->kernel_satcurvergb); + dt_opencl_free_kernel(gd->kernel_satcurve_histogram); free(self->data); self->data = NULL; } @@ -914,12 +950,12 @@ static void _draw_sat_picker(cairo_t *cr, dt_iop_module_t *self, const int w, co cairo_save(cr); - cairo_set_source_rgba(cr, 0.5, 0.7, 0.5, 0.25); - cairo_rectangle(cr, x_min, 0, fmaxf(x_max - x_min, 0.f), h); + cairo_set_source_rgba(cr, 1.0, 1.0, 1.0, 0.20); + cairo_rectangle(cr, x_min, 0, MAX(1.f, x_max - x_min), h); cairo_fill(cr); - cairo_set_source_rgba(cr, 0.5, 0.9, 0.5, 0.9); - cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(2.)); + cairo_set_source_rgba(cr, 1.0, 0.6, 0.2, 0.9); + cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.2)); cairo_move_to(cr, x_mean, 0); cairo_line_to(cr, x_mean, h); cairo_stroke(cr); @@ -1315,14 +1351,14 @@ void gui_init(dt_iop_module_t *self) gtk_widget_set_size_request(page_sat, -1, 0); gtk_widget_set_size_request(page_bri, -1, 0); -GtkWidget *tab_sat = gtk_label_new(_("saturation")); -GtkWidget *tab_bri = gtk_label_new(_("brilliance")); + GtkWidget *tab_sat = gtk_label_new(_("saturation")); + GtkWidget *tab_bri = gtk_label_new(_("brilliance")); -gtk_widget_set_size_request(tab_sat, DT_PIXEL_APPLY_DPI(130), -1); -gtk_widget_set_size_request(tab_bri, DT_PIXEL_APPLY_DPI(130), -1); + gtk_widget_set_size_request(tab_sat, DT_PIXEL_APPLY_DPI(130), -1); + gtk_widget_set_size_request(tab_bri, DT_PIXEL_APPLY_DPI(130), -1); -gtk_notebook_append_page(g->notebook, page_sat, tab_sat); -gtk_notebook_append_page(g->notebook, page_bri, tab_bri); + gtk_notebook_append_page(g->notebook, page_sat, tab_sat); + gtk_notebook_append_page(g->notebook, page_bri, tab_bri); gtk_notebook_set_current_page(g->notebook, 0); g_signal_connect(G_OBJECT(g->notebook), "switch-page", From 750f2d8f5074e3d265e686cda495a6a949f5b391 Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:18:34 +0200 Subject: [PATCH 05/12] scroll to change --- src/iop/satcurvergb.c | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c index d7fc82c49eca..e5f47e6b5c78 100644 --- a/src/iop/satcurvergb.c +++ b/src/iop/satcurvergb.c @@ -1154,6 +1154,26 @@ static gboolean area_button(GtkWidget *widget, GdkEventButton *event, dt_iop_mod return FALSE; } +static gboolean area_scroll(GtkWidget *widget, GdkEventScroll *event, + dt_iop_module_t *self) +{ + dt_iop_satcurve_gui_data_t *g = self->gui_data; + dt_iop_satcurve_channel_params_t *cp = get_active_channel_params(self); + + if(g->selected < 0) return FALSE; // nur wenn ein Knoten selektiert ist + + int delta_y = 0; + if(dt_gui_get_scroll_unit_delta(event, &delta_y)) + { + const float step = 0.02f * (event->state & GDK_CONTROL_MASK ? 5.0f : 1.0f); + const int n = g->selected; + cp->curve[n].y = CLAMP(cp->curve[n].y - delta_y * step, 0.f, 1.f); + + dt_dev_add_history_item(darktable.develop, self, TRUE); + gtk_widget_queue_draw(widget); + } + return TRUE; +} static gboolean area_motion(GtkWidget *widget, GdkEventMotion *event, dt_iop_module_t *self) { @@ -1369,11 +1389,13 @@ void gui_init(dt_iop_module_t *self) g->area = GTK_DRAWING_AREA(dt_ui_resize_wrap(NULL, 0, "plugins/darkroom/satcurve/graph_height")); gtk_widget_set_size_request(GTK_WIDGET(g->area), -1, DT_PIXEL_APPLY_DPI(180)); gtk_widget_add_events(GTK_WIDGET(g->area), - GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK); + GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); g_signal_connect(G_OBJECT(g->area), "draw", G_CALLBACK(area_draw), self); g_signal_connect(G_OBJECT(g->area), "button-press-event", G_CALLBACK(area_button), self); g_signal_connect(G_OBJECT(g->area), "button-release-event", G_CALLBACK(area_release), self); g_signal_connect(G_OBJECT(g->area), "motion-notify-event", G_CALLBACK(area_motion), self); + g_signal_connect(G_OBJECT(g->area), "scroll-event", G_CALLBACK(area_scroll), self); dt_gui_box_add(self->widget, GTK_WIDGET(g->area)); g->formula = dt_bauhaus_combobox_from_params(self, "formula"); From 0ff49c9d69cd4a9b84364e281f43249aaf5372d9 Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:20:52 +0200 Subject: [PATCH 06/12] add saturation mask --- data/kernels/satcurve.cl | 22 +++ src/iop/satcurvergb.c | 379 +++++++++++++++++++++++++-------------- 2 files changed, 268 insertions(+), 133 deletions(-) diff --git a/data/kernels/satcurve.cl b/data/kernels/satcurve.cl index 3d2c85c3d924..d9b47b56897a 100755 --- a/data/kernels/satcurve.cl +++ b/data/kernels/satcurve.cl @@ -252,5 +252,27 @@ satcurvergb(read_only image2d_t in, write_only image2d_t out, pixout = fmax(pixout, 0.f); pixout.w = pix_in.w; + write_imagef(out, (int2)(x, y), pixout); +} +// Visualisierung der Sättigungsmaske für die GUI-Anzeige: schreibt +// sqrt(s_in_norm) als Graustufe in alle 3 RGB-Kanäle, Alpha unverändert. +// Mirrors display_saturation_mask() auf der CPU-Seite. +kernel void +satcurve_mask(read_only image2d_t in, write_only image2d_t out, + const int width, const int height, + constant const float *const matrix_in, + global const float *const gamut_lut, + const int formula, const float L_white) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + const float4 pix_in = Areadpixel(in, x, y); + const float s_in_norm = satcurve_s_in_norm_cl(pix_in, matrix_in, gamut_lut, formula, L_white); + + const float intensity = sqrt(clamp(s_in_norm, 0.f, 1.f)); + float4 pixout = { intensity, intensity, intensity, pix_in.w }; + write_imagef(out, (int2)(x, y), pixout); } \ No newline at end of file diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c index e5f47e6b5c78..bcf9f43094e2 100644 --- a/src/iop/satcurvergb.c +++ b/src/iop/satcurvergb.c @@ -1,16 +1,16 @@ /* - This file is part of darktable, - Copyright (C) 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. + This file is part of darktable, + Copyright (C) 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. */ // our includes go first: @@ -25,6 +25,7 @@ #include "develop/imageop.h" #include "develop/imageop_gui.h" #include "develop/openmp_maths.h" +#include "dtgtk/button.h" #include "dtgtk/drawingarea.h" #include "gui/color_picker_proxy.h" #include "gui/draw.h" @@ -112,6 +113,7 @@ typedef struct dt_iop_satcurve_gui_data_t GtkDrawingArea *area; GtkWidget *colorpicker; GtkWidget *formula; + GtkWidget *show_saturation_mask; // NEU GtkNotebook *notebook; dt_iop_satcurve_gui_channel_t channel[DT_IOP_SATCURVE_CHANNELS]; @@ -127,12 +129,15 @@ typedef struct dt_iop_satcurve_gui_data_t float picked_s; float picked_s_min; float picked_s_max; + + gboolean mask_display; // NEU } dt_iop_satcurve_gui_data_t; typedef struct dt_iop_satcurve_global_data_t { int kernel_satcurvergb; int kernel_satcurve_histogram; + int kernel_satcurve_mask; // NEU } dt_iop_satcurve_global_data_t; typedef struct dt_iop_satcurve_factors_t @@ -154,10 +159,10 @@ const char *aliases() const char **description(dt_iop_module_t *self) { return dt_iop_set_description(self, _("remap saturation and brilliance with curves"), - _("corrective or creative"), - _("linear, RGB, scene-referred"), - _("linear, RGB, scene-referred"), - _("linear, RGB, scene-referred")); + _("corrective or creative"), + _("linear, RGB, scene-referred"), + _("linear, RGB, scene-referred"), + _("linear, RGB, scene-referred")); } int flags() @@ -171,8 +176,8 @@ int default_group() } dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, - dt_dev_pixelpipe_t *pipe, - dt_dev_pixelpipe_iop_t *piece) + dt_dev_pixelpipe_t *pipe, + dt_dev_pixelpipe_iop_t *piece) { return IOP_CS_RGB; } @@ -248,9 +253,9 @@ static inline float clip_jz_chroma(const float Jz, const float Cz, const float c } static inline float pixel_s_in_norm_jzazbz(const float *const restrict rgb_in, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict gamut_lut, - float *const restrict h_out) + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + float *const restrict h_out) { dt_aligned_pixel_t rgb, xyz, jab; copy_pixel(rgb, rgb_in); @@ -273,14 +278,14 @@ static inline float pixel_s_in_norm_jzazbz(const float *const restrict rgb_in, // model used throughout darktable_ucs_22_helpers. Single source of truth for this // computation -- do not re-derive it inline at call sites. static inline float satcurve_ucs_gamut_saturation(const float J, const float h, - const float L_white, - const float *const restrict gamut_lut) + const float L_white, + const float *const restrict gamut_lut) { const float max_colorfulness = MAX(satcurve_lookup_gamut(gamut_lut, h), FLT_MIN); const float max_chroma = 15.932993652962535f - * powf(J / L_white, 0.6523997524738018f) - * powf(max_colorfulness, 0.6007557017508491f) - / L_white; + * powf(J / L_white, 0.6523997524738018f) + * powf(max_colorfulness, 0.6007557017508491f) + / L_white; const dt_aligned_pixel_t JCH_gamut_boundary = { J, max_chroma, h, 0.f }; dt_aligned_pixel_t HSB_gamut_boundary; @@ -290,10 +295,10 @@ static inline float satcurve_ucs_gamut_saturation(const float J, const float h, } static inline float pixel_s_in_norm_ucs(const float *const restrict rgb_in, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict gamut_lut, - const float L_white, - float *const restrict h_out) + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + const float L_white, + float *const restrict h_out) { dt_aligned_pixel_t rgb, xyz, xyY, JCH, HCB; @@ -314,11 +319,11 @@ static inline float pixel_s_in_norm_ucs(const float *const restrict rgb_in, } static inline float pixel_s_in_norm(const dt_iop_satcurve_formula_t formula, - const float *const restrict rgb_in, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict gamut_lut, - const float L_white, - float *const restrict h_out) + const float *const restrict rgb_in, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + const float L_white, + float *const restrict h_out) { if(formula == DT_IOP_SATCURVE_DTUCS) return pixel_s_in_norm_ucs(rgb_in, inputmatrix_trans, gamut_lut, L_white, h_out); @@ -342,12 +347,12 @@ static inline dt_iop_satcurve_gui_channel_t *get_active_gui_channel(dt_iop_modul static inline gboolean channel_is_neutral(const dt_iop_satcurve_channel_data_t *c) { return c->curve_num_nodes == 2 - && fabsf(c->lut[0] - .5f) < 1e-6f - && fabsf(c->lut[DT_IOP_SATCURVE_RES - 1] - .5f) < 1e-6f; + && fabsf(c->lut[0] - .5f) < 1e-6f + && fabsf(c->lut[DT_IOP_SATCURVE_RES - 1] - .5f) < 1e-6f; } static inline dt_iop_satcurve_factors_t eval_curve_factors(const dt_iop_satcurve_data_t *d, - const float s_in_norm) + const float s_in_norm) { const float sat_c = CLAMP(lookup_lut(d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].lut, s_in_norm), 0.f, 1.f); const float bri_c = CLAMP(lookup_lut(d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].lut, s_in_norm), 0.f, 1.f); @@ -383,10 +388,10 @@ static void _commit_sat_histogram(dt_iop_module_t *self, const int *const bins) } static void _update_sat_histogram(dt_iop_module_t *self, - const dt_iop_satcurve_data_t *d, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict in, - const size_t npixels) + const dt_iop_satcurve_data_t *d, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict in, + const size_t npixels) { dt_iop_satcurve_gui_data_t *g = self->gui_data; if(!g) return; @@ -399,17 +404,16 @@ static void _update_sat_histogram(dt_iop_module_t *self, { const float s_in_norm = pixel_s_in_norm(d->formula, in + 4 * k, inputmatrix_trans, d->gamut_lut, L_white, NULL); const int bin = CLAMP((int)(s_in_norm * (DT_IOP_SATCURVE_HIST_RES - 1)), - 0, DT_IOP_SATCURVE_HIST_RES - 1); + 0, DT_IOP_SATCURVE_HIST_RES - 1); local_hist[bin]++; } _commit_sat_histogram(self, local_hist); } - static inline void apply_sat_and_brilliance_ucs(const dt_iop_satcurve_data_t *d, - dt_aligned_pixel_t xyz, - const float L_white) + dt_aligned_pixel_t xyz, + const float L_white) { dt_aligned_pixel_t xyY, JCH, HCB, HSB; @@ -453,7 +457,7 @@ static inline void apply_sat_and_brilliance_ucs(const dt_iop_satcurve_data_t *d, } static inline void apply_sat_and_brilliance_jzazbz(const dt_iop_satcurve_data_t *d, - dt_aligned_pixel_t xyz) + dt_aligned_pixel_t xyz) { dt_aligned_pixel_t jab; dt_XYZ_2_JzAzBz(xyz, jab); @@ -485,20 +489,47 @@ static inline void apply_sat_and_brilliance_jzazbz(const dt_iop_satcurve_data_t dt_JzAzBz_2_XYZ(jab, xyz); } +// NEU: berechnet pro Pixel die normierte Sättigung in einen separaten Buffer, +// analog zu compute_luminance_mask() in toneequal.c +static inline void compute_saturation_mask(const dt_iop_satcurve_data_t *d, + const dt_colormatrix_t inputmatrix_trans, + const float L_white, + const float *const restrict in, + float *const restrict mask, + const size_t npixels) +{ + DT_OMP_FOR() + for(size_t k = 0; k < npixels; k++) + { + mask[k] = pixel_s_in_norm(d->formula, in + 4 * k, inputmatrix_trans, + d->gamut_lut, L_white, NULL); + } +} + +// NEU: Graustufen-Visualisierung der Sättigungsmaske; sqrt-"Gamma" für bessere +// Sichtbarkeit niedriger Werte, analog display_luminance_mask() in toneequal.c +static inline void display_saturation_mask(const float *const restrict in, + const float *const restrict mask, + float *const restrict out, + const size_t npixels) +{ + DT_OMP_FOR() + for(size_t k = 0; k < npixels; k++) + { + const float intensity = sqrtf(CLAMP(mask[k], 0.f, 1.f)); + out[4 * k + 0] = intensity; + out[4 * k + 1] = intensity; + out[4 * k + 2] = intensity; + out[4 * k + 3] = in[4 * k + 3]; + } +} + void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *const ivoid, void *const ovoid, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) { dt_iop_satcurve_data_t *d = piece->data; - - const gboolean neutral_sat = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); - const gboolean neutral_bri = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); - - if(neutral_sat && neutral_bri) - { - dt_iop_image_copy_by_size(ovoid, ivoid, roi_out->width, roi_out->height, piece->colors); - return; - } + dt_iop_satcurve_gui_data_t *g = self->gui_data; const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); if(!work_profile || piece->colors != 4) @@ -520,12 +551,34 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const float *const restrict in = DT_IS_ALIGNED((const float *)ivoid); float *const restrict out = DT_IS_ALIGNED((float *)ovoid); const size_t npixels = (size_t)roi_out->width * roi_out->height; + const float L_white = Y_to_dt_UCS_L_star(1.f); if(self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) && piece->pipe == self->dev->full.pipe) _update_sat_histogram(self, d, inputmatrix_trans, in, npixels); - const float L_white = Y_to_dt_UCS_L_star(1.f); + // NEU: Maskenanzeige-Zweig -- zeigt die normierte Sättigung als Graustufenbild + if(self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && g && g->mask_display) + { + float *const restrict mask = dt_alloc_align_float(npixels); + if(mask) + { + compute_saturation_mask(d, inputmatrix_trans, L_white, in, mask, npixels); + display_saturation_mask(in, mask, out, npixels); + dt_free_align(mask); + piece->pipe->mask_display = DT_DEV_PIXELPIPE_DISPLAY_PASSTHRU; + return; + } + } + + const gboolean neutral_sat = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); + const gboolean neutral_bri = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); + + if(neutral_sat && neutral_bri) + { + dt_iop_image_copy_by_size(ovoid, ivoid, roi_out->width, roi_out->height, piece->colors); + return; + } DT_OMP_FOR() for(size_t k = 0; k < npixels; k++) @@ -545,7 +598,6 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, pixout[3] = in[4 * k + 3]; copy_pixel_nontemporal(out + 4 * k, pixout); } - dt_omploop_sfence(); } @@ -555,30 +607,22 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) { dt_iop_satcurve_data_t *d = piece->data; + dt_iop_satcurve_gui_data_t *g = self->gui_data; const dt_iop_satcurve_global_data_t *gd = self->global_data; cl_int err = DT_OPENCL_DEFAULT_ERROR; if(piece->colors != 4) return err; - const gboolean neutral_sat = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); - const gboolean neutral_bri = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); - if(neutral_sat && neutral_bri) - return dt_opencl_enqueue_copy_image(piece->pipe->devid, dev_in, dev_out, - (size_t[]){ 0, 0, 0 }, - (size_t[]){ 0, 0, 0 }, - (size_t[]){ roi_in->width, roi_in->height, 1 }); - const dt_iop_order_iccprofile_info_t *const work_profile = - dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); + dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); if(work_profile == NULL) return err; const int devid = piece->pipe->devid; const int width = roi_in->width; const int height = roi_in->height; - const gboolean want_histogram = - self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) - && piece->pipe == self->dev->full.pipe; + const gboolean want_mask = + self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && g && g->mask_display; cl_mem input_matrix_cl = NULL; cl_mem output_matrix_cl = NULL; @@ -593,6 +637,41 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, dt_colormatrix_mul(output_matrix, work_profile->matrix_out, XYZ_D65_to_D50_CAT16); input_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), input_matrix); + gamut_lut_cl = dt_opencl_copy_host_to_device_constant(devid, LUT_ELEM * sizeof(float), d->gamut_lut); + + if(input_matrix_cl == NULL || gamut_lut_cl == NULL) + { + err = CL_MEM_OBJECT_ALLOCATION_FAILURE; + goto error; + } + + const float L_white = Y_to_dt_UCS_L_star(1.f); + + // NEU: Maskenanzeige-Pfad -- eigener, einfacherer Kernel statt satcurvergb + if(want_mask) + { + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_satcurve_mask, width, height, + CLARG(dev_in), CLARG(dev_out), + CLARG(width), CLARG(height), + CLARG(input_matrix_cl), CLARG(gamut_lut_cl), + CLARG(d->formula), CLARG(L_white)); + + if(err == CL_SUCCESS) piece->pipe->mask_display = DT_DEV_PIXELPIPE_DISPLAY_PASSTHRU; + goto error; + } + + const gboolean neutral_sat = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); + const gboolean neutral_bri = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); + if(neutral_sat && neutral_bri) + { + err = dt_opencl_enqueue_copy_image(piece->pipe->devid, dev_in, dev_out, + (size_t[]){ 0, 0, 0 }, + (size_t[]){ 0, 0, 0 }, + (size_t[]){ roi_in->width, roi_in->height, 1 }); + goto error; + } + output_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), output_matrix); sat_lut_cl = dt_opencl_copy_host_to_device_constant( devid, DT_IOP_SATCURVE_RES * sizeof(float), @@ -600,17 +679,16 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, bri_lut_cl = dt_opencl_copy_host_to_device_constant( devid, DT_IOP_SATCURVE_RES * sizeof(float), d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].lut); - gamut_lut_cl = dt_opencl_copy_host_to_device_constant( - devid, LUT_ELEM * sizeof(float), d->gamut_lut); - if(input_matrix_cl == NULL || output_matrix_cl == NULL - || sat_lut_cl == NULL || bri_lut_cl == NULL || gamut_lut_cl == NULL) + if(output_matrix_cl == NULL || sat_lut_cl == NULL || bri_lut_cl == NULL) { err = CL_MEM_OBJECT_ALLOCATION_FAILURE; goto error; } - const float L_white = Y_to_dt_UCS_L_star(1.f); + const gboolean want_histogram = + self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) + && piece->pipe == self->dev->full.pipe; // pipeline-critical kernel goes first: the histogram below is pure UI // feedback and must never delay the actual image processing on the queue @@ -636,7 +714,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, hist_bins_cl = dt_opencl_alloc_device_buffer(devid, hist_bins_size); if(hist_bins_cl && dt_opencl_write_buffer_to_device(devid, hist_bins_host, hist_bins_cl, 0, - hist_bins_size, TRUE) == CL_SUCCESS) + hist_bins_size, TRUE) == CL_SUCCESS) { const cl_int hist_err = dt_opencl_enqueue_kernel_2d_args( devid, gd->kernel_satcurve_histogram, width, height, @@ -647,7 +725,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, if(hist_err == CL_SUCCESS && dt_opencl_read_buffer_from_device(devid, hist_bins_host, hist_bins_cl, 0, - hist_bins_size, TRUE) == CL_SUCCESS) + hist_bins_size, TRUE) == CL_SUCCESS) _commit_sat_histogram(self, hist_bins_host); } } @@ -669,6 +747,7 @@ void init_global(dt_iop_module_so_t *self) self->data = gd; gd->kernel_satcurvergb = dt_opencl_create_kernel(program, "satcurvergb"); gd->kernel_satcurve_histogram = dt_opencl_create_kernel(program, "satcurve_histogram"); + gd->kernel_satcurve_mask = dt_opencl_create_kernel(program, "satcurve_mask"); // NEU } void cleanup_global(dt_iop_module_so_t *self) @@ -676,6 +755,7 @@ void cleanup_global(dt_iop_module_so_t *self) const dt_iop_satcurve_global_data_t *gd = self->data; dt_opencl_free_kernel(gd->kernel_satcurvergb); dt_opencl_free_kernel(gd->kernel_satcurve_histogram); + dt_opencl_free_kernel(gd->kernel_satcurve_mask); // NEU free(self->data); self->data = NULL; } @@ -716,7 +796,7 @@ void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelp } static void build_jzazbz_gamut_lut(dt_iop_satcurve_data_t *d, - const dt_iop_order_iccprofile_info_t *work_profile) + const dt_iop_order_iccprofile_info_t *work_profile) { dt_colormatrix_t inputmatrix = {{ 0.0f }}; dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); @@ -753,15 +833,15 @@ static void build_jzazbz_gamut_lut(dt_iop_satcurve_data_t *d, d->gamut_lut[0] = (sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0] + sampler[1] + sampler[2]) / 5.f; d->gamut_lut[1] = (sampler[LUT_ELEM - 1] + sampler[0] + sampler[1] + sampler[2] + sampler[3]) / 5.f; d->gamut_lut[LUT_ELEM - 2] = (sampler[LUT_ELEM - 4] + sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] - + sampler[LUT_ELEM - 1] + sampler[0]) / 5.f; + + sampler[LUT_ELEM - 1] + sampler[0]) / 5.f; d->gamut_lut[LUT_ELEM - 1] = (sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] - + sampler[0] + sampler[1]) / 5.f; + + sampler[0] + sampler[1]) / 5.f; dt_free_align(sampler); } static void build_gamut_lut(dt_iop_satcurve_data_t *d, - const dt_iop_order_iccprofile_info_t *work_profile) + const dt_iop_order_iccprofile_info_t *work_profile) { if(d->formula == DT_IOP_SATCURVE_DTUCS) { @@ -776,7 +856,7 @@ static void build_gamut_lut(dt_iop_satcurve_data_t *d, } static void sync_channel_curve(dt_iop_satcurve_channel_data_t *dst, - const dt_iop_satcurve_channel_params_t *src) + const dt_iop_satcurve_channel_params_t *src) { if(dst->curve_type != src->curve_type || dst->curve_num_nodes != src->curve_num_nodes) { @@ -798,7 +878,7 @@ static void sync_channel_curve(dt_iop_satcurve_channel_data_t *dst, } void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_t *pipe, - dt_dev_pixelpipe_iop_t *piece) + dt_dev_pixelpipe_iop_t *piece) { dt_iop_satcurve_data_t *d = piece->data; const dt_iop_satcurve_params_t *p = (const dt_iop_satcurve_params_t *)p1; @@ -813,7 +893,7 @@ void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_ sync_channel_curve(&d->channel[ch], &p->channel[ch]); const dt_iop_order_iccprofile_info_t *work_profile = - dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); + dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); if(work_profile && (!d->lut_inited || work_profile != d->work_profile)) { @@ -824,11 +904,11 @@ void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_ } 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) + const void *const old_params, + const int old_version, + void **new_params, + int32_t *new_params_size, + int *new_version) { if(old_version == 1) { @@ -849,7 +929,6 @@ int legacy_params(dt_iop_module_t *self, *new_version = 2; return 0; } - return 1; } @@ -859,33 +938,33 @@ void init_presets(dt_iop_module_so_t *self) reset_params(&p); dt_gui_presets_add_generic(_("neutral"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); reset_params(&p); p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[0].y = .60f; p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[1].y = .55f; dt_gui_presets_add_generic(_("gentle saturation"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); reset_params(&p); p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve_num_nodes = 3; p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[1] = (dt_iop_satcurve_node_t){ .45f, .62f }; p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[2] = (dt_iop_satcurve_node_t){ 1.f, .42f }; dt_gui_presets_add_generic(_("protect saturated colors"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); reset_params(&p); p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[0].y = .56f; p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[1].y = .54f; dt_gui_presets_add_generic(_("gentle brilliance"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); reset_params(&p); p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve_num_nodes = 3; p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[1] = (dt_iop_satcurve_node_t){ .40f, .58f }; p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[2] = (dt_iop_satcurve_node_t){ 1.f, .48f }; dt_gui_presets_add_generic(_("bright mids, protect extremes"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); } static inline int add_node_to_channel(dt_iop_satcurve_channel_params_t *c, const float x, const float y) @@ -911,7 +990,7 @@ static void _get_graph_geometry(const GtkAllocation *a, float *x0, float *y0, fl *y0 = DT_IOP_SATCURVE_INSET; *w = MAX(1, a->width - 2 * DT_IOP_SATCURVE_INSET); *h = MAX(1, a->height - 2 * DT_IOP_SATCURVE_INSET - DT_IOP_SATCURVE_GRADIENT_GAP - - DT_IOP_SATCURVE_GRADIENT_SIZE); + - DT_IOP_SATCURVE_GRADIENT_SIZE); } static void _draw_sat_histogram(cairo_t *cr, dt_iop_satcurve_gui_data_t *g, const int w, const int h) @@ -964,12 +1043,12 @@ static void _draw_sat_picker(cairo_t *cr, dt_iop_module_t *self, const int w, co } static void _draw_channel_curve(cairo_t *cr, - const dt_iop_satcurve_channel_params_t *cp, - const dt_iop_satcurve_gui_channel_t *gc, - const int selected, - const gboolean active, - const int w, const int h, - const double r, const double g, const double b) + const dt_iop_satcurve_channel_params_t *cp, + const dt_iop_satcurve_gui_channel_t *gc, + const int selected, + const gboolean active, + const int w, const int h, + const double r, const double g, const double b) { cairo_save(cr); @@ -1000,7 +1079,7 @@ static void _draw_channel_curve(cairo_t *cr, } static void _sync_gui_curve(dt_iop_satcurve_gui_channel_t *gc, - const dt_iop_satcurve_channel_params_t *cp) + const dt_iop_satcurve_channel_params_t *cp) { if(gc->curve_type != cp->curve_type || gc->curve_num_nodes != cp->curve_num_nodes) { @@ -1061,20 +1140,20 @@ static gboolean area_draw(GtkWidget *widget, cairo_t *cr, dt_iop_module_t *self) cairo_stroke(cr); _draw_channel_curve(cr, - &p->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], - &g->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], - g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION ? g->selected : -1, - g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION, - (int)gw, (int)gh, - .90, .90, .90); + &p->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], + &g->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], + g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION ? g->selected : -1, + g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION, + (int)gw, (int)gh, + .90, .90, .90); _draw_channel_curve(cr, - &p->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], - &g->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], - g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? g->selected : -1, - g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE, - (int)gw, (int)gh, - 1.00, .65, .20); + &p->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], + &g->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], + g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? g->selected : -1, + g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE, + (int)gw, (int)gh, + 1.00, .65, .20); cairo_restore(cr); @@ -1135,7 +1214,6 @@ static gboolean area_button(GtkWidget *widget, GdkEventButton *event, dt_iop_mod for(int i = hit; i < cp->curve_num_nodes - 1; i++) cp->curve[i] = cp->curve[i + 1]; cp->curve_num_nodes--; } - g->selected = -1; dt_dev_add_history_item(darktable.develop, self, TRUE); gtk_widget_queue_draw(widget); @@ -1154,6 +1232,7 @@ static gboolean area_button(GtkWidget *widget, GdkEventButton *event, dt_iop_mod return FALSE; } + static gboolean area_scroll(GtkWidget *widget, GdkEventScroll *event, dt_iop_module_t *self) { @@ -1172,6 +1251,7 @@ static gboolean area_scroll(GtkWidget *widget, GdkEventScroll *event, dt_dev_add_history_item(darktable.develop, self, TRUE); gtk_widget_queue_draw(widget); } + return TRUE; } @@ -1197,8 +1277,8 @@ static gboolean area_motion(GtkWidget *widget, GdkEventMotion *event, dt_iop_mod cp->curve[n].x = 1.f; else cp->curve[n].x = CLAMP(x, - cp->curve[n - 1].x + DT_IOP_SATCURVE_MIN_X_DISTANCE, - cp->curve[n + 1].x - DT_IOP_SATCURVE_MIN_X_DISTANCE); + cp->curve[n - 1].x + DT_IOP_SATCURVE_MIN_X_DISTANCE, + cp->curve[n + 1].x - DT_IOP_SATCURVE_MIN_X_DISTANCE); cp->curve[n].y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); gtk_widget_queue_draw(widget); @@ -1248,7 +1328,7 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *widget, dt_dev_pixelpi if(!g) return; const dt_iop_order_iccprofile_info_t *work_profile = - dt_ioppr_get_iop_work_profile_info(self, self->dev->iop); + dt_ioppr_get_iop_work_profile_info(self, self->dev->iop); if(!work_profile) return; dt_colormatrix_t inputmatrix = {{ 0.0f }}; @@ -1273,15 +1353,39 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *widget, dt_dev_pixelpi gtk_widget_queue_draw(GTK_WIDGET(g->area)); } +// NEU: Callback für den Sättigungsmasken-Toggle-Button, analog +// show_luminance_mask_callback() in toneequal.c +static void show_saturation_mask_callback(GtkToggleButton *button, dt_iop_module_t *self) +{ + if(darktable.gui->reset) return; + dt_iop_satcurve_gui_data_t *g = self->gui_data; + + dt_iop_request_focus(self); + g->mask_display = gtk_toggle_button_get_active(button); + dt_dev_reprocess_center(self->dev); +} + void gui_focus(dt_iop_module_t *self, gboolean in) { - if(!in) dt_iop_color_picker_reset(self, FALSE); + dt_iop_satcurve_gui_data_t *g = self->gui_data; + if(!in) + { + dt_iop_color_picker_reset(self, FALSE); + + // NEU: Maskenanzeige beim Verlassen des Moduls zurücksetzen + if(g && g->mask_display) + { + g->mask_display = FALSE; + if(g->show_saturation_mask) + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->show_saturation_mask), FALSE); + } + } } static void _channel_tabs_switch_callback(GtkNotebook *notebook, - GtkWidget *page, - guint page_num, - dt_iop_module_t *self) + GtkWidget *page, + guint page_num, + dt_iop_module_t *self) { DT_GUARD_GUI_UPDATE(); @@ -1289,8 +1393,8 @@ static void _channel_tabs_switch_callback(GtkNotebook *notebook, if(!g) return; g->active_channel = (page_num == 1) - ? DT_IOP_SATCURVE_CHANNEL_BRILLIANCE - : DT_IOP_SATCURVE_CHANNEL_SATURATION; + ? DT_IOP_SATCURVE_CHANNEL_BRILLIANCE + : DT_IOP_SATCURVE_CHANNEL_SATURATION; g->selected = -1; gtk_widget_queue_draw(GTK_WIDGET(g->area)); @@ -1315,7 +1419,7 @@ void gui_update(dt_iop_module_t *self) if(g->notebook) gtk_notebook_set_current_page(GTK_NOTEBOOK(g->notebook), - g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? 1 : 0); + g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? 1 : 0); if(g->area) gtk_widget_queue_draw(GTK_WIDGET(g->area)); } @@ -1339,6 +1443,7 @@ void gui_init(dt_iop_module_t *self) g->selected = -1; g->dragging = FALSE; g->active_channel = DT_IOP_SATCURVE_CHANNEL_SATURATION; + g->mask_display = FALSE; // NEU for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) { @@ -1348,8 +1453,8 @@ void gui_init(dt_iop_module_t *self) for(int i = 0; i < p->channel[ch].curve_num_nodes; i++) dt_draw_curve_add_point(g->channel[ch].curve, - p->channel[ch].curve[i].x, - p->channel[ch].curve[i].y); + p->channel[ch].curve[i].x, + p->channel[ch].curve[i].y); } memset(g->histogram, 0, sizeof(g->histogram)); @@ -1382,27 +1487,27 @@ void gui_init(dt_iop_module_t *self) gtk_notebook_set_current_page(g->notebook, 0); g_signal_connect(G_OBJECT(g->notebook), "switch-page", - G_CALLBACK(_channel_tabs_switch_callback), self); + G_CALLBACK(_channel_tabs_switch_callback), self); dt_gui_box_add(self->widget, GTK_WIDGET(g->notebook)); g->area = GTK_DRAWING_AREA(dt_ui_resize_wrap(NULL, 0, "plugins/darkroom/satcurve/graph_height")); gtk_widget_set_size_request(GTK_WIDGET(g->area), -1, DT_PIXEL_APPLY_DPI(180)); gtk_widget_add_events(GTK_WIDGET(g->area), - GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); + GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); g_signal_connect(G_OBJECT(g->area), "draw", G_CALLBACK(area_draw), self); g_signal_connect(G_OBJECT(g->area), "button-press-event", G_CALLBACK(area_button), self); g_signal_connect(G_OBJECT(g->area), "button-release-event", G_CALLBACK(area_release), self); g_signal_connect(G_OBJECT(g->area), "motion-notify-event", G_CALLBACK(area_motion), self); - g_signal_connect(G_OBJECT(g->area), "scroll-event", G_CALLBACK(area_scroll), self); + g_signal_connect(G_OBJECT(g->area), "scroll-event", G_CALLBACK(area_scroll), self); dt_gui_box_add(self->widget, GTK_WIDGET(g->area)); g->formula = dt_bauhaus_combobox_from_params(self, "formula"); dt_bauhaus_combobox_add(g->formula, _("JzAzBz")); dt_bauhaus_combobox_add(g->formula, _("darktable UCS")); gtk_widget_set_tooltip_text(g->formula, - _("choose the perceptual saturation definition used by both curves")); + _("choose the perceptual saturation definition used by both curves")); g_object_ref(g->formula); gtk_container_remove(GTK_CONTAINER(self->widget), g->formula); @@ -1413,7 +1518,14 @@ void gui_init(dt_iop_module_t *self) g->colorpicker = dt_color_picker_new_with_cst(self, DT_COLOR_PICKER_POINT, NULL, IOP_CS_RGB); gtk_widget_set_tooltip_text(g->colorpicker, _("pick saturation from image")); + // NEU: Toggle-Button für die Sättigungsmaskenanzeige + g->show_saturation_mask = dtgtk_togglebutton_new(dtgtk_cairo_paint_showmask, 0, NULL); + gtk_widget_set_tooltip_text(g->show_saturation_mask, _("display saturation mask")); + g_signal_connect(G_OBJECT(g->show_saturation_mask), "toggled", + G_CALLBACK(show_saturation_mask_callback), self); + gtk_box_pack_start(GTK_BOX(controls_hbox), g->colorpicker, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(controls_hbox), g->show_saturation_mask, FALSE, FALSE, 0); // NEU gtk_box_pack_end(GTK_BOX(controls_hbox), g->formula, FALSE, FALSE, 0); g_object_unref(g->formula); @@ -1425,6 +1537,7 @@ void init(dt_iop_module_t *self) self->params = calloc(1, sizeof(dt_iop_satcurve_params_t)); self->default_params = calloc(1, sizeof(dt_iop_satcurve_params_t)); self->params_size = sizeof(dt_iop_satcurve_params_t); + self->gui_data = NULL; reset_params(self->params); reset_params(self->default_params); From 19abf26371bf2d35998deebe0ca74f7aa9e20f1e Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:22:14 +0200 Subject: [PATCH 07/12] added fast guided filter ported from toneequal plus opencl path for it --- data/kernels/fast_guided_filter.cl | 220 ++++++ data/kernels/programs.conf | 3 +- data/kernels/satcurve.cl | 45 ++ src/iop/satcurvergb.c | 1032 ++++++++++++++++++++-------- 4 files changed, 1009 insertions(+), 291 deletions(-) create mode 100755 data/kernels/fast_guided_filter.cl mode change 100644 => 100755 src/iop/satcurvergb.c diff --git a/data/kernels/fast_guided_filter.cl b/data/kernels/fast_guided_filter.cl new file mode 100755 index 000000000000..aa4ef383b655 --- /dev/null +++ b/data/kernels/fast_guided_filter.cl @@ -0,0 +1,220 @@ +/* + This file is part of darktable, + Copyright (C) 2019-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 . +*/ + +#include "common.h" + +/* + * OpenCL port of the "fast scalar guided filter" from common/fast_guided_filter.h + * (fast_surface_blur() and friends). This mirrors the CPU algorithm exactly: + * + * 1. downsample the single-channel image by a factor of 4 (bilinear) + * 2. per iteration: + * - quantize the (downsampled) image into a guide buffer g + * - box-blur g, p (=image), g*g, g*p over a window of 2*radius+1 + * - solve the linear regression p ~= a*g + b per pixel + * - box-blur a, b over the same window (spatial smoothing of params) + * - if not the last iteration: image = a*image + b + * 3. upsample a, b back to full resolution (bilinear) + * 4. final blend: image = a*image + b + * + * All buffers are single-channel (CL_R / float) images, addressed the same + * way as the existing guided_filter_box_mean_x/_y kernels in guided_filter.cl, + * which this file reuses verbatim for the box-blur passes (see the host-side + * orchestration that must call them once per buffer, per direction). + * + * NOTE: this file only provides the per-pixel kernels. The host-side C code + * that allocates the intermediate buffers and launches these kernels (plus + * the existing guided_filter_box_mean_x/_y from guided_filter.cl) in the + * right order, once per iteration, still needs to be written -- mirroring + * what common/guided_filter.c does for the RGB-guided variant. + */ + +#define FGF_MIN_FLOAT 0x1p-16f // exp2(-16.0f), matches MIN_FLOAT in fast_guided_filter.h + + +// generic bilinear resample of a single-channel image; used both to +// downsample the guide/signal by a factor of ~4 and to upsample the +// solved (a, b) parameter buffers back to full resolution. +kernel void +fastguided_resample(read_only image2d_t in, + const int width_in, const int height_in, + write_only image2d_t out, + const int width_out, const int height_out) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width_out || y >= height_out) return; + + const float x_out = (float)x / (float)width_out; + const float y_out = (float)y / (float)height_out; + + const float x_in = x_out * (float)width_in; + const float y_in = y_out * (float)height_in; + + int x_prev = (int)floor(x_in); + int x_next = x_prev + 1; + int y_prev = (int)floor(y_in); + int y_next = y_prev + 1; + + x_prev = clamp(x_prev, 0, width_in - 1); + x_next = clamp(x_next, 0, width_in - 1); + y_prev = clamp(y_prev, 0, height_in - 1); + y_next = clamp(y_next, 0, height_in - 1); + + const float Dy_next = (float)y_next - y_in; + const float Dy_prev = 1.f - Dy_next; + const float Dx_next = (float)x_next - x_in; + const float Dx_prev = 1.f - Dx_next; + + const float Q_NW = Areadsingle(in, x_prev, y_prev); + const float Q_NE = Areadsingle(in, x_next, y_prev); + const float Q_SE = Areadsingle(in, x_next, y_next); + const float Q_SW = Areadsingle(in, x_prev, y_next); + + const float result = Dy_prev * (Q_SW * Dx_next + Q_SE * Dx_prev) + + Dy_next * (Q_NW * Dx_next + Q_NE * Dx_prev); + + write_imagef(out, (int2)(x, y), result); +} + + +// posterize `in` in log2 space; sampling == 0 is a no-op copy, sampling == 1 +// is the fast per-stop track, anything else uses the general formula. +// Mirrors quantize() in fast_guided_filter.h. +kernel void +fastguided_quantize(read_only image2d_t in, write_only image2d_t out, + const int width, const int height, + const float sampling, const float clip_min, const float clip_max) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + const float value = Areadsingle(in, x, y); + float result; + + if(sampling == 0.0f) + result = value; + else if(sampling == 1.0f) + result = clamp(exp2(floor(log2(value))), clip_min, clip_max); + else + result = clamp(exp2(floor(log2(value) / sampling) * sampling), clip_min, clip_max); + + write_imagef(out, (int2)(x, y), result); +} + + +// per-pixel g*g and g*p, ready for box-blurring. Mirrors the packing step in +// variance_analyse() (the guide/mask/guide^2/guide*mask struct), split into +// two single-channel outputs since the box-blur passes are single-channel. +kernel void +fastguided_covar_products(read_only image2d_t guide, read_only image2d_t signal, + write_only image2d_t guide_sq, write_only image2d_t guide_signal, + const int width, const int height) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + const float g = Areadsingle(guide, x, y); + const float p = Areadsingle(signal, x, y); + + write_imagef(guide_sq, (int2)(x, y), g * g); + write_imagef(guide_signal, (int2)(x, y), g * p); +} + + +// solve the linear blending parameters a, b such that p ~= a*g + b, from the +// box-blurred means of g, p, g*g and g*p. Mirrors the blending step at the +// end of variance_analyse(). +kernel void +fastguided_solve_ab(read_only image2d_t mean_g, read_only image2d_t mean_p, + read_only image2d_t mean_gg, read_only image2d_t mean_gp, + write_only image2d_t a_out, write_only image2d_t b_out, + const int width, const int height, + const float feathering) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + const float g = Areadsingle(mean_g, x, y); + const float p = Areadsingle(mean_p, x, y); + const float gg = Areadsingle(mean_gg, x, y); + const float gp = Areadsingle(mean_gp, x, y); + + const float d = fmax((gg - g * g) + feathering, 1e-15f); + const float a = (gp - g * p) / d; + const float b = p - a * g; + + write_imagef(a_out, (int2)(x, y), a); + write_imagef(b_out, (int2)(x, y), b); +} + + +// image = max(image * a + b, MIN_FLOAT), in-place capable (no neighbour +// access). Used both for the intermediate per-iteration update of the +// downsampled image and for the final full-resolution blend. Mirrors +// apply_linear_blending() (DT_GF_BLENDING_LINEAR case only -- the geomean +// blending mode is not ported here since satcurvergb only ever requests +// DT_GF_BLENDING_LINEAR). +kernel void +fastguided_apply_blend(read_only image2d_t image_in, write_only image2d_t image_out, + read_only image2d_t a_img, read_only image2d_t b_img, + const int width, const int height) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + const float value = Areadsingle(image_in, x, y); + const float a = Areadsingle(a_img, x, y); + const float b = Areadsingle(b_img, x, y); + + write_imagef(image_out, (int2)(x, y), fmax(value * a + b, FGF_MIN_FLOAT)); +} + + +// final step specific to satcurvergb: blend the already-corrected RGB pixel +// back towards the untouched input RGB, using the filtered scalar mask as +// per-pixel strength. Mirrors the `if(gf_mask)` branch inside the CPU +// process() loop: pixout[c] = rgb[c] + w * (pixout[c] - rgb[c]). +kernel void +satcurve_apply_guided_mask(read_only image2d_t rgb_in, read_only image2d_t corrected, + read_only image2d_t mask, write_only image2d_t out, + const int width, const int height) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + const float4 rgb = Areadpixel(rgb_in, x, y); + const float4 pixout = Areadpixel(corrected, x, y); + const float w = clamp(Areadsingle(mask, x, y), 0.f, 1.f); + + float4 result = rgb + w * (pixout - rgb); + result.w = pixout.w; + + write_imagef(out, (int2)(x, y), result); +} + +// 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 diff --git a/data/kernels/programs.conf b/data/kernels/programs.conf index 16b3addde72d..4d24747de157 100644 --- a/data/kernels/programs.conf +++ b/data/kernels/programs.conf @@ -42,4 +42,5 @@ capture.cl 38 agx.cl 39 colorharmonizer.cl 40 overlay.cl 41 -satcurve.cl 42 \ No newline at end of file +satcurve.cl 42 +fast_guided_filter.cl 43 diff --git a/data/kernels/satcurve.cl b/data/kernels/satcurve.cl index d9b47b56897a..b778b43f44ad 100755 --- a/data/kernels/satcurve.cl +++ b/data/kernels/satcurve.cl @@ -274,5 +274,50 @@ satcurve_mask(read_only image2d_t in, write_only image2d_t out, const float intensity = sqrt(clamp(s_in_norm, 0.f, 1.f)); float4 pixout = { intensity, intensity, intensity, pix_in.w }; + write_imagef(out, (int2)(x, y), pixout); +} + +// scalar (single-channel) saturation mask, feeding the guided filter pipeline +// in fast_guided_filter_scalar.cl. Mirrors compute_saturation_mask() on the +// CPU side: MAX(s_in_norm, 0.f), written into a single-channel buffer instead +// of the 3-channel visualisation that satcurve_mask above produces. +kernel void +satcurve_scalar_mask(read_only image2d_t in, write_only image2d_t out, + const int width, const int height, + constant const float *const matrix_in, + global const float *const gamut_lut, + const int formula, const float L_white) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + const float4 pix_in = Areadpixel(in, x, y); + const float s_in_norm = satcurve_s_in_norm_cl(pix_in, matrix_in, gamut_lut, formula, L_white); + + write_imagef(out, (int2)(x, y), fmax(s_in_norm, 0.f)); +} + +// broadcasts a single-channel (guided-filtered) mask into an RGB visualisation, +// alpha taken from the original input. Used for the mask_display branch when +// the guided filter is enabled, so the preview shows the mask that is +// actually used to modulate the correction. Mirrors display_saturation_mask() +// on the CPU side, but reading a pre-computed scalar buffer instead of +// recomputing s_in_norm. +kernel void +satcurve_mask_from_scalar(read_only image2d_t scalar_mask, read_only image2d_t in, + write_only image2d_t out, + const int width, const int height) +{ + const int x = get_global_id(0); + const int y = get_global_id(1); + if(x >= width || y >= height) return; + + const float mask_value = Areadsingle(scalar_mask, x, y); + const float4 pix_in = Areadpixel(in, x, y); + + const float intensity = sqrt(clamp(mask_value, 0.f, 1.f)); + float4 pixout = { intensity, intensity, intensity, pix_in.w }; + write_imagef(out, (int2)(x, y), pixout); } \ No newline at end of file diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c old mode 100644 new mode 100755 index bcf9f43094e2..67684e4bfdf2 --- a/src/iop/satcurvergb.c +++ b/src/iop/satcurvergb.c @@ -19,12 +19,15 @@ #include "common/color_picker.h" #include "common/darktable_ucs_22_helpers.h" #include "common/dtpthread.h" +#include "common/fast_guided_filter.h" +#include "common/guided_filter.h" #include "common/gamut_mapping.h" #include "common/imagebuf.h" #include "common/math.h" #include "develop/imageop.h" #include "develop/imageop_gui.h" #include "develop/openmp_maths.h" +#include "develop/tiling.h" #include "dtgtk/button.h" #include "dtgtk/drawingarea.h" #include "gui/color_picker_proxy.h" @@ -43,7 +46,7 @@ #define DT_IOP_SATCURVE_GAMUT_STEPS 92 #define DT_IOP_SATCURVE_CHANNELS 2 -DT_MODULE_INTROSPECTION(2, dt_iop_satcurve_params_t) +DT_MODULE_INTROSPECTION(1, dt_iop_satcurve_params_t) typedef enum dt_iop_satcurve_formula_t { @@ -73,15 +76,14 @@ typedef struct dt_iop_satcurve_params_t { dt_iop_satcurve_channel_params_t channel[DT_IOP_SATCURVE_CHANNELS]; dt_iop_satcurve_formula_t formula; // $DEFAULT: 1 $DESCRIPTION: "saturation formula" -} dt_iop_satcurve_params_t; -typedef struct dt_iop_satcurve_params_v1_t -{ - dt_iop_satcurve_node_t curve[DT_IOP_SATCURVE_MAXNODES]; - int curve_num_nodes; - int curve_type; - dt_iop_satcurve_formula_t formula; -} dt_iop_satcurve_params_v1_t; + // fast scalar guided filter controls + gboolean use_guided_filter; // $DEFAULT: FALSE $DESCRIPTION: "use guided filter" + float gf_radius; // $MIN: 0.5 $MAX: 200.0 $DEFAULT: 10.0 $DESCRIPTION: "filter radius" + float gf_feathering; // $MIN: 0.1 $MAX: 50.0 $DEFAULT: 1.0 $DESCRIPTION: "edge feathering" + int gf_iterations; // $MIN: 1 $MAX: 10 $DEFAULT: 1 $DESCRIPTION: "iterations" + float gf_quantization; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "mask quantization" +} dt_iop_satcurve_params_t; typedef struct dt_iop_satcurve_channel_data_t { @@ -98,6 +100,12 @@ typedef struct dt_iop_satcurve_data_t float *gamut_lut; gboolean lut_inited; const struct dt_iop_order_iccprofile_info_t *work_profile; + + gboolean use_guided_filter; + float gf_radius; + float gf_feathering; + int gf_iterations; + float gf_quantization; } dt_iop_satcurve_data_t; typedef struct dt_iop_satcurve_gui_channel_t @@ -113,9 +121,16 @@ typedef struct dt_iop_satcurve_gui_data_t GtkDrawingArea *area; GtkWidget *colorpicker; GtkWidget *formula; - GtkWidget *show_saturation_mask; // NEU + GtkWidget *show_saturation_mask; GtkNotebook *notebook; + GtkWidget *use_guided_filter; + GtkWidget *gf_radius; + GtkWidget *gf_feathering; + GtkWidget *gf_iterations; + GtkWidget *gf_quantization; + dt_gui_collapsible_section_t gf_section; + dt_iop_satcurve_gui_channel_t channel[DT_IOP_SATCURVE_CHANNELS]; dt_iop_satcurve_channel_t active_channel; int selected; @@ -130,14 +145,22 @@ typedef struct dt_iop_satcurve_gui_data_t float picked_s_min; float picked_s_max; - gboolean mask_display; // NEU + gboolean mask_display; // NEU } dt_iop_satcurve_gui_data_t; typedef struct dt_iop_satcurve_global_data_t { int kernel_satcurvergb; int kernel_satcurve_histogram; - int kernel_satcurve_mask; // NEU + int kernel_satcurve_mask; // NEU + int kernel_satcurve_scalar_mask; + int kernel_satcurve_mask_from_scalar; + int kernel_satcurve_apply_guided_mask; + int kernel_fgf_resample; + int kernel_fgf_quantize; + int kernel_fgf_covar_products; + int kernel_fgf_solve_ab; + int kernel_fgf_apply_blend; } dt_iop_satcurve_global_data_t; typedef struct dt_iop_satcurve_factors_t @@ -159,10 +182,10 @@ const char *aliases() const char **description(dt_iop_module_t *self) { return dt_iop_set_description(self, _("remap saturation and brilliance with curves"), - _("corrective or creative"), - _("linear, RGB, scene-referred"), - _("linear, RGB, scene-referred"), - _("linear, RGB, scene-referred")); + _("corrective or creative"), + _("linear, RGB, scene-referred"), + _("linear, RGB, scene-referred"), + _("linear, RGB, scene-referred")); } int flags() @@ -176,8 +199,8 @@ int default_group() } dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, - dt_dev_pixelpipe_t *pipe, - dt_dev_pixelpipe_iop_t *piece) + dt_dev_pixelpipe_t *pipe, + dt_dev_pixelpipe_iop_t *piece) { return IOP_CS_RGB; } @@ -186,8 +209,8 @@ static inline void reset_channel_curve(dt_iop_satcurve_channel_params_t *c) { c->curve_num_nodes = 2; c->curve_type = MONOTONE_HERMITE; - c->curve[0] = (dt_iop_satcurve_node_t){ 0.f, .5f }; - c->curve[1] = (dt_iop_satcurve_node_t){ 1.f, .5f }; + c->curve[0] = (dt_iop_satcurve_node_t){0.f, .5f}; + c->curve[1] = (dt_iop_satcurve_node_t){1.f, .5f}; } static inline void reset_params(dt_iop_satcurve_params_t *p) @@ -195,6 +218,12 @@ static inline void reset_params(dt_iop_satcurve_params_t *p) reset_channel_curve(&p->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); reset_channel_curve(&p->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); p->formula = DT_IOP_SATCURVE_DTUCS; + + p->use_guided_filter = FALSE; + p->gf_radius = 10.0f; + p->gf_feathering = 1.0f; + p->gf_iterations = 1; + p->gf_quantization = 0.0f; } static inline float lookup_lut(const float *lut, const float x) @@ -220,10 +249,12 @@ static inline float satcurve_lookup_gamut(const float *const gamut_lut, const fl static inline float satcurve_soft_clip(const float x, const float knee, const float maximum) { - if(x <= knee) return x; + if (x <= knee) + return x; const float range = maximum - knee; - if(range <= 0.f) return maximum; + if (range <= 0.f) + return maximum; return knee + range * (1.f - expf(-(x - knee) / range)); } @@ -236,26 +267,28 @@ static inline float clip_jz_chroma(const float Jz, const float Cz, const float c Iz = MAX(Iz, 0.f); static const dt_colormatrix_t AI_trans = { - { 1.f, 1.f, 1.f, 0.f }, - { .1386050432715393f, -.1386050432715393f, -.0960192420263190f, 0.f }, - { .0580473161561189f, -.0580473161561189f, -.8118918960560390f, 0.f } - }; + {1.f, 1.f, 1.f, 0.f}, + {.1386050432715393f, -.1386050432715393f, -.0960192420263190f, 0.f}, + {.0580473161561189f, -.0580473161561189f, -.8118918960560390f, 0.f}}; - dt_aligned_pixel_t izab = { Iz, Cz * ch, Cz * sh, 0.f }, lms; + dt_aligned_pixel_t izab = {Iz, Cz * ch, Cz * sh, 0.f}, lms; dt_apply_transposed_color_matrix(izab, AI_trans, lms); float max_c = Cz; - if(lms[0] < 0.f) max_c = MIN(max_c, -Iz / (AI_trans[1][0] * ch + AI_trans[2][0] * sh)); - if(lms[1] < 0.f) max_c = MIN(max_c, -Iz / (AI_trans[1][1] * ch + AI_trans[2][1] * sh)); - if(lms[2] < 0.f) max_c = MIN(max_c, -Iz / (AI_trans[1][2] * ch + AI_trans[2][2] * sh)); + if (lms[0] < 0.f) + max_c = MIN(max_c, -Iz / (AI_trans[1][0] * ch + AI_trans[2][0] * sh)); + if (lms[1] < 0.f) + max_c = MIN(max_c, -Iz / (AI_trans[1][1] * ch + AI_trans[2][1] * sh)); + if (lms[2] < 0.f) + max_c = MIN(max_c, -Iz / (AI_trans[1][2] * ch + AI_trans[2][2] * sh)); return MAX(max_c, 0.f); } static inline float pixel_s_in_norm_jzazbz(const float *const restrict rgb_in, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict gamut_lut, - float *const restrict h_out) + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + float *const restrict h_out) { dt_aligned_pixel_t rgb, xyz, jab; copy_pixel(rgb, rgb_in); @@ -269,7 +302,8 @@ static inline float pixel_s_in_norm_jzazbz(const float *const restrict rgb_in, const float gamut = MAX(satcurve_lookup_gamut(gamut_lut, h), FLT_MIN); const float s_in = Jz > 0.f ? Cz / Jz : 0.f; - if(h_out) *h_out = h; + if (h_out) + *h_out = h; return s_in / gamut; } @@ -278,16 +312,13 @@ static inline float pixel_s_in_norm_jzazbz(const float *const restrict rgb_in, // model used throughout darktable_ucs_22_helpers. Single source of truth for this // computation -- do not re-derive it inline at call sites. static inline float satcurve_ucs_gamut_saturation(const float J, const float h, - const float L_white, - const float *const restrict gamut_lut) + const float L_white, + const float *const restrict gamut_lut) { const float max_colorfulness = MAX(satcurve_lookup_gamut(gamut_lut, h), FLT_MIN); - const float max_chroma = 15.932993652962535f - * powf(J / L_white, 0.6523997524738018f) - * powf(max_colorfulness, 0.6007557017508491f) - / L_white; + const float max_chroma = 15.932993652962535f * powf(J / L_white, 0.6523997524738018f) * powf(max_colorfulness, 0.6007557017508491f) / L_white; - const dt_aligned_pixel_t JCH_gamut_boundary = { J, max_chroma, h, 0.f }; + const dt_aligned_pixel_t JCH_gamut_boundary = {J, max_chroma, h, 0.f}; dt_aligned_pixel_t HSB_gamut_boundary; dt_UCS_JCH_to_HSB(JCH_gamut_boundary, HSB_gamut_boundary); @@ -295,10 +326,10 @@ static inline float satcurve_ucs_gamut_saturation(const float J, const float h, } static inline float pixel_s_in_norm_ucs(const float *const restrict rgb_in, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict gamut_lut, - const float L_white, - float *const restrict h_out) + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + const float L_white, + float *const restrict h_out) { dt_aligned_pixel_t rgb, xyz, xyY, JCH, HCB; @@ -312,20 +343,21 @@ static inline float pixel_s_in_norm_ucs(const float *const restrict rgb_in, const float gamut_s = satcurve_ucs_gamut_saturation(JCH[0], JCH[2], L_white, gamut_lut); - if(h_out) *h_out = JCH[2]; + if (h_out) + *h_out = JCH[2]; const float saturation = HCB[2] > 0.f ? HCB[1] / HCB[2] : 0.f; return saturation / gamut_s; } static inline float pixel_s_in_norm(const dt_iop_satcurve_formula_t formula, - const float *const restrict rgb_in, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict gamut_lut, - const float L_white, - float *const restrict h_out) + const float *const restrict rgb_in, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict gamut_lut, + const float L_white, + float *const restrict h_out) { - if(formula == DT_IOP_SATCURVE_DTUCS) + if (formula == DT_IOP_SATCURVE_DTUCS) return pixel_s_in_norm_ucs(rgb_in, inputmatrix_trans, gamut_lut, L_white, h_out); return pixel_s_in_norm_jzazbz(rgb_in, inputmatrix_trans, gamut_lut, h_out); @@ -346,21 +378,18 @@ static inline dt_iop_satcurve_gui_channel_t *get_active_gui_channel(dt_iop_modul static inline gboolean channel_is_neutral(const dt_iop_satcurve_channel_data_t *c) { - return c->curve_num_nodes == 2 - && fabsf(c->lut[0] - .5f) < 1e-6f - && fabsf(c->lut[DT_IOP_SATCURVE_RES - 1] - .5f) < 1e-6f; + return c->curve_num_nodes == 2 && fabsf(c->lut[0] - .5f) < 1e-6f && fabsf(c->lut[DT_IOP_SATCURVE_RES - 1] - .5f) < 1e-6f; } static inline dt_iop_satcurve_factors_t eval_curve_factors(const dt_iop_satcurve_data_t *d, - const float s_in_norm) + const float s_in_norm) { const float sat_c = CLAMP(lookup_lut(d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].lut, s_in_norm), 0.f, 1.f); const float bri_c = CLAMP(lookup_lut(d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].lut, s_in_norm), 0.f, 1.f); return (dt_iop_satcurve_factors_t){ - .sat_factor = curve_to_factor(sat_c), - .bri_factor = curve_to_factor(bri_c) - }; + .sat_factor = curve_to_factor(sat_c), + .bri_factor = curve_to_factor(bri_c)}; } // applies log1p compression and publishes bin counts to the GUI histogram; @@ -369,11 +398,12 @@ static inline dt_iop_satcurve_factors_t eval_curve_factors(const dt_iop_satcurve static void _commit_sat_histogram(dt_iop_module_t *self, const int *const bins) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - if(!g) return; + if (!g) + return; float local_hist[DT_IOP_SATCURVE_HIST_RES]; float max_val = 0.f; - for(int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) + for (int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) { local_hist[i] = log1pf((float)bins[i]); max_val = MAX(max_val, local_hist[i]); @@ -388,23 +418,24 @@ static void _commit_sat_histogram(dt_iop_module_t *self, const int *const bins) } static void _update_sat_histogram(dt_iop_module_t *self, - const dt_iop_satcurve_data_t *d, - const dt_colormatrix_t inputmatrix_trans, - const float *const restrict in, - const size_t npixels) + const dt_iop_satcurve_data_t *d, + const dt_colormatrix_t inputmatrix_trans, + const float *const restrict in, + const size_t npixels) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - if(!g) return; + if (!g) + return; - int local_hist[DT_IOP_SATCURVE_HIST_RES] = { 0 }; + int local_hist[DT_IOP_SATCURVE_HIST_RES] = {0}; const float L_white = Y_to_dt_UCS_L_star(1.f); DT_OMP_FOR(reduction(+ : local_hist[:DT_IOP_SATCURVE_HIST_RES])) - for(size_t k = 0; k < npixels; k++) + for (size_t k = 0; k < npixels; k++) { const float s_in_norm = pixel_s_in_norm(d->formula, in + 4 * k, inputmatrix_trans, d->gamut_lut, L_white, NULL); const int bin = CLAMP((int)(s_in_norm * (DT_IOP_SATCURVE_HIST_RES - 1)), - 0, DT_IOP_SATCURVE_HIST_RES - 1); + 0, DT_IOP_SATCURVE_HIST_RES - 1); local_hist[bin]++; } @@ -412,8 +443,8 @@ static void _update_sat_histogram(dt_iop_module_t *self, } static inline void apply_sat_and_brilliance_ucs(const dt_iop_satcurve_data_t *d, - dt_aligned_pixel_t xyz, - const float L_white) + dt_aligned_pixel_t xyz, + const float L_white) { dt_aligned_pixel_t xyY, JCH, HCB, HSB; @@ -457,7 +488,7 @@ static inline void apply_sat_and_brilliance_ucs(const dt_iop_satcurve_data_t *d, } static inline void apply_sat_and_brilliance_jzazbz(const dt_iop_satcurve_data_t *d, - dt_aligned_pixel_t xyz) + dt_aligned_pixel_t xyz) { dt_aligned_pixel_t jab; dt_XYZ_2_JzAzBz(xyz, jab); @@ -492,29 +523,29 @@ static inline void apply_sat_and_brilliance_jzazbz(const dt_iop_satcurve_data_t // NEU: berechnet pro Pixel die normierte Sättigung in einen separaten Buffer, // analog zu compute_luminance_mask() in toneequal.c static inline void compute_saturation_mask(const dt_iop_satcurve_data_t *d, - const dt_colormatrix_t inputmatrix_trans, - const float L_white, - const float *const restrict in, - float *const restrict mask, - const size_t npixels) + const dt_colormatrix_t inputmatrix_trans, + const float L_white, + const float *const restrict in, + float *const restrict mask, + const size_t npixels) { DT_OMP_FOR() - for(size_t k = 0; k < npixels; k++) + for (size_t k = 0; k < npixels; k++) { mask[k] = pixel_s_in_norm(d->formula, in + 4 * k, inputmatrix_trans, - d->gamut_lut, L_white, NULL); + d->gamut_lut, L_white, NULL); } } // NEU: Graustufen-Visualisierung der Sättigungsmaske; sqrt-"Gamma" für bessere // Sichtbarkeit niedriger Werte, analog display_luminance_mask() in toneequal.c static inline void display_saturation_mask(const float *const restrict in, - const float *const restrict mask, - float *const restrict out, - const size_t npixels) + const float *const restrict mask, + float *const restrict out, + const size_t npixels) { DT_OMP_FOR() - for(size_t k = 0; k < npixels; k++) + for (size_t k = 0; k < npixels; k++) { const float intensity = sqrtf(CLAMP(mask[k], 0.f, 1.f)); out[4 * k + 0] = intensity; @@ -524,6 +555,30 @@ static inline void display_saturation_mask(const float *const restrict in, } } +// Applies the fast scalar guided filter (edge-aware surface blur) in-place on a +// saturation mask, using the guided-filter parameters cached in d. No-op if +// use_guided_filter is off or the parameters are degenerate. +static inline void apply_guided_filter_to_mask(const dt_iop_satcurve_data_t *d, + float *const restrict mask, + const int width, + const int height) +{ + if (!d->use_guided_filter || d->gf_radius < 0.5f || d->gf_iterations <= 0) + return; + + fast_surface_blur(mask, + width, + height, + (int)d->gf_radius, + d->gf_feathering, + d->gf_iterations, + DT_GF_BLENDING_LINEAR, + 1.0f, + d->gf_quantization, + exp2f(-14.0f), + 4.0f); +} + void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *const ivoid, void *const ovoid, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) @@ -532,14 +587,14 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, dt_iop_satcurve_gui_data_t *g = self->gui_data; const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); - if(!work_profile || piece->colors != 4) + if (!work_profile || piece->colors != 4) { dt_iop_image_copy_by_size(ovoid, ivoid, roi_out->width, roi_out->height, piece->colors); return; } - dt_colormatrix_t inputmatrix = {{ 0.0f }}; - dt_colormatrix_t outputmatrix = {{ 0.0f }}; + dt_colormatrix_t inputmatrix = {{0.0f}}; + dt_colormatrix_t outputmatrix = {{0.0f}}; dt_colormatrix_t inputmatrix_trans; dt_colormatrix_t outputmatrix_trans; @@ -553,20 +608,25 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const size_t npixels = (size_t)roi_out->width * roi_out->height; const float L_white = Y_to_dt_UCS_L_star(1.f); - if(self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) - && piece->pipe == self->dev->full.pipe) + if (self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) && piece->pipe == self->dev->full.pipe) _update_sat_histogram(self, d, inputmatrix_trans, in, npixels); // NEU: Maskenanzeige-Zweig -- zeigt die normierte Sättigung als Graustufenbild - if(self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && g && g->mask_display) + if (self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && g && g->mask_display) { float *const restrict mask = dt_alloc_align_float(npixels); - if(mask) + if (mask) { compute_saturation_mask(d, inputmatrix_trans, L_white, in, mask, npixels); + + // if the guided filter is enabled, show the filtered mask that is + // actually used to modulate the correction, not the raw saturation + apply_guided_filter_to_mask(d, mask, roi_out->width, roi_out->height); + display_saturation_mask(in, mask, out, npixels); dt_free_align(mask); piece->pipe->mask_display = DT_DEV_PIXELPIPE_DISPLAY_PASSTHRU; + return; } } @@ -574,21 +634,40 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const gboolean neutral_sat = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); const gboolean neutral_bri = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); - if(neutral_sat && neutral_bri) + if (neutral_sat && neutral_bri) { dt_iop_image_copy_by_size(ovoid, ivoid, roi_out->width, roi_out->height, piece->colors); return; } + // Guided-filter strength mask, derived from normalized input saturation and + // edge-aware blurred, so that the correction fades smoothly across saturation + // edges instead of being applied uniformly or with hard transitions. + float *restrict gf_mask = NULL; + + if (d->use_guided_filter && d->gf_radius >= 0.5f && d->gf_iterations > 0) + { + gf_mask = dt_alloc_align_float(npixels); + if (gf_mask) + { + compute_saturation_mask(d, inputmatrix_trans, L_white, in, gf_mask, npixels); + apply_guided_filter_to_mask(d, gf_mask, roi_out->width, roi_out->height); + } + else + { + dt_control_log(_("satcurve: guided filter failed to allocate memory, disabling it for this run")); + } + } + DT_OMP_FOR() - for(size_t k = 0; k < npixels; k++) + for (size_t k = 0; k < npixels; k++) { dt_aligned_pixel_t rgb, xyz, pixout; copy_pixel(rgb, in + 4 * k); dt_vector_clipneg(rgb); dt_apply_transposed_color_matrix(rgb, inputmatrix_trans, xyz); - if(d->formula == DT_IOP_SATCURVE_DTUCS) + if (d->formula == DT_IOP_SATCURVE_DTUCS) apply_sat_and_brilliance_ucs(d, xyz, L_white); else apply_sat_and_brilliance_jzazbz(d, xyz); @@ -596,12 +675,251 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, dt_apply_transposed_color_matrix(xyz, outputmatrix_trans, pixout); dt_vector_clipneg(pixout); pixout[3] = in[4 * k + 3]; + + if (gf_mask) + { + // blend the corrected pixel back towards the untouched input, using the + // blurred saturation mask as per-pixel strength (1 = full correction) + const float w = CLAMP(gf_mask[k], 0.f, 1.f); + for (int c = 0; c < 3; c++) + pixout[c] = rgb[c] + w * (pixout[c] - rgb[c]); + } + copy_pixel_nontemporal(out + 4 * k, pixout); } dt_omploop_sfence(); + + if (gf_mask) + dt_free_align(gf_mask); } #if HAVE_OPENCL + +// box-blur a single-channel device buffer over a window of 2*radius+1, +// reusing the generic box-mean kernels from common/guided_filter.c (they are +// guide-agnostic single-channel passes, not RGB-specific -- see +// _cl_box_mean() in guided_filter.c, which this mirrors exactly). +static cl_int _cl_fgf_box_mean(const int devid, const int width, const int height, + const int radius, cl_mem in, cl_mem out, cl_mem temp) +{ + cl_int err = dt_opencl_enqueue_kernel_1d_args( + devid, darktable.opencl->guided_filter->kernel_guided_filter_box_mean_x, height, + CLARG(width), CLARG(height), CLARG(in), CLARG(temp), CLARG(radius)); + if (err != CL_SUCCESS) + return err; + + return dt_opencl_enqueue_kernel_1d_args( + devid, darktable.opencl->guided_filter->kernel_guided_filter_box_mean_y, width, + CLARG(width), CLARG(height), CLARG(temp), CLARG(out), CLARG(radius)); +} + +// GPU port of fast_surface_blur() from common/fast_guided_filter.h, operating +// on a full-resolution single-channel scalar mask. mask_in and mask_out must +// be distinct buffers (OpenCL images cannot safely be read and written by the +// same kernel invocation). Returns a non-CL_SUCCESS error on any failure, +// without touching mask_out, so the caller's own error handling (which in +// process_cl ultimately triggers darktable's per-module CPU fallback) applies +// unchanged. +// +// CAVEAT: unlike guided_filter.c's guided_filter_cl(), this does not tile the +// computation to stay within GPU memory limits for very large images -- see +// _guided_filter_cl_impl()'s tiling logic for the pattern that would need to +// be replicated here for full parity. +static cl_int _satcurve_guided_filter_cl(const dt_iop_satcurve_global_data_t *gd, + const int devid, + cl_mem mask_in, cl_mem mask_out, + const int width, const int height, + const int radius, const float feathering, + const int iterations, const float quantization) +{ + const float scaling = 4.0f; + const int ds_radius = (radius < 4) ? 1 : (int)(radius / scaling); + const int ds_width = (int)((float)width / scaling); + const int ds_height = (int)((float)height / scaling); + + if (ds_width < 1 || ds_height < 1) + return DT_OPENCL_DEFAULT_ERROR; + + cl_mem ds_image = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem ds_blend_tmp = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem ds_g = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem ds_gg = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem ds_gp = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem mean_g = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem mean_p = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem mean_gg = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem mean_gp = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem a = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem b = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem temp_ds = dt_opencl_alloc_device(devid, ds_width, ds_height, sizeof(float)); + cl_mem a_full = dt_opencl_alloc_device(devid, width, height, sizeof(float)); + cl_mem b_full = dt_opencl_alloc_device(devid, width, height, sizeof(float)); + + cl_int err = CL_MEM_OBJECT_ALLOCATION_FAILURE; + if (!ds_image || !ds_blend_tmp || !ds_g || !ds_gg || !ds_gp || !mean_g || !mean_p || !mean_gg || !mean_gp || !a || !b || !temp_ds || !a_full || !b_full) + goto cleanup; + + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_fgf_resample, ds_width, ds_height, + CLARG(mask_in), CLARG(width), CLARG(height), + CLARG(ds_image), CLARG(ds_width), CLARG(ds_height)); + if (err != CL_SUCCESS) + goto cleanup; + + for (int i = 0; i < iterations; i++) + { + const float qmin = exp2f(-14.0f); + const float qmax = 4.0f; + + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_fgf_quantize, ds_width, ds_height, + CLARG(ds_image), CLARG(ds_g), CLARG(ds_width), CLARG(ds_height), + CLARG(quantization), CLARG(qmin), CLARG(qmax)); + if (err != CL_SUCCESS) + goto cleanup; + + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_fgf_covar_products, ds_width, ds_height, + CLARG(ds_g), CLARG(ds_image), CLARG(ds_gg), CLARG(ds_gp), + CLARG(ds_width), CLARG(ds_height)); + if (err != CL_SUCCESS) + goto cleanup; + + if ((err = _cl_fgf_box_mean(devid, ds_width, ds_height, ds_radius, ds_g, mean_g, temp_ds)) != CL_SUCCESS) + goto cleanup; + if ((err = _cl_fgf_box_mean(devid, ds_width, ds_height, ds_radius, ds_image, mean_p, temp_ds)) != CL_SUCCESS) + goto cleanup; + if ((err = _cl_fgf_box_mean(devid, ds_width, ds_height, ds_radius, ds_gg, mean_gg, temp_ds)) != CL_SUCCESS) + goto cleanup; + if ((err = _cl_fgf_box_mean(devid, ds_width, ds_height, ds_radius, ds_gp, mean_gp, temp_ds)) != CL_SUCCESS) + goto cleanup; + + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_fgf_solve_ab, ds_width, ds_height, + CLARG(mean_g), CLARG(mean_p), CLARG(mean_gg), CLARG(mean_gp), + CLARG(a), CLARG(b), CLARG(ds_width), CLARG(ds_height), CLARG(feathering)); + if (err != CL_SUCCESS) + goto cleanup; + + if ((err = _cl_fgf_box_mean(devid, ds_width, ds_height, ds_radius, a, a, temp_ds)) != CL_SUCCESS) + goto cleanup; + if ((err = _cl_fgf_box_mean(devid, ds_width, ds_height, ds_radius, b, b, temp_ds)) != CL_SUCCESS) + goto cleanup; + + if (i != iterations - 1) + { + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_fgf_apply_blend, ds_width, ds_height, + CLARG(ds_image), CLARG(ds_blend_tmp), CLARG(a), CLARG(b), + CLARG(ds_width), CLARG(ds_height)); + if (err != CL_SUCCESS) + goto cleanup; + + const cl_mem swap = ds_image; + ds_image = ds_blend_tmp; + ds_blend_tmp = swap; + } + } + + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_fgf_resample, width, height, + CLARG(a), CLARG(ds_width), CLARG(ds_height), + CLARG(a_full), CLARG(width), CLARG(height)); + if (err != CL_SUCCESS) + goto cleanup; + + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_fgf_resample, width, height, + CLARG(b), CLARG(ds_width), CLARG(ds_height), + CLARG(b_full), CLARG(width), CLARG(height)); + if (err != CL_SUCCESS) + goto cleanup; + + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_fgf_apply_blend, width, height, + CLARG(mask_in), CLARG(mask_out), CLARG(a_full), CLARG(b_full), + CLARG(width), CLARG(height)); + +cleanup: + dt_opencl_release_mem_object(ds_image); + dt_opencl_release_mem_object(ds_blend_tmp); + dt_opencl_release_mem_object(ds_g); + dt_opencl_release_mem_object(ds_gg); + dt_opencl_release_mem_object(ds_gp); + dt_opencl_release_mem_object(mean_g); + dt_opencl_release_mem_object(mean_p); + dt_opencl_release_mem_object(mean_gg); + dt_opencl_release_mem_object(mean_gp); + dt_opencl_release_mem_object(a); + dt_opencl_release_mem_object(b); + dt_opencl_release_mem_object(temp_ds); + dt_opencl_release_mem_object(a_full); + dt_opencl_release_mem_object(b_full); + return err; +} + +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, + struct dt_develop_tiling_t *tiling) +{ + dt_iop_satcurve_data_t *d = piece->data; + + const float ioratio = (float)roi_out->width * roi_out->height / ((float)roi_in->width * roi_in->height); + + const gboolean gf_active = d->use_guided_filter && d->gf_radius >= 0.5f && d->gf_iterations > 0; + + // default pixel-to-pixel case: input + output + tiling->factor = 1.0f + ioratio; + tiling->factor_cl = tiling->factor; + tiling->maxbuf = 1.0f; + tiling->maxbuf_cl = 1.0f; + tiling->overhead = 0; + tiling->overlap = 0; + tiling->align = 1; + + if (!gf_active) + return; + + // Additional OpenCL allocations in process_cl(): + // corrected_cl : full-res RGBA -> 1.00 + // mask_scalar_cl : full-res float -> 0.25 + // mask_filtered_cl: full-res float -> 0.25 + // + // Additional allocations in _satcurve_guided_filter_cl(): + // ds_image, ds_blend_tmp, ds_g, ds_gg, ds_gp, + // mean_g, mean_p, mean_gg, mean_gp, a, b, temp_ds : ds float, 12x -> 12/16 = 0.75 + // + // a_full, b_full : full-res float -> 0.25 + 0.25 + // + // total guided-filter extras: 1.00 + 0.25 + 0.25 + 0.75 + 0.25 + 0.25 = 2.75 + tiling->factor_cl += 2.75f; + + // largest single extra allocation is corrected_cl + tiling->maxbuf_cl = MAX(tiling->maxbuf_cl, 1.0f); + + // Guided-filter neighbourhood requirement: each iteration re-runs the + // box-blur chain (quantize -> covar -> mean -> solve -> mean(a,b) -> + // blend) on the *current* intermediate image, so the effective support + // of the filter grows roughly linearly with gf_iterations, not just with + // gf_radius. A single-iteration overlap of ceil(gf_radius) is only + // correct for gf_iterations == 1; for more iterations, information from + // outside the tile can propagate inward by an extra ~radius per + // iteration. Scale the overlap accordingly, and clamp to something + // sane so pathological radius/iteration combos don't request an + // absurd amount of overlap. + const int base_overlap = (int)ceilf(d->gf_radius); + const int scaled_overlap = base_overlap * d->gf_iterations; + + // upsampling from the downsampled (factor 4) working resolution can add + // up to ~1 extra full-res pixel of bilinear blur at tile edges per + // resample pass; fold in a small fixed margin to stay safe. + const int resample_margin = 2; + + tiling->overlap = MAX(tiling->overlap, scaled_overlap + resample_margin); +} + 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) @@ -611,16 +929,20 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const dt_iop_satcurve_global_data_t *gd = self->global_data; cl_int err = DT_OPENCL_DEFAULT_ERROR; - if(piece->colors != 4) return err; + if (piece->colors != 4) + return err; const dt_iop_order_iccprofile_info_t *const work_profile = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); - if(work_profile == NULL) return err; + if (work_profile == NULL) + return err; const int devid = piece->pipe->devid; const int width = roi_in->width; const int height = roi_in->height; + const gboolean gf_active = d->use_guided_filter && d->gf_radius >= 0.5f && d->gf_iterations > 0; + const gboolean want_mask = self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && g && g->mask_display; @@ -630,16 +952,19 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem bri_lut_cl = NULL; cl_mem gamut_lut_cl = NULL; cl_mem hist_bins_cl = NULL; + cl_mem mask_scalar_cl = NULL; + cl_mem mask_filtered_cl = NULL; + cl_mem corrected_cl = NULL; - dt_colormatrix_t input_matrix = {{ 0.0f }}; - dt_colormatrix_t output_matrix = {{ 0.0f }}; + dt_colormatrix_t input_matrix = {{0.0f}}; + dt_colormatrix_t output_matrix = {{0.0f}}; 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); input_matrix_cl = dt_opencl_copy_host_to_device_constant(devid, 12 * sizeof(float), input_matrix); gamut_lut_cl = dt_opencl_copy_host_to_device_constant(devid, LUT_ELEM * sizeof(float), d->gamut_lut); - if(input_matrix_cl == NULL || gamut_lut_cl == NULL) + if (input_matrix_cl == NULL || gamut_lut_cl == NULL) { err = CL_MEM_OBJECT_ALLOCATION_FAILURE; goto error; @@ -648,27 +973,64 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const float L_white = Y_to_dt_UCS_L_star(1.f); // NEU: Maskenanzeige-Pfad -- eigener, einfacherer Kernel statt satcurvergb - if(want_mask) + if (want_mask) { + if (!gf_active) + { + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_satcurve_mask, width, height, + CLARG(dev_in), CLARG(dev_out), + CLARG(width), CLARG(height), + CLARG(input_matrix_cl), CLARG(gamut_lut_cl), + CLARG(d->formula), CLARG(L_white)); + + if (err == CL_SUCCESS) + piece->pipe->mask_display = DT_DEV_PIXELPIPE_DISPLAY_PASSTHRU; + goto error; + } + + // guided filter enabled: show the actually-filtered mask, not the raw one + mask_scalar_cl = dt_opencl_alloc_device(devid, width, height, sizeof(float)); + mask_filtered_cl = dt_opencl_alloc_device(devid, width, height, sizeof(float)); + if (mask_scalar_cl == NULL || mask_filtered_cl == NULL) + { + err = CL_MEM_OBJECT_ALLOCATION_FAILURE; + goto error; + } + err = dt_opencl_enqueue_kernel_2d_args( - devid, gd->kernel_satcurve_mask, width, height, - CLARG(dev_in), CLARG(dev_out), + devid, gd->kernel_satcurve_scalar_mask, width, height, + CLARG(dev_in), CLARG(mask_scalar_cl), CLARG(width), CLARG(height), CLARG(input_matrix_cl), CLARG(gamut_lut_cl), CLARG(d->formula), CLARG(L_white)); + if (err != CL_SUCCESS) + goto error; + + err = _satcurve_guided_filter_cl(gd, devid, mask_scalar_cl, mask_filtered_cl, + width, height, (int)d->gf_radius, d->gf_feathering, + d->gf_iterations, d->gf_quantization); + if (err != CL_SUCCESS) + goto error; + + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_satcurve_mask_from_scalar, width, height, + CLARG(mask_filtered_cl), CLARG(dev_in), CLARG(dev_out), + CLARG(width), CLARG(height)); - if(err == CL_SUCCESS) piece->pipe->mask_display = DT_DEV_PIXELPIPE_DISPLAY_PASSTHRU; + if (err == CL_SUCCESS) + piece->pipe->mask_display = DT_DEV_PIXELPIPE_DISPLAY_PASSTHRU; goto error; } const gboolean neutral_sat = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION]); const gboolean neutral_bri = channel_is_neutral(&d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE]); - if(neutral_sat && neutral_bri) + if (neutral_sat && neutral_bri) { err = dt_opencl_enqueue_copy_image(piece->pipe->devid, dev_in, dev_out, - (size_t[]){ 0, 0, 0 }, - (size_t[]){ 0, 0, 0 }, - (size_t[]){ roi_in->width, roi_in->height, 1 }); + (size_t[]){0, 0, 0}, + (size_t[]){0, 0, 0}, + (size_t[]){roi_in->width, roi_in->height, 1}); goto error; } @@ -680,41 +1042,80 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, devid, DT_IOP_SATCURVE_RES * sizeof(float), d->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].lut); - if(output_matrix_cl == NULL || sat_lut_cl == NULL || bri_lut_cl == NULL) + if (output_matrix_cl == NULL || sat_lut_cl == NULL || bri_lut_cl == NULL) { err = CL_MEM_OBJECT_ALLOCATION_FAILURE; goto error; } const gboolean want_histogram = - self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) - && piece->pipe == self->dev->full.pipe; + self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) && piece->pipe == self->dev->full.pipe; + + cl_mem correction_target = dev_out; + + if (gf_active) + { + corrected_cl = dt_opencl_alloc_device(devid, width, height, sizeof(float) * 4); + mask_scalar_cl = dt_opencl_alloc_device(devid, width, height, sizeof(float)); + mask_filtered_cl = dt_opencl_alloc_device(devid, width, height, sizeof(float)); + if (corrected_cl == NULL || mask_scalar_cl == NULL || mask_filtered_cl == NULL) + { + err = CL_MEM_OBJECT_ALLOCATION_FAILURE; + goto error; + } + correction_target = corrected_cl; + } // pipeline-critical kernel goes first: the histogram below is pure UI // feedback and must never delay the actual image processing on the queue err = dt_opencl_enqueue_kernel_2d_args( devid, gd->kernel_satcurvergb, width, height, - CLARG(dev_in), CLARG(dev_out), + CLARG(dev_in), CLARG(correction_target), CLARG(width), CLARG(height), CLARG(input_matrix_cl), CLARG(output_matrix_cl), CLARG(sat_lut_cl), CLARG(bri_lut_cl), CLARG(gamut_lut_cl), CLARG(d->formula), CLARG(L_white)); - if(err != CL_SUCCESS) goto error; + if (err != CL_SUCCESS) + goto error; + + if (gf_active) + { + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_satcurve_scalar_mask, width, height, + CLARG(dev_in), CLARG(mask_scalar_cl), + CLARG(width), CLARG(height), + CLARG(input_matrix_cl), CLARG(gamut_lut_cl), + CLARG(d->formula), CLARG(L_white)); + if (err != CL_SUCCESS) + goto error; + + err = _satcurve_guided_filter_cl(gd, devid, mask_scalar_cl, mask_filtered_cl, + width, height, (int)d->gf_radius, d->gf_feathering, + d->gf_iterations, d->gf_quantization); + if (err != CL_SUCCESS) + goto error; + + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_satcurve_apply_guided_mask, width, height, + CLARG(dev_in), CLARG(corrected_cl), CLARG(mask_filtered_cl), CLARG(dev_out), + CLARG(width), CLARG(height)); + if (err != CL_SUCCESS) + goto error; + } // GPU-side histogram reduction: only DT_IOP_SATCURVE_HIST_RES ints cross // the PCIe bus, instead of the full width*height*4 floats of the old // copy-to-host approach. Reuses input_matrix_cl/gamut_lut_cl already // uploaded above -- no second matrix upload needed. - if(want_histogram) + if (want_histogram) { - int hist_bins_host[DT_IOP_SATCURVE_HIST_RES] = { 0 }; + int hist_bins_host[DT_IOP_SATCURVE_HIST_RES] = {0}; const size_t hist_bins_size = sizeof(int) * DT_IOP_SATCURVE_HIST_RES; hist_bins_cl = dt_opencl_alloc_device_buffer(devid, hist_bins_size); - if(hist_bins_cl - && dt_opencl_write_buffer_to_device(devid, hist_bins_host, hist_bins_cl, 0, - hist_bins_size, TRUE) == CL_SUCCESS) + if (hist_bins_cl && dt_opencl_write_buffer_to_device(devid, hist_bins_host, hist_bins_cl, 0, + hist_bins_size, TRUE) == CL_SUCCESS) { const cl_int hist_err = dt_opencl_enqueue_kernel_2d_args( devid, gd->kernel_satcurve_histogram, width, height, @@ -723,9 +1124,8 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, CLARG(d->formula), CLARG(L_white), CLARG(hist_bins_cl)); - if(hist_err == CL_SUCCESS - && dt_opencl_read_buffer_from_device(devid, hist_bins_host, hist_bins_cl, 0, - hist_bins_size, TRUE) == CL_SUCCESS) + if (hist_err == CL_SUCCESS && dt_opencl_read_buffer_from_device(devid, hist_bins_host, hist_bins_cl, 0, + hist_bins_size, TRUE) == CL_SUCCESS) _commit_sat_histogram(self, hist_bins_host); } } @@ -737,17 +1137,30 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, dt_opencl_release_mem_object(bri_lut_cl); dt_opencl_release_mem_object(gamut_lut_cl); dt_opencl_release_mem_object(hist_bins_cl); + dt_opencl_release_mem_object(mask_scalar_cl); + dt_opencl_release_mem_object(mask_filtered_cl); + dt_opencl_release_mem_object(corrected_cl); return err; } void init_global(dt_iop_module_so_t *self) { - const int program = 42; // satcurve.cl in programs.conf + const int program = 42; // satcurve.cl in programs.conf + const int program_fgf = 43; // fast_guided_filter_scalar.cl in programs.conf + dt_iop_satcurve_global_data_t *gd = malloc(sizeof(dt_iop_satcurve_global_data_t)); self->data = gd; gd->kernel_satcurvergb = dt_opencl_create_kernel(program, "satcurvergb"); gd->kernel_satcurve_histogram = dt_opencl_create_kernel(program, "satcurve_histogram"); gd->kernel_satcurve_mask = dt_opencl_create_kernel(program, "satcurve_mask"); // NEU + gd->kernel_satcurve_scalar_mask = dt_opencl_create_kernel(program, "satcurve_scalar_mask"); + gd->kernel_satcurve_mask_from_scalar = dt_opencl_create_kernel(program, "satcurve_mask_from_scalar"); + gd->kernel_satcurve_apply_guided_mask = dt_opencl_create_kernel(program_fgf, "satcurve_apply_guided_mask"); + gd->kernel_fgf_resample = dt_opencl_create_kernel(program_fgf, "fastguided_resample"); + gd->kernel_fgf_quantize = dt_opencl_create_kernel(program_fgf, "fastguided_quantize"); + gd->kernel_fgf_covar_products = dt_opencl_create_kernel(program_fgf, "fastguided_covar_products"); + gd->kernel_fgf_solve_ab = dt_opencl_create_kernel(program_fgf, "fastguided_solve_ab"); + gd->kernel_fgf_apply_blend = dt_opencl_create_kernel(program_fgf, "fastguided_apply_blend"); } void cleanup_global(dt_iop_module_so_t *self) @@ -756,6 +1169,14 @@ void cleanup_global(dt_iop_module_so_t *self) dt_opencl_free_kernel(gd->kernel_satcurvergb); dt_opencl_free_kernel(gd->kernel_satcurve_histogram); dt_opencl_free_kernel(gd->kernel_satcurve_mask); // NEU + dt_opencl_free_kernel(gd->kernel_satcurve_scalar_mask); + dt_opencl_free_kernel(gd->kernel_satcurve_mask_from_scalar); + dt_opencl_free_kernel(gd->kernel_satcurve_apply_guided_mask); + dt_opencl_free_kernel(gd->kernel_fgf_resample); + dt_opencl_free_kernel(gd->kernel_fgf_quantize); + dt_opencl_free_kernel(gd->kernel_fgf_covar_products); + dt_opencl_free_kernel(gd->kernel_fgf_solve_ab); + dt_opencl_free_kernel(gd->kernel_fgf_apply_blend); free(self->data); self->data = NULL; } @@ -765,7 +1186,7 @@ void init_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe { dt_iop_satcurve_data_t *d = dt_calloc_align_type(dt_iop_satcurve_data_t, 1); - for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + for (int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) { d->channel[ch].lut = dt_alloc_align_float(DT_IOP_SATCURVE_RES); d->channel[ch].curve = dt_draw_curve_new(0.f, 1.f, MONOTONE_HERMITE); @@ -782,11 +1203,13 @@ void init_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece) { dt_iop_satcurve_data_t *d = piece->data; - if(!d) return; + if (!d) + return; - for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + for (int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) { - if(d->channel[ch].curve) dt_draw_curve_destroy(d->channel[ch].curve); + if (d->channel[ch].curve) + dt_draw_curve_destroy(d->channel[ch].curve); dt_free_align(d->channel[ch].lut); } @@ -796,9 +1219,9 @@ void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelp } static void build_jzazbz_gamut_lut(dt_iop_satcurve_data_t *d, - const dt_iop_order_iccprofile_info_t *work_profile) + const dt_iop_order_iccprofile_info_t *work_profile) { - dt_colormatrix_t inputmatrix = {{ 0.0f }}; + dt_colormatrix_t inputmatrix = {{0.0f}}; dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); dt_colormatrix_t inputmatrix_trans; @@ -807,45 +1230,42 @@ static void build_jzazbz_gamut_lut(dt_iop_satcurve_data_t *d, float *sampler = dt_calloc_align_float(LUT_ELEM); DT_OMP_FOR(reduction(max : sampler[:LUT_ELEM]) collapse(3)) - for(int r = 0; r < DT_IOP_SATCURVE_GAMUT_STEPS; r++) - for(int g = 0; g < DT_IOP_SATCURVE_GAMUT_STEPS; g++) - for(int b = 0; b < DT_IOP_SATCURVE_GAMUT_STEPS; b++) + for (int r = 0; r < DT_IOP_SATCURVE_GAMUT_STEPS; r++) + for (int g = 0; g < DT_IOP_SATCURVE_GAMUT_STEPS; g++) + for (int b = 0; b < DT_IOP_SATCURVE_GAMUT_STEPS; b++) { const dt_aligned_pixel_t rgb = { - r / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), - g / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), - b / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), - 0.f - }; + r / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), + g / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), + b / (float)(DT_IOP_SATCURVE_GAMUT_STEPS - 1), + 0.f}; dt_aligned_pixel_t xyz, jab; dt_apply_transposed_color_matrix(rgb, inputmatrix_trans, xyz); dt_XYZ_2_JzAzBz(xyz, jab); - const float sat = jab[0] > 0.f ? dt_fast_hypotf(jab[1], jab[2]) / jab[0] : 0.f; + const float sat = jab[0] > 1e-6f ? dt_fast_hypotf(jab[1], jab[2]) / jab[0] : 0.f; int index = roundf((LUT_ELEM - 1) * (atan2f(jab[2], jab[1]) + M_PI_F) / DT_2PI_F); index = index < 0 ? LUT_ELEM - 1 : (index >= LUT_ELEM ? 0 : index); sampler[index] = MAX(sampler[index], sat); } - for(size_t i = 2; i < LUT_ELEM - 2; i++) + for (size_t i = 2; i < LUT_ELEM - 2; i++) d->gamut_lut[i] = (sampler[i - 2] + sampler[i - 1] + sampler[i] + sampler[i + 1] + sampler[i + 2]) / 5.f; d->gamut_lut[0] = (sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0] + sampler[1] + sampler[2]) / 5.f; d->gamut_lut[1] = (sampler[LUT_ELEM - 1] + sampler[0] + sampler[1] + sampler[2] + sampler[3]) / 5.f; - d->gamut_lut[LUT_ELEM - 2] = (sampler[LUT_ELEM - 4] + sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] - + sampler[LUT_ELEM - 1] + sampler[0]) / 5.f; - d->gamut_lut[LUT_ELEM - 1] = (sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] - + sampler[0] + sampler[1]) / 5.f; + d->gamut_lut[LUT_ELEM - 2] = (sampler[LUT_ELEM - 4] + sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0]) / 5.f; + d->gamut_lut[LUT_ELEM - 1] = (sampler[LUT_ELEM - 3] + sampler[LUT_ELEM - 2] + sampler[LUT_ELEM - 1] + sampler[0] + sampler[1]) / 5.f; dt_free_align(sampler); } static void build_gamut_lut(dt_iop_satcurve_data_t *d, - const dt_iop_order_iccprofile_info_t *work_profile) + const dt_iop_order_iccprofile_info_t *work_profile) { - if(d->formula == DT_IOP_SATCURVE_DTUCS) + if (d->formula == DT_IOP_SATCURVE_DTUCS) { - dt_colormatrix_t inputmatrix = {{ 0.0f }}; + dt_colormatrix_t inputmatrix = {{0.0f}}; dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); dt_UCS_22_build_gamut_LUT(inputmatrix, d->gamut_lut); } @@ -856,21 +1276,22 @@ static void build_gamut_lut(dt_iop_satcurve_data_t *d, } static void sync_channel_curve(dt_iop_satcurve_channel_data_t *dst, - const dt_iop_satcurve_channel_params_t *src) + const dt_iop_satcurve_channel_params_t *src) { - if(dst->curve_type != src->curve_type || dst->curve_num_nodes != src->curve_num_nodes) + if (dst->curve_type != src->curve_type || dst->curve_num_nodes != src->curve_num_nodes) { - if(dst->curve) dt_draw_curve_destroy(dst->curve); + if (dst->curve) + dt_draw_curve_destroy(dst->curve); dst->curve = dt_draw_curve_new(0.f, 1.f, src->curve_type); dst->curve_type = src->curve_type; dst->curve_num_nodes = src->curve_num_nodes; - for(int i = 0; i < src->curve_num_nodes; i++) + for (int i = 0; i < src->curve_num_nodes; i++) dt_draw_curve_add_point(dst->curve, src->curve[i].x, src->curve[i].y); } else { - for(int i = 0; i < src->curve_num_nodes; i++) + for (int i = 0; i < src->curve_num_nodes; i++) dt_draw_curve_set_point(dst->curve, i, src->curve[i].x, src->curve[i].y); } @@ -878,24 +1299,30 @@ static void sync_channel_curve(dt_iop_satcurve_channel_data_t *dst, } void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_t *pipe, - dt_dev_pixelpipe_iop_t *piece) + dt_dev_pixelpipe_iop_t *piece) { dt_iop_satcurve_data_t *d = piece->data; const dt_iop_satcurve_params_t *p = (const dt_iop_satcurve_params_t *)p1; - if(d->formula != p->formula) + if (d->formula != p->formula) { d->formula = p->formula; d->lut_inited = FALSE; } - for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + for (int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) sync_channel_curve(&d->channel[ch], &p->channel[ch]); + d->use_guided_filter = p->use_guided_filter; + d->gf_radius = p->gf_radius; + d->gf_feathering = p->gf_feathering; + d->gf_iterations = p->gf_iterations; + d->gf_quantization = p->gf_quantization; + const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_current_profile_info(self, piece->pipe); - if(work_profile && (!d->lut_inited || work_profile != d->work_profile)) + if (work_profile && (!d->lut_inited || work_profile != d->work_profile)) { build_gamut_lut(d, work_profile); d->work_profile = work_profile; @@ -903,83 +1330,56 @@ void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_ } } -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_satcurve_params_v1_t *o = old_params; - dt_iop_satcurve_params_t *n = calloc(1, sizeof(dt_iop_satcurve_params_t)); - - reset_params(n); - - n->formula = o->formula; - n->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve_num_nodes = o->curve_num_nodes; - n->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve_type = o->curve_type; - - for(int i = 0; i < o->curve_num_nodes; i++) - n->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[i] = o->curve[i]; - - *new_params = n; - *new_params_size = sizeof(dt_iop_satcurve_params_t); - *new_version = 2; - return 0; - } - return 1; -} - void init_presets(dt_iop_module_so_t *self) { - dt_iop_satcurve_params_t p = { 0 }; + dt_iop_satcurve_params_t p = {0}; reset_params(&p); dt_gui_presets_add_generic(_("neutral"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); reset_params(&p); p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[0].y = .60f; p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[1].y = .55f; dt_gui_presets_add_generic(_("gentle saturation"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); reset_params(&p); p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve_num_nodes = 3; - p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[1] = (dt_iop_satcurve_node_t){ .45f, .62f }; - p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[2] = (dt_iop_satcurve_node_t){ 1.f, .42f }; + p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[1] = (dt_iop_satcurve_node_t){.45f, .62f}; + p.channel[DT_IOP_SATCURVE_CHANNEL_SATURATION].curve[2] = (dt_iop_satcurve_node_t){1.f, .42f}; dt_gui_presets_add_generic(_("protect saturated colors"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); reset_params(&p); p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[0].y = .56f; p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[1].y = .54f; dt_gui_presets_add_generic(_("gentle brilliance"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); reset_params(&p); p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve_num_nodes = 3; - p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[1] = (dt_iop_satcurve_node_t){ .40f, .58f }; - p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[2] = (dt_iop_satcurve_node_t){ 1.f, .48f }; + p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[1] = (dt_iop_satcurve_node_t){.40f, .58f}; + p.channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE].curve[2] = (dt_iop_satcurve_node_t){1.f, .48f}; dt_gui_presets_add_generic(_("bright mids, protect extremes"), self->op, self->version(), - &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); + &p, sizeof(p), TRUE, DEVELOP_BLEND_CS_RGB_SCENE); } static inline int add_node_to_channel(dt_iop_satcurve_channel_params_t *c, const float x, const float y) { - if(c->curve_num_nodes >= DT_IOP_SATCURVE_MAXNODES) return -1; + if (c->curve_num_nodes >= DT_IOP_SATCURVE_MAXNODES) + return -1; int at = 0; - while(at < c->curve_num_nodes && c->curve[at].x < x) at++; + while (at < c->curve_num_nodes && c->curve[at].x < x) + at++; - if((at && x - c->curve[at - 1].x < DT_IOP_SATCURVE_MIN_X_DISTANCE) - || (at < c->curve_num_nodes && c->curve[at].x - x < DT_IOP_SATCURVE_MIN_X_DISTANCE)) + if ((at && x - c->curve[at - 1].x < DT_IOP_SATCURVE_MIN_X_DISTANCE) || (at < c->curve_num_nodes && c->curve[at].x - x < DT_IOP_SATCURVE_MIN_X_DISTANCE)) return -1; - for(int i = c->curve_num_nodes; i > at; i--) c->curve[i] = c->curve[i - 1]; - c->curve[at] = (dt_iop_satcurve_node_t){ x, y }; + for (int i = c->curve_num_nodes; i > at; i--) + c->curve[i] = c->curve[i - 1]; + c->curve[at] = (dt_iop_satcurve_node_t){x, y}; c->curve_num_nodes++; return at; } @@ -989,8 +1389,7 @@ static void _get_graph_geometry(const GtkAllocation *a, float *x0, float *y0, fl *x0 = DT_IOP_SATCURVE_INSET; *y0 = DT_IOP_SATCURVE_INSET; *w = MAX(1, a->width - 2 * DT_IOP_SATCURVE_INSET); - *h = MAX(1, a->height - 2 * DT_IOP_SATCURVE_INSET - DT_IOP_SATCURVE_GRADIENT_GAP - - DT_IOP_SATCURVE_GRADIENT_SIZE); + *h = MAX(1, a->height - 2 * DT_IOP_SATCURVE_INSET - DT_IOP_SATCURVE_GRADIENT_GAP - DT_IOP_SATCURVE_GRADIENT_SIZE); } static void _draw_sat_histogram(cairo_t *cr, dt_iop_satcurve_gui_data_t *g, const int w, const int h) @@ -1002,7 +1401,7 @@ static void _draw_sat_histogram(cairo_t *cr, dt_iop_satcurve_gui_data_t *g, cons cairo_set_source_rgba(cr, .8, .8, .8, .30); cairo_move_to(cr, 0, h); - for(int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) + for (int i = 0; i < DT_IOP_SATCURVE_HIST_RES; i++) { const float x = w * i / (float)(DT_IOP_SATCURVE_HIST_RES - 1); const float y = h * (1.f - g->histogram[i] / hist_max); @@ -1020,8 +1419,10 @@ static void _draw_sat_histogram(cairo_t *cr, dt_iop_satcurve_gui_data_t *g, cons static void _draw_sat_picker(cairo_t *cr, dt_iop_module_t *self, const int w, const int h) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - if(!g->picker_valid) return; - if(self->request_color_pick != DT_REQUEST_COLORPICK_MODULE) return; + if (!g->picker_valid) + return; + if (self->request_color_pick != DT_REQUEST_COLORPICK_MODULE) + return; const float x_mean = CLAMP(g->picked_s, 0.f, 1.f) * w; const float x_min = CLAMP(g->picked_s_min, 0.f, 1.f) * w; @@ -1043,19 +1444,19 @@ static void _draw_sat_picker(cairo_t *cr, dt_iop_module_t *self, const int w, co } static void _draw_channel_curve(cairo_t *cr, - const dt_iop_satcurve_channel_params_t *cp, - const dt_iop_satcurve_gui_channel_t *gc, - const int selected, - const gboolean active, - const int w, const int h, - const double r, const double g, const double b) + const dt_iop_satcurve_channel_params_t *cp, + const dt_iop_satcurve_gui_channel_t *gc, + const int selected, + const gboolean active, + const int w, const int h, + const double r, const double g, const double b) { cairo_save(cr); cairo_set_source_rgba(cr, r, g, b, active ? .95 : .50); cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(active ? 2.0 : 1.2)); cairo_move_to(cr, 0, h * (1.f - gc->draw_ys[0])); - for(int i = 1; i < DT_IOP_SATCURVE_RES; i++) + for (int i = 1; i < DT_IOP_SATCURVE_RES; i++) { const float x = w * i / (float)(DT_IOP_SATCURVE_RES - 1); const float y = h * (1.f - gc->draw_ys[i]); @@ -1063,10 +1464,10 @@ static void _draw_channel_curve(cairo_t *cr, } cairo_stroke(cr); - if(active) + if (active) { cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(1.0)); - for(int i = 0; i < cp->curve_num_nodes; i++) + for (int i = 0; i < cp->curve_num_nodes; i++) { const float px = w * cp->curve[i].x; const float py = h * (1.f - cp->curve[i].y); @@ -1079,21 +1480,22 @@ static void _draw_channel_curve(cairo_t *cr, } static void _sync_gui_curve(dt_iop_satcurve_gui_channel_t *gc, - const dt_iop_satcurve_channel_params_t *cp) + const dt_iop_satcurve_channel_params_t *cp) { - if(gc->curve_type != cp->curve_type || gc->curve_num_nodes != cp->curve_num_nodes) + if (gc->curve_type != cp->curve_type || gc->curve_num_nodes != cp->curve_num_nodes) { - if(gc->curve) dt_draw_curve_destroy(gc->curve); + if (gc->curve) + dt_draw_curve_destroy(gc->curve); gc->curve = dt_draw_curve_new(0.f, 1.f, cp->curve_type); gc->curve_type = cp->curve_type; gc->curve_num_nodes = cp->curve_num_nodes; - for(int i = 0; i < cp->curve_num_nodes; i++) + for (int i = 0; i < cp->curve_num_nodes; i++) dt_draw_curve_add_point(gc->curve, cp->curve[i].x, cp->curve[i].y); } else { - for(int i = 0; i < cp->curve_num_nodes; i++) + for (int i = 0; i < cp->curve_num_nodes; i++) dt_draw_curve_set_point(gc->curve, i, cp->curve[i].x, cp->curve[i].y); } @@ -1117,7 +1519,7 @@ static gboolean area_draw(GtkWidget *widget, cairo_t *cr, dt_iop_module_t *self) cairo_save(cr); cairo_translate(cr, gx0, gy0); - for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + for (int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) _sync_gui_curve(&g->channel[ch], &p->channel[ch]); _draw_sat_histogram(cr, g, (int)gw, (int)gh); @@ -1125,13 +1527,13 @@ static gboolean area_draw(GtkWidget *widget, cairo_t *cr, dt_iop_module_t *self) cairo_set_source_rgba(cr, 1, 1, 1, .08); cairo_set_line_width(cr, 1.0); - for(int i = 0; i <= 4; i++) + for (int i = 0; i <= 4; i++) { const float y = gh * i / 4.f; cairo_move_to(cr, 0, y); cairo_line_to(cr, gw, y); } - for(int i = 0; i <= 4; i++) + for (int i = 0; i <= 4; i++) { const float x = gw * i / 4.f; cairo_move_to(cr, x, 0); @@ -1140,20 +1542,20 @@ static gboolean area_draw(GtkWidget *widget, cairo_t *cr, dt_iop_module_t *self) cairo_stroke(cr); _draw_channel_curve(cr, - &p->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], - &g->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], - g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION ? g->selected : -1, - g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION, - (int)gw, (int)gh, - .90, .90, .90); + &p->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], + &g->channel[DT_IOP_SATCURVE_CHANNEL_SATURATION], + g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION ? g->selected : -1, + g->active_channel == DT_IOP_SATCURVE_CHANNEL_SATURATION, + (int)gw, (int)gh, + .90, .90, .90); _draw_channel_curve(cr, - &p->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], - &g->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], - g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? g->selected : -1, - g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE, - (int)gw, (int)gh, - 1.00, .65, .20); + &p->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], + &g->channel[DT_IOP_SATCURVE_CHANNEL_BRILLIANCE], + g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? g->selected : -1, + g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE, + (int)gw, (int)gh, + 1.00, .65, .20); cairo_restore(cr); @@ -1183,18 +1585,18 @@ static gboolean area_button(GtkWidget *widget, GdkEventButton *event, dt_iop_mod const float y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); int hit = -1; - for(int i = 0; i < cp->curve_num_nodes; i++) + for (int i = 0; i < cp->curve_num_nodes; i++) { const float dx = x - cp->curve[i].x; const float dy = y - cp->curve[i].y; - if(dx * dx + dy * dy < .0025f) + if (dx * dx + dy * dy < .0025f) { hit = i; break; } } - if(event->type == GDK_2BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) + if (event->type == GDK_2BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) { reset_channel_curve(cp); g->selected = -1; @@ -1203,15 +1605,16 @@ static gboolean area_button(GtkWidget *widget, GdkEventButton *event, dt_iop_mod return TRUE; } - if(event->button == GDK_BUTTON_SECONDARY && hit >= 0) + if (event->button == GDK_BUTTON_SECONDARY && hit >= 0) { - if((event->state & GDK_CONTROL_MASK) || cp->curve_num_nodes <= 2) + if ((event->state & GDK_CONTROL_MASK) || cp->curve_num_nodes <= 2) { cp->curve[hit].y = .5f; } else { - for(int i = hit; i < cp->curve_num_nodes - 1; i++) cp->curve[i] = cp->curve[i + 1]; + for (int i = hit; i < cp->curve_num_nodes - 1; i++) + cp->curve[i] = cp->curve[i + 1]; cp->curve_num_nodes--; } g->selected = -1; @@ -1220,9 +1623,9 @@ static gboolean area_button(GtkWidget *widget, GdkEventButton *event, dt_iop_mod return TRUE; } - if(event->button == GDK_BUTTON_PRIMARY) + if (event->button == GDK_BUTTON_PRIMARY) { - if(hit < 0 && cp->curve_num_nodes < DT_IOP_SATCURVE_MAXNODES) + if (hit < 0 && cp->curve_num_nodes < DT_IOP_SATCURVE_MAXNODES) hit = add_node_to_channel(cp, x, CLAMP(dt_draw_curve_calc_value(gc->curve, x), 0.f, 1.f)); g->selected = hit; @@ -1234,15 +1637,16 @@ static gboolean area_button(GtkWidget *widget, GdkEventButton *event, dt_iop_mod } static gboolean area_scroll(GtkWidget *widget, GdkEventScroll *event, - dt_iop_module_t *self) + dt_iop_module_t *self) { dt_iop_satcurve_gui_data_t *g = self->gui_data; dt_iop_satcurve_channel_params_t *cp = get_active_channel_params(self); - if(g->selected < 0) return FALSE; // nur wenn ein Knoten selektiert ist + if (g->selected < 0) + return FALSE; // nur wenn ein Knoten selektiert ist int delta_y = 0; - if(dt_gui_get_scroll_unit_delta(event, &delta_y)) + if (dt_gui_get_scroll_unit_delta(event, &delta_y)) { const float step = 0.02f * (event->state & GDK_CONTROL_MASK ? 5.0f : 1.0f); const int n = g->selected; @@ -1260,7 +1664,8 @@ static gboolean area_motion(GtkWidget *widget, GdkEventMotion *event, dt_iop_mod dt_iop_satcurve_gui_data_t *g = self->gui_data; dt_iop_satcurve_channel_params_t *cp = get_active_channel_params(self); - if(!g->dragging || g->selected < 0) return FALSE; + if (!g->dragging || g->selected < 0) + return FALSE; GtkAllocation a; gtk_widget_get_allocation(widget, &a); @@ -1271,14 +1676,14 @@ static gboolean area_motion(GtkWidget *widget, GdkEventMotion *event, dt_iop_mod const float x = CLAMP((event->x - gx0) / w, 0.f, 1.f); const int n = g->selected; - if(n == 0) + if (n == 0) cp->curve[n].x = 0.f; - else if(n == cp->curve_num_nodes - 1) + else if (n == cp->curve_num_nodes - 1) cp->curve[n].x = 1.f; else cp->curve[n].x = CLAMP(x, - cp->curve[n - 1].x + DT_IOP_SATCURVE_MIN_X_DISTANCE, - cp->curve[n + 1].x - DT_IOP_SATCURVE_MIN_X_DISTANCE); + cp->curve[n - 1].x + DT_IOP_SATCURVE_MIN_X_DISTANCE, + cp->curve[n + 1].x - DT_IOP_SATCURVE_MIN_X_DISTANCE); cp->curve[n].y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); gtk_widget_queue_draw(widget); @@ -1288,7 +1693,7 @@ static gboolean area_motion(GtkWidget *widget, GdkEventMotion *event, dt_iop_mod static gboolean area_release(GtkWidget *widget, GdkEventButton *event, dt_iop_module_t *self) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - if(g->dragging) + if (g->dragging) { g->dragging = FALSE; dt_dev_add_history_item(darktable.develop, self, TRUE); @@ -1301,21 +1706,23 @@ static const float *_get_gamut_lut_for_picker(dt_iop_module_t *self) static float unity_gamut_lut[LUT_ELEM]; static gboolean unity_inited = FALSE; - if(!unity_inited) + if (!unity_inited) { - for(int i = 0; i < LUT_ELEM; i++) unity_gamut_lut[i] = 1.f; + for (int i = 0; i < LUT_ELEM; i++) + unity_gamut_lut[i] = 1.f; unity_inited = TRUE; } - for(GList *nodes = self->dev->full.pipe ? self->dev->full.pipe->nodes : NULL; - nodes; - nodes = g_list_next(nodes)) + for (GList *nodes = self->dev->full.pipe ? self->dev->full.pipe->nodes : NULL; + nodes; + nodes = g_list_next(nodes)) { dt_dev_pixelpipe_iop_t *piece = nodes->data; - if(piece->module == self && piece->data) + if (piece->module == self && piece->data) { const dt_iop_satcurve_data_t *d = piece->data; - if(d->lut_inited && d->gamut_lut) return d->gamut_lut; + if (d->lut_inited && d->gamut_lut) + return d->gamut_lut; } } @@ -1325,13 +1732,15 @@ static const float *_get_gamut_lut_for_picker(dt_iop_module_t *self) void color_picker_apply(dt_iop_module_t *self, GtkWidget *widget, dt_dev_pixelpipe_t *pipe) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - if(!g) return; + if (!g) + return; const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_iop_work_profile_info(self, self->dev->iop); - if(!work_profile) return; + if (!work_profile) + return; - dt_colormatrix_t inputmatrix = {{ 0.0f }}; + dt_colormatrix_t inputmatrix = {{0.0f}}; dt_colormatrix_t inputmatrix_trans; dt_colormatrix_mul(inputmatrix, XYZ_D50_to_D65_CAT16, work_profile->matrix_in); dt_colormatrix_transpose(inputmatrix_trans, inputmatrix); @@ -1339,9 +1748,9 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *widget, dt_dev_pixelpi const float *const gamut_lut = _get_gamut_lut_for_picker(self); const float L_white = Y_to_dt_UCS_L_star(1.f); - const dt_aligned_pixel_t mean_rgb = { self->picked_color[0], self->picked_color[1], self->picked_color[2], 0.f }; - const dt_aligned_pixel_t min_rgb = { self->picked_color_min[0], self->picked_color_min[1], self->picked_color_min[2], 0.f }; - const dt_aligned_pixel_t max_rgb = { self->picked_color_max[0], self->picked_color_max[1], self->picked_color_max[2], 0.f }; + const dt_aligned_pixel_t mean_rgb = {self->picked_color[0], self->picked_color[1], self->picked_color[2], 0.f}; + const dt_aligned_pixel_t min_rgb = {self->picked_color_min[0], self->picked_color_min[1], self->picked_color_min[2], 0.f}; + const dt_aligned_pixel_t max_rgb = {self->picked_color_max[0], self->picked_color_max[1], self->picked_color_max[2], 0.f}; const dt_iop_satcurve_formula_t formula = ((const dt_iop_satcurve_params_t *)self->params)->formula; @@ -1357,7 +1766,8 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *widget, dt_dev_pixelpi // show_luminance_mask_callback() in toneequal.c static void show_saturation_mask_callback(GtkToggleButton *button, dt_iop_module_t *self) { - if(darktable.gui->reset) return; + if (darktable.gui->reset) + return; dt_iop_satcurve_gui_data_t *g = self->gui_data; dt_iop_request_focus(self); @@ -1368,33 +1778,34 @@ static void show_saturation_mask_callback(GtkToggleButton *button, dt_iop_module void gui_focus(dt_iop_module_t *self, gboolean in) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - if(!in) + if (!in) { dt_iop_color_picker_reset(self, FALSE); // NEU: Maskenanzeige beim Verlassen des Moduls zurücksetzen - if(g && g->mask_display) + if (g && g->mask_display) { g->mask_display = FALSE; - if(g->show_saturation_mask) + if (g->show_saturation_mask) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->show_saturation_mask), FALSE); } } } static void _channel_tabs_switch_callback(GtkNotebook *notebook, - GtkWidget *page, - guint page_num, - dt_iop_module_t *self) + GtkWidget *page, + guint page_num, + dt_iop_module_t *self) { DT_GUARD_GUI_UPDATE(); dt_iop_satcurve_gui_data_t *g = self->gui_data; - if(!g) return; + if (!g) + return; g->active_channel = (page_num == 1) - ? DT_IOP_SATCURVE_CHANNEL_BRILLIANCE - : DT_IOP_SATCURVE_CHANNEL_SATURATION; + ? DT_IOP_SATCURVE_CHANNEL_BRILLIANCE + : DT_IOP_SATCURVE_CHANNEL_SATURATION; g->selected = -1; gtk_widget_queue_draw(GTK_WIDGET(g->area)); @@ -1403,9 +1814,10 @@ static void _channel_tabs_switch_callback(GtkNotebook *notebook, void gui_changed(dt_iop_module_t *self, GtkWidget *widget, void *previous) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - if(!g) return; + if (!g) + return; - if(widget == g->formula) + if (widget == g->formula) dt_dev_add_history_item(darktable.develop, self, TRUE); } @@ -1413,24 +1825,29 @@ void gui_update(dt_iop_module_t *self) { dt_iop_satcurve_gui_data_t *g = self->gui_data; const dt_iop_satcurve_params_t *p = self->params; - if(!g) return; + if (!g) + return; - if(g->formula) dt_bauhaus_combobox_set(g->formula, p->formula); + if (g->formula) + dt_bauhaus_combobox_set(g->formula, p->formula); - if(g->notebook) + if (g->notebook) gtk_notebook_set_current_page(GTK_NOTEBOOK(g->notebook), - g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? 1 : 0); + g->active_channel == DT_IOP_SATCURVE_CHANNEL_BRILLIANCE ? 1 : 0); - if(g->area) gtk_widget_queue_draw(GTK_WIDGET(g->area)); + if (g->area) + gtk_widget_queue_draw(GTK_WIDGET(g->area)); } void gui_cleanup(dt_iop_module_t *self) { dt_iop_satcurve_gui_data_t *g = self->gui_data; - if(!g) return; + if (!g) + return; - for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) - if(g->channel[ch].curve) dt_draw_curve_destroy(g->channel[ch].curve); + for (int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + if (g->channel[ch].curve) + dt_draw_curve_destroy(g->channel[ch].curve); dt_pthread_mutex_destroy(&g->histogram_lock); } @@ -1445,16 +1862,16 @@ void gui_init(dt_iop_module_t *self) g->active_channel = DT_IOP_SATCURVE_CHANNEL_SATURATION; g->mask_display = FALSE; // NEU - for(int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) + for (int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) { g->channel[ch].curve_type = p->channel[ch].curve_type; g->channel[ch].curve_num_nodes = p->channel[ch].curve_num_nodes; g->channel[ch].curve = dt_draw_curve_new(0.f, 1.f, p->channel[ch].curve_type); - for(int i = 0; i < p->channel[ch].curve_num_nodes; i++) + for (int i = 0; i < p->channel[ch].curve_num_nodes; i++) dt_draw_curve_add_point(g->channel[ch].curve, - p->channel[ch].curve[i].x, - p->channel[ch].curve[i].y); + p->channel[ch].curve[i].x, + p->channel[ch].curve[i].y); } memset(g->histogram, 0, sizeof(g->histogram)); @@ -1487,15 +1904,14 @@ void gui_init(dt_iop_module_t *self) gtk_notebook_set_current_page(g->notebook, 0); g_signal_connect(G_OBJECT(g->notebook), "switch-page", - G_CALLBACK(_channel_tabs_switch_callback), self); + G_CALLBACK(_channel_tabs_switch_callback), self); dt_gui_box_add(self->widget, GTK_WIDGET(g->notebook)); g->area = GTK_DRAWING_AREA(dt_ui_resize_wrap(NULL, 0, "plugins/darkroom/satcurve/graph_height")); gtk_widget_set_size_request(GTK_WIDGET(g->area), -1, DT_PIXEL_APPLY_DPI(180)); gtk_widget_add_events(GTK_WIDGET(g->area), - GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); + GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); g_signal_connect(G_OBJECT(g->area), "draw", G_CALLBACK(area_draw), self); g_signal_connect(G_OBJECT(g->area), "button-press-event", G_CALLBACK(area_button), self); g_signal_connect(G_OBJECT(g->area), "button-release-event", G_CALLBACK(area_release), self); @@ -1507,7 +1923,7 @@ void gui_init(dt_iop_module_t *self) dt_bauhaus_combobox_add(g->formula, _("JzAzBz")); dt_bauhaus_combobox_add(g->formula, _("darktable UCS")); gtk_widget_set_tooltip_text(g->formula, - _("choose the perceptual saturation definition used by both curves")); + _("choose the perceptual saturation definition used by both curves")); g_object_ref(g->formula); gtk_container_remove(GTK_CONTAINER(self->widget), g->formula); @@ -1522,7 +1938,7 @@ void gui_init(dt_iop_module_t *self) g->show_saturation_mask = dtgtk_togglebutton_new(dtgtk_cairo_paint_showmask, 0, NULL); gtk_widget_set_tooltip_text(g->show_saturation_mask, _("display saturation mask")); g_signal_connect(G_OBJECT(g->show_saturation_mask), "toggled", - G_CALLBACK(show_saturation_mask_callback), self); + G_CALLBACK(show_saturation_mask_callback), self); gtk_box_pack_start(GTK_BOX(controls_hbox), g->colorpicker, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(controls_hbox), g->show_saturation_mask, FALSE, FALSE, 0); // NEU @@ -1530,6 +1946,42 @@ void gui_init(dt_iop_module_t *self) g_object_unref(g->formula); dt_gui_box_add(self->widget, controls_hbox); + + GtkWidget *gf_box = dt_gui_vbox(); + dt_gui_new_collapsible_section(&g->gf_section, + "plugins/darkroom/satcurve/expand_guided_filter", + _("guided filter"), + GTK_BOX(gf_box), DT_ACTION(self)); + + dt_iop_module_t *gf_section = + DT_IOP_SECTION_FOR_PARAMS(self, NULL, g->gf_section.container); + + g->use_guided_filter = dt_bauhaus_toggle_from_params(gf_section, "use_guided_filter"); + gtk_widget_set_tooltip_text(g->use_guided_filter, + _("spatially modulate the strength of the curves with an edge-aware " + "guided filter, based on local saturation, to reduce halos")); + + g->gf_radius = dt_bauhaus_slider_from_params(gf_section, "gf_radius"); + dt_bauhaus_slider_set_format(g->gf_radius, _("px")); + gtk_widget_set_tooltip_text(g->gf_radius, _("size of the neighbourhood used to guide the filter")); + + g->gf_feathering = dt_bauhaus_slider_from_params(gf_section, "gf_feathering"); + gtk_widget_set_tooltip_text(g->gf_feathering, + _("precision of the feathering: higher values produce softer, " + "less edge-sensitive transitions")); + + g->gf_iterations = dt_bauhaus_slider_from_params(gf_section, "gf_iterations"); + gtk_widget_set_tooltip_text(g->gf_iterations, + _("number of times the guided filter is applied recursively; " + "increases smoothing but costs more time")); + + g->gf_quantization = dt_bauhaus_slider_from_params(gf_section, "gf_quantization"); + dt_bauhaus_slider_set_format(g->gf_quantization, _("EV")); + gtk_widget_set_tooltip_text(g->gf_quantization, + _("posterize the guiding mask in log2 space to produce smoother, " + "more contoured transitions; 0 disables quantization")); + + dt_gui_box_add(self->widget, gf_box); } void init(dt_iop_module_t *self) From 15a9476e157ac546029f81d22f4d51c414ad6ad3 Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:02:19 +0200 Subject: [PATCH 08/12] adapted to changed develop.h --- src/iop/satcurvergb.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c index 67684e4bfdf2..6396643618eb 100755 --- a/src/iop/satcurvergb.c +++ b/src/iop/satcurvergb.c @@ -1772,7 +1772,7 @@ static void show_saturation_mask_callback(GtkToggleButton *button, dt_iop_module dt_iop_request_focus(self); g->mask_display = gtk_toggle_button_get_active(button); - dt_dev_reprocess_center(self->dev); + dt_dev_reprocess_center(self->dev, self->iop_order); } void gui_focus(dt_iop_module_t *self, gboolean in) From 18a158860ce1fade99d7fef4db4a30c539c54c18 Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:13:07 +0200 Subject: [PATCH 09/12] update copyright note --- data/kernels/fast_guided_filter.cl | 2 +- data/kernels/satcurve.cl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/kernels/fast_guided_filter.cl b/data/kernels/fast_guided_filter.cl index aa4ef383b655..3e2bb42b36cd 100755 --- a/data/kernels/fast_guided_filter.cl +++ b/data/kernels/fast_guided_filter.cl @@ -1,6 +1,6 @@ /* This file is part of darktable, - Copyright (C) 2019-2026 darktable developers. + Copyright (C) 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 diff --git a/data/kernels/satcurve.cl b/data/kernels/satcurve.cl index b778b43f44ad..0b321e986f79 100755 --- a/data/kernels/satcurve.cl +++ b/data/kernels/satcurve.cl @@ -1,6 +1,6 @@ /* This file is part of darktable, - Copyright (C) 2009-2026 darktable developers. + Copyright (C) 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 From 0796c70ecf6128cd49dac63f6684b16c3e058720 Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:31:54 +0200 Subject: [PATCH 10/12] add dt_dev_add_history_item to area_motion --- src/iop/satcurvergb.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c index 6396643618eb..8504c91dd458 100755 --- a/src/iop/satcurvergb.c +++ b/src/iop/satcurvergb.c @@ -1686,6 +1686,7 @@ static gboolean area_motion(GtkWidget *widget, GdkEventMotion *event, dt_iop_mod cp->curve[n + 1].x - DT_IOP_SATCURVE_MIN_X_DISTANCE); cp->curve[n].y = CLAMP(1.f - (event->y - gy0) / h, 0.f, 1.f); + dt_dev_add_history_item(darktable.develop, self, TRUE); gtk_widget_queue_draw(widget); return TRUE; } From 41af41a074726e7389c04d7df181d22003646cb0 Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:50:58 +0200 Subject: [PATCH 11/12] add satcurve to all + scene referred module group --- src/libs/modulegroups.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/modulegroups.c b/src/libs/modulegroups.c index bba427dd2a6e..049e2246faa2 100644 --- a/src/libs/modulegroups.c +++ b/src/libs/modulegroups.c @@ -1604,6 +1604,7 @@ void init_presets(dt_lib_module_t *self) AM("monochrome"); AM("profile"); AM("primaries"); + AM("satcurvergb"); AM("gamma"); AM("velvia"); @@ -1766,6 +1767,7 @@ void init_presets(dt_lib_module_t *self) AM("colorequal"); AM("colorharmonizer"); AM("primaries"); + AM("satcurvergb"); SMG(C_("modulegroup", "correct"), "correct"); AM("cacorrect"); From 3883ec366cd1e4f8781f001ef866901484501e92 Mon Sep 17 00:00:00 2001 From: Martin Straeten <39386816+MStraeten@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:46:18 +0200 Subject: [PATCH 12/12] replaced german comments + rework show_saturation_mask_callback --- src/iop/satcurvergb.c | 50 +++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/src/iop/satcurvergb.c b/src/iop/satcurvergb.c index 8504c91dd458..1608a507afc7 100755 --- a/src/iop/satcurvergb.c +++ b/src/iop/satcurvergb.c @@ -145,14 +145,14 @@ typedef struct dt_iop_satcurve_gui_data_t float picked_s_min; float picked_s_max; - gboolean mask_display; // NEU + gboolean mask_display; } dt_iop_satcurve_gui_data_t; typedef struct dt_iop_satcurve_global_data_t { int kernel_satcurvergb; int kernel_satcurve_histogram; - int kernel_satcurve_mask; // NEU + int kernel_satcurve_mask; int kernel_satcurve_scalar_mask; int kernel_satcurve_mask_from_scalar; int kernel_satcurve_apply_guided_mask; @@ -520,8 +520,8 @@ static inline void apply_sat_and_brilliance_jzazbz(const dt_iop_satcurve_data_t dt_JzAzBz_2_XYZ(jab, xyz); } -// NEU: berechnet pro Pixel die normierte Sättigung in einen separaten Buffer, -// analog zu compute_luminance_mask() in toneequal.c +// Compute the normalized saturation for each pixel into a separate buffer, +// analogous to compute_luminance_mask() in toneequal.c. static inline void compute_saturation_mask(const dt_iop_satcurve_data_t *d, const dt_colormatrix_t inputmatrix_trans, const float L_white, @@ -537,8 +537,8 @@ static inline void compute_saturation_mask(const dt_iop_satcurve_data_t *d, } } -// NEU: Graustufen-Visualisierung der Sättigungsmaske; sqrt-"Gamma" für bessere -// Sichtbarkeit niedriger Werte, analog display_luminance_mask() in toneequal.c +// Render the saturation mask as a grayscale preview. Using a sqrt-like gamma +// makes low values easier to see, analogous to display_luminance_mask() in toneequal.c. static inline void display_saturation_mask(const float *const restrict in, const float *const restrict mask, float *const restrict out, @@ -611,7 +611,7 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, if (self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && dt_iop_has_focus(self) && piece->pipe == self->dev->full.pipe) _update_sat_histogram(self, d, inputmatrix_trans, in, npixels); - // NEU: Maskenanzeige-Zweig -- zeigt die normierte Sättigung als Graustufenbild + // Display a grayscale preview of the normalized saturation mask. if (self->dev->gui_attached && dt_pipe_is_full(piece->pipe) && g && g->mask_display) { float *const restrict mask = dt_alloc_align_float(npixels); @@ -972,7 +972,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const float L_white = Y_to_dt_UCS_L_star(1.f); - // NEU: Maskenanzeige-Pfad -- eigener, einfacherer Kernel statt satcurvergb + // Mask-preview path: use a simpler kernel than the main satcurvergb path. if (want_mask) { if (!gf_active) @@ -1152,7 +1152,7 @@ void init_global(dt_iop_module_so_t *self) self->data = gd; gd->kernel_satcurvergb = dt_opencl_create_kernel(program, "satcurvergb"); gd->kernel_satcurve_histogram = dt_opencl_create_kernel(program, "satcurve_histogram"); - gd->kernel_satcurve_mask = dt_opencl_create_kernel(program, "satcurve_mask"); // NEU + gd->kernel_satcurve_mask = dt_opencl_create_kernel(program, "satcurve_mask"); gd->kernel_satcurve_scalar_mask = dt_opencl_create_kernel(program, "satcurve_scalar_mask"); gd->kernel_satcurve_mask_from_scalar = dt_opencl_create_kernel(program, "satcurve_mask_from_scalar"); gd->kernel_satcurve_apply_guided_mask = dt_opencl_create_kernel(program_fgf, "satcurve_apply_guided_mask"); @@ -1168,7 +1168,7 @@ void cleanup_global(dt_iop_module_so_t *self) const dt_iop_satcurve_global_data_t *gd = self->data; dt_opencl_free_kernel(gd->kernel_satcurvergb); dt_opencl_free_kernel(gd->kernel_satcurve_histogram); - dt_opencl_free_kernel(gd->kernel_satcurve_mask); // NEU + dt_opencl_free_kernel(gd->kernel_satcurve_mask); dt_opencl_free_kernel(gd->kernel_satcurve_scalar_mask); dt_opencl_free_kernel(gd->kernel_satcurve_mask_from_scalar); dt_opencl_free_kernel(gd->kernel_satcurve_apply_guided_mask); @@ -1763,17 +1763,29 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *widget, dt_dev_pixelpi gtk_widget_queue_draw(GTK_WIDGET(g->area)); } -// NEU: Callback für den Sättigungsmasken-Toggle-Button, analog -// show_luminance_mask_callback() in toneequal.c +// Toggle the saturation-mask preview, following the standard pattern used by +// other mask preview controls in darktable. static void show_saturation_mask_callback(GtkToggleButton *button, dt_iop_module_t *self) { - if (darktable.gui->reset) - return; + DT_GUARD_GUI_UPDATE(); + dt_iop_satcurve_gui_data_t *g = self->gui_data; + if (!g) + return; dt_iop_request_focus(self); + + if (self->request_mask_display) + { + dt_control_log(_("cannot display saturation masks while the blending mask is displayed")); + gtk_toggle_button_set_active(button, FALSE); + g->mask_display = FALSE; + return; + } + g->mask_display = gtk_toggle_button_get_active(button); - dt_dev_reprocess_center(self->dev, self->iop_order); + dt_iop_refresh_center(self); + dt_iop_color_picker_reset(self, TRUE); } void gui_focus(dt_iop_module_t *self, gboolean in) @@ -1783,7 +1795,7 @@ void gui_focus(dt_iop_module_t *self, gboolean in) { dt_iop_color_picker_reset(self, FALSE); - // NEU: Maskenanzeige beim Verlassen des Moduls zurücksetzen + // Reset the mask preview when leaving the module. if (g && g->mask_display) { g->mask_display = FALSE; @@ -1861,7 +1873,7 @@ void gui_init(dt_iop_module_t *self) g->selected = -1; g->dragging = FALSE; g->active_channel = DT_IOP_SATCURVE_CHANNEL_SATURATION; - g->mask_display = FALSE; // NEU + g->mask_display = FALSE; for (int ch = 0; ch < DT_IOP_SATCURVE_CHANNELS; ch++) { @@ -1935,14 +1947,14 @@ void gui_init(dt_iop_module_t *self) g->colorpicker = dt_color_picker_new_with_cst(self, DT_COLOR_PICKER_POINT, NULL, IOP_CS_RGB); gtk_widget_set_tooltip_text(g->colorpicker, _("pick saturation from image")); - // NEU: Toggle-Button für die Sättigungsmaskenanzeige + // Toggle button for the saturation-mask preview. g->show_saturation_mask = dtgtk_togglebutton_new(dtgtk_cairo_paint_showmask, 0, NULL); gtk_widget_set_tooltip_text(g->show_saturation_mask, _("display saturation mask")); g_signal_connect(G_OBJECT(g->show_saturation_mask), "toggled", G_CALLBACK(show_saturation_mask_callback), self); gtk_box_pack_start(GTK_BOX(controls_hbox), g->colorpicker, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(controls_hbox), g->show_saturation_mask, FALSE, FALSE, 0); // NEU + gtk_box_pack_start(GTK_BOX(controls_hbox), g->show_saturation_mask, FALSE, FALSE, 0); gtk_box_pack_end(GTK_BOX(controls_hbox), g->formula, FALSE, FALSE, 0); g_object_unref(g->formula);