diff --git a/.gitignore b/.gitignore
index ff04a7818b2e..97143fbec46e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,5 @@ output*png
Brewfile.lock.json
CMakeLists.txt.user
workspace/
+cmake-build-debug/
+.idea/
diff --git a/data/darktableconfig.xml.in b/data/darktableconfig.xml.in
index f4d6db7e4498..56329d594cda 100644
--- a/data/darktableconfig.xml.in
+++ b/data/darktableconfig.xml.in
@@ -3520,6 +3520,7 @@
+
@@ -3851,6 +3852,34 @@
active tab in tone equaliser module
which of simple, advanced or masking tabs will show at startup
+
+ plugins/darkroom/agx/enable_curve_tab
+ bool
+ false
+ whether to enable the curve tab
+ if enabled, the settings tab will have fewer controls and will occupy less vertical space
+
+
+ plugins/darkroom/agx/expand_curve_graph
+ bool
+ false
+ whether to expand the curve graph
+ this is just to store whether to show the curve graph or not
+
+
+ plugins/darkroom/agx/curve_graph_height
+ int
+ 200
+ height of agx graph in per cent
+ height of agx graph in per cent
+
+
+ plugins/darkroom/agx/expand_curve_advanced
+ bool
+ false
+ whether to show advanced curve parameters
+ this is just to store whether to show advanced curve parameters or not
+
plugins/lighttable/collect/windowheight
int
diff --git a/src/common/iop_order.c b/src/common/iop_order.c
index 8ff8e3d1dd2d..2e4e9617522a 100644
--- a/src/common/iop_order.c
+++ b/src/common/iop_order.c
@@ -140,6 +140,7 @@ const dt_iop_order_entry_t legacy_order[] = {
{ {44.0f }, "lowlight", 0},
{ {45.0f }, "monochrome", 0},
{ {45.3f }, "sigmoid", 0},
+ { {45.5f }, "agx", 0},
{ {46.0f }, "filmic", 0},
{ {46.5f }, "filmicrgb", 0},
{ {47.0f }, "colisa", 0},
@@ -253,6 +254,7 @@ const dt_iop_order_entry_t v30_order[] = {
// on camera JPEG default look
{ {45.0f }, "filmic", 0}, // same, but different (parametric) approach
{ {45.3f }, "sigmoid", 0},
+ { {45.5f }, "agx", 0},
{ {46.0f }, "filmicrgb", 0}, // same, upgraded
{ {36.0f }, "lut3d", 0}, // apply a creative style or film emulation, possibly non-linear
{ {47.0f }, "colisa", 0}, // edit contrast while damaging colour
@@ -370,6 +372,7 @@ const dt_iop_order_entry_t v50_order[] = {
// on camera JPEG default look
{ {45.0f }, "filmic", 0}, // same, but different (parametric) approach
{ {45.3f }, "sigmoid", 0},
+ { {45.5f }, "agx", 0},
{ {46.0f }, "filmicrgb", 0}, // same, upgraded
{ {36.0f }, "lut3d", 0}, // apply a creative style or film emulation, possibly non-linear
{ {47.0f }, "colisa", 0}, // edit contrast while damaging colour
@@ -487,6 +490,7 @@ const dt_iop_order_entry_t v30_jpg_order[] = {
{ { 44.0f }, "basecurve", 0 }, // conversion from scene-referred to display referred, reverse-engineered
// on camera JPEG default look
{ { 45.0f }, "filmic", 0 }, // same, but different (parametric) approach
+ { {45.5f }, "agx", 0},
{ { 45.3f }, "sigmoid", 0},
{ { 46.0f }, "filmicrgb", 0 }, // same, upgraded
{ { 36.0f }, "lut3d", 0 }, // apply a creative style or film emulation, possibly non-linear
@@ -608,6 +612,7 @@ const dt_iop_order_entry_t v50_jpg_order[] = {
// on camera JPEG default look
{ { 45.0f }, "filmic", 0 }, // same, but different (parametric) approach
{ { 45.3f }, "sigmoid", 0},
+ { {45.5f }, "agx", 0},
{ { 46.0f }, "filmicrgb", 0 }, // same, upgraded
{ { 36.0f }, "lut3d", 0 }, // apply a creative style or film emulation, possibly non-linear
{ { 47.0f }, "colisa", 0 }, // edit contrast while damaging colour
@@ -1177,6 +1182,7 @@ GList *dt_ioppr_get_iop_order_list(const dt_imgid_t imgid,
_insert_before(iop_order_list, "colorbalance", "diffuse");
_insert_before(iop_order_list, "nlmeans", "blurs");
_insert_before(iop_order_list, "filmicrgb", "sigmoid");
+ _insert_before(iop_order_list, "filmicrgb", "agx");
_insert_before(iop_order_list, "colorbalancergb", "colorequal");
_insert_before(iop_order_list, "highlights", "rasterfile");
}
diff --git a/src/common/iop_profile.c b/src/common/iop_profile.c
index 654a534e9ba9..5809d6d104b8 100644
--- a/src/common/iop_profile.c
+++ b/src/common/iop_profile.c
@@ -1097,6 +1097,96 @@ void dt_ioppr_get_export_profile_type(struct dt_develop_t *dev,
"[dt_ioppr_get_export_profile_type] can't find colorout iop");
}
+/**
+ * Get the export profile settings configured in the colorout module.
+ * This retrieves the settings as configured in the GUI, regardless of the
+ * current pipeline type (display, export, etc.).
+ *
+ * @param dev develop struct
+ * @param profile_type pointer to store the profile type
+ * @param profile_filename buffer to store the filename (supply size)
+ * @param filename_size size of the profile_filename buffer
+ * @param profile_intent pointer to store the rendering intent
+ * @return TRUE if colorout module and parameters were found, FALSE otherwise
+ */
+gboolean dt_ioppr_get_configured_export_profile_settings(
+ struct dt_develop_t *dev,
+ dt_colorspaces_color_profile_type_t *profile_type,
+ char *profile_filename,
+ size_t filename_size,
+ dt_iop_color_intent_t *profile_intent)
+{
+ // Initialize output parameters to default/invalid values
+ if (profile_type) *profile_type = DT_COLORSPACE_NONE;
+ if (profile_filename) profile_filename[0] = '\0';
+ if (profile_intent) *profile_intent = DT_INTENT_PERCEPTUAL; // Default intent
+
+ dt_iop_module_so_t *colorout_so = NULL;
+ // Find the colorout module SO (should be loaded)
+ for(const GList *modules = darktable.iop; modules; modules = g_list_next(modules))
+ {
+ dt_iop_module_so_t *module_so = modules->data;
+ if(dt_iop_module_is(module_so, "colorout"))
+ {
+ colorout_so = module_so;
+ break;
+ }
+ }
+
+ if (!colorout_so || !colorout_so->get_p)
+ {
+ // colorout module SO not found or doesn't support get_p
+ dt_print(DT_DEBUG_ALWAYS, "[get_configured_export_profile_settings] colorout SO or get_p not found.");
+ return FALSE;
+ }
+
+ dt_iop_module_t *colorout = NULL;
+ // Find the colorout instance in the current pipeline (dev->iop)
+ // Start from the end, as colorout is usually last
+ for(const GList *modules = g_list_last(dev->iop); modules; modules = g_list_previous(modules))
+ {
+ dt_iop_module_t *module = modules->data;
+ if(dt_iop_module_is(module->so, "colorout"))
+ {
+ colorout = module;
+ break;
+ }
+ }
+
+ if (!colorout)
+ {
+ // colorout module instance not found in this pipeline
+ dt_print(DT_DEBUG_ALWAYS, "[get_configured_export_profile_settings] colorout module not found in pipe.");
+ return FALSE;
+ }
+
+ // Get pointers to the parameter values using introspection
+ dt_colorspaces_color_profile_type_t *_type = colorout_so->get_p(colorout->params, "type");
+ char *_filename = colorout_so->get_p(colorout->params, "filename");
+ dt_iop_color_intent_t *_intent = colorout_so->get_p(colorout->params, "intent");
+
+ // Check if all expected parameters were successfully retrieved
+ if (_type && _filename && _intent)
+ {
+ // Populate the output parameters
+ if (profile_type) *profile_type = *_type;
+ if (profile_filename && filename_size > 0) g_strlcpy(profile_filename, _filename, filename_size);
+ if (profile_intent) *profile_intent = *_intent;
+
+ dt_print(DT_DEBUG_DEV,
+ "[get_configured_export_profile_settings] Retrieved export profile: type=%d, filename='%s', intent=%d",
+ *profile_type, profile_filename, *profile_intent);
+ return TRUE; // Success
+ }
+ else
+ {
+ dt_print(DT_DEBUG_ALWAYS,
+ "[get_configured_export_profile_settings] Failed to get colorout parameters via get_p.");
+ // Even on failure, the output parameters were initialized to default/invalid
+ return FALSE;
+ }
+}
+
void dt_ioppr_get_histogram_profile_type(dt_colorspaces_color_profile_type_t *profile_type,
const char **profile_filename)
{
diff --git a/src/common/iop_profile.h b/src/common/iop_profile.h
index f68889c376c3..afdf595076d8 100644
--- a/src/common/iop_profile.h
+++ b/src/common/iop_profile.h
@@ -145,6 +145,16 @@ void dt_ioppr_get_input_profile_type(struct dt_develop_t *dev,
void dt_ioppr_get_export_profile_type(struct dt_develop_t *dev,
dt_colorspaces_color_profile_type_t *profile_type,
const char **profile_filename);
+/**
+ * @see dt_ioppr_get_configured_export_profile_settings()
+ */
+gboolean dt_ioppr_get_configured_export_profile_settings(
+ struct dt_develop_t *dev,
+ dt_colorspaces_color_profile_type_t *profile_type,
+ char *profile_filename,
+ size_t filename_size,
+ dt_iop_color_intent_t *profile_intent);
+
/** returns the current setting of the histogram profile */
void dt_ioppr_get_histogram_profile_type(dt_colorspaces_color_profile_type_t *profile_type,
const char **profile_filename);
diff --git a/src/common/utility.c b/src/common/utility.c
index f28908a70ed2..ebec5c849fc3 100644
--- a/src/common/utility.c
+++ b/src/common/utility.c
@@ -1162,7 +1162,8 @@ gboolean dt_str_commasubstring(const char *list,
gboolean dt_is_scene_referred(void)
{
return dt_conf_is_equal("plugins/darkroom/workflow", "scene-referred (filmic)")
- || dt_conf_is_equal("plugins/darkroom/workflow", "scene-referred (sigmoid)");
+ || dt_conf_is_equal("plugins/darkroom/workflow", "scene-referred (sigmoid)")
+ || dt_conf_is_equal("plugins/darkroom/workflow", "scene-referred (agx)");
}
gboolean dt_is_display_referred(void)
diff --git a/src/iop/CMakeLists.txt b/src/iop/CMakeLists.txt
index 89a6e7453e1b..fee76f13ae07 100644
--- a/src/iop/CMakeLists.txt
+++ b/src/iop/CMakeLists.txt
@@ -152,6 +152,7 @@ add_iop(cacorrectrgb "cacorrectrgb.c")
add_iop(diffuse "diffuse.c")
add_iop(blurs "blurs.c")
add_iop(sigmoid "sigmoid.c")
+add_iop(agx "agx.c")
add_iop(primaries "primaries.c")
add_iop(colorequal "colorequal.c")
add_iop(rasterfile "rasterfile.c")
diff --git a/src/iop/agx.c b/src/iop/agx.c
new file mode 100644
index 000000000000..cf704e5abc46
--- /dev/null
+++ b/src/iop/agx.c
@@ -0,0 +1,2357 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2020-2025 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 "bauhaus/bauhaus.h"
+#include "common/colorspaces_inline_conversions.h"
+#include "common/custom_primaries.h"
+#include "common/iop_profile.h"
+#include "common/math.h"
+#include "common/matrices.h"
+#include "control/control.h"
+#include "develop/develop.h"
+#include "develop/imageop.h"
+#include "develop/imageop_gui.h"
+#include "develop/tiling.h"
+#include "gui/accelerators.h"
+#include "gui/color_picker_proxy.h"
+#include "gui/draw.h"
+#include "gui/gtk.h"
+#include "gui/presets.h"
+#include "iop/iop_api.h"
+#include
+#include
+#include
+#include
+
+DT_MODULE_INTROSPECTION(2, dt_iop_agx_user_params_t)
+
+const char *name()
+{
+ return _("agx");
+}
+
+const char **description(dt_iop_module_t *self)
+{
+ return dt_iop_set_description(self,
+ _("applies a tone mapping curve.\n"
+ "Inspired by Blender's AgX tone mapper"),
+ _("corrective and creative"), _("linear, RGB, scene-referred"),
+ _("non-linear, RGB"), _("linear, RGB, display-referred"));
+}
+
+int flags()
+{
+ return IOP_FLAGS_INCLUDE_IN_STYLES | IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_ALLOW_TILING;
+}
+
+int default_group()
+{
+ return IOP_GROUP_TONE | IOP_GROUP_TECHNICAL;
+}
+
+static const float _epsilon = 1E-6f;
+
+typedef enum dt_iop_agx_base_primaries_t
+{
+ DT_AGX_EXPORT_PROFILE = 0, // $DESCRIPTION: "export profile"
+ DT_AGX_WORK_PROFILE = 1, // $DESCRIPTION: "working profile"
+ DT_AGX_REC2020 = 2, // $DESCRIPTION: "Rec2020"
+ DT_AGX_DISPLAY_P3 = 3, // $DESCRIPTION: "Display P3"
+ DT_AGX_ADOBE_RGB = 4, // $DESCRIPTION: "Adobe RGB (compatible)"
+ DT_AGX_SRGB = 5, // $DESCRIPTION: "sRGB"
+} dt_iop_agx_base_primaries_t;
+
+// Params exposed on the UI
+typedef struct dt_iop_agx_user_params_t
+{
+ float look_offset; // $MIN: -1.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "offset"
+ float look_slope; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 1.0 $DESCRIPTION: "slope"
+ float look_power; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 1.0 $DESCRIPTION: "power"
+ float look_saturation; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 1.0 $DESCRIPTION: "saturation"
+ float look_original_hue_mix_ratio; // $MIN: 0.0 $MAX: 1 $DEFAULT: 0.0 $DESCRIPTION: "preserve hue"
+
+ // log mapping
+ float range_black_relative_exposure; // $MIN: -20.0 $MAX: -0.1 $DEFAULT: -10 $DESCRIPTION: "black relative exposure"
+ float range_white_relative_exposure; // $MIN: 0.1 $MAX: 20 $DEFAULT: 6.5 $DESCRIPTION: "white relative exposure"
+ float security_factor; // $MIN: -50 $MAX: 200 $DEFAULT: 10.0 $DESCRIPTION: "dynamic range scaling"
+
+ // curve params - comments indicate the original variables from https://www.desmos.com/calculator/yrysofmx8h
+ // Corresponds to p_x, but not directly -- allows shifting the default 0.18 towards black or white relative exposure
+ float curve_pivot_x_shift; // $MIN: -1.0 $MAX: 1.0 $DEFAULT: 0 $DESCRIPTION: "pivot x shift"
+ // Corresponds to p_y, but not directly -- needs application of gamma
+ float curve_pivot_y_linear; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.18 $DESCRIPTION: "pivot y (linear)"
+ // P_slope
+ float curve_contrast_around_pivot; // $MIN: 0.1 $MAX: 10.0 $DEFAULT: 2.4 $DESCRIPTION: "contrast around the pivot"
+ // related to P_tlength; the number expresses the portion of the y range below the pivot
+ float curve_linear_percent_below_pivot; // $MIN: 0.0 $MAX: 100.0 $DEFAULT: 0.0 $DESCRIPTION: "toe start"
+ // related to P_slength; the number expresses the portion of the y range below the pivot
+ float curve_linear_percent_above_pivot; // $MIN: 0.0 $MAX: 100.0 $DEFAULT: 0.0 $DESCRIPTION: "shoulder start"
+ // t_p
+ float curve_toe_power; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 1.5 $DESCRIPTION: "toe power"
+ // s_p
+ float curve_shoulder_power; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 1.5 $DESCRIPTION: "shoulder power"
+ float curve_gamma; // $MIN: 0.01 $MAX: 100.0 $DEFAULT: 2.2 $DESCRIPTION: "curve y gamma"
+ gboolean auto_gamma; // $MIN: 0 $MAX: 1 $DEFAULT: 0 $DESCRIPTION: "keep the pivot on the identity line"
+ // t_ly
+ float curve_target_display_black_percent; // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "target black"
+ // s_ly
+ float curve_target_display_white_percent; // $MIN: 20.0 $MAX: 100.0 $DEFAULT: 100.0 $DESCRIPTION: "target white"
+
+ // custom primaries; 30 degrees = 0.5236 radian for rotation
+ dt_iop_agx_base_primaries_t base_primaries; // $DEFAULT: DT_AGX_REC2020 $DESCRIPTION: "base primaries"
+ gboolean disable_primaries_adjustments; // $MIN: 0 $MAX: 1 $DEFAULT: 0 $DESCRIPTION: "disable adjustments"
+ float red_inset; // $MIN: 0.0 $MAX: 0.99 $DEFAULT: 0.0 $DESCRIPTION: "red attenuation"
+ float red_rotation; // $MIN: -0.5236 $MAX: 0.5236 $DEFAULT: 0.0 $DESCRIPTION: "red rotation"
+ float green_inset; // $MIN: 0.0 $MAX: 0.99 $DEFAULT: 0.0 $DESCRIPTION: "green attenuation"
+ float green_rotation; // $MIN: -0.5236 $MAX: 0.5236 $DEFAULT: 0.0 $DESCRIPTION: "green rotation"
+ float blue_inset; // $MIN: 0.0 $MAX: 0.99 $DEFAULT: 0.0 $DESCRIPTION: "blue attenuation"
+ float blue_rotation; // $MIN: -0.5236 $MAX: 0.5236 $DEFAULT: 0.0 $DESCRIPTION: "blue rotation"
+
+ float master_outset_ratio; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "master purity boost"
+ float master_unrotation_ratio; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "master rotation reversal"
+ float red_outset; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "red purity boost"
+ float red_unrotation; // $MIN: -0.5236 $MAX: 0.5236 $DEFAULT: 0.0 $DESCRIPTION: "red reverse rotation"
+ float green_outset; // $MIN: 0.0 $MAX: 0.99 $DEFAULT: 0.0 $DESCRIPTION: "green purity boost"
+ float green_unrotation; // $MIN: -0.5236 $MAX: 0.5236 $DEFAULT: 0.0 $DESCRIPTION: "green reverse rotation"
+ float blue_outset; // $MIN: 0.0 $MAX: 0.99 $DEFAULT: 0.0 $DESCRIPTION: "blue purity boost"
+ float blue_unrotation; // $MIN: -0.5236 $MAX: 0.5236 $DEFAULT: 0.0 $DESCRIPTION: "blue reverse rotation"
+} dt_iop_agx_user_params_t;
+
+typedef struct dt_iop_basic_curve_controls_t
+{
+ GtkWidget *curve_pivot_x_shift;
+ GtkWidget *curve_pivot_y_linear;
+ GtkWidget *curve_contrast_around_pivot;
+ GtkWidget *curve_toe_power;
+ GtkWidget *curve_shoulder_power;
+} dt_iop_basic_curve_controls_t;
+
+typedef struct dt_iop_agx_gui_data_t
+{
+ GtkNotebook *notebook;
+ GtkWidget *auto_gamma;
+ GtkWidget *curve_gamma;
+ GtkDrawingArea *graph_drawing_area;
+ dt_gui_collapsible_section_t look_section;
+ dt_gui_collapsible_section_t graph_section;
+ dt_gui_collapsible_section_t advanced_section;
+
+ // Exposure pickers and their sliders
+ GtkWidget *range_exposure_picker;
+ GtkWidget *black_exposure_picker;
+ GtkWidget *white_exposure_picker;
+ GtkWidget *security_factor;
+
+ // the duplicated curve controls that appear on both the 'settings' and on the 'curve' page
+ dt_iop_basic_curve_controls_t basic_curve_controls_settings_page;
+ dt_iop_basic_curve_controls_t basic_curve_controls_curve_page;
+
+ // curve graph/plot
+ GtkAllocation allocation;
+ PangoRectangle ink;
+ GtkStyleContext *context;
+
+ GtkWidget *disable_primaries_adjustments;
+ GtkWidget *primaries_controls_vbox;
+ gboolean curve_tab_enabled;
+ GtkComboBoxText *primaries_preset_combo;
+ GtkWidget *primaries_preset_apply_button;
+} dt_iop_agx_gui_data_t;
+
+typedef struct curve_and_look_params_t
+{
+ float min_ev;
+ float max_ev;
+ float range_in_ev;
+ float curve_gamma;
+
+ // the toe runs from (t_lx = 0, target black) to (toe_transition_x, toe_transition_y)
+ float pivot_x;
+ float pivot_y;
+ float target_black; // t_ly
+ float toe_power; // t_p
+ float toe_transition_x; // t_tx
+ float toe_transition_y; // t_ty
+ float toe_scale; // t_s
+ gboolean need_convex_toe;
+ float toe_fallback_coefficient;
+ float toe_fallback_power;
+
+ // the linear section lies on y = mx + b, running from (toe_transition_x, toe_transition_y) to (shoulder_transition_x, shoulder_transition_y)
+ // it can have length 0, in which case it only contains the pivot (pivot_x, pivot_y)
+ // the pivot may coincide with toe_transition or shoulder_start or both
+ float slope; // m - for the linear section
+ float intercept; // b parameter of the straight segment (y = mx + b, intersection with the y-axis at (0, b))
+
+ // the shoulder runs from (shoulder_transition_x, shoulder_transition_y) to (s_lx = 1, target_white)
+ float target_white; // s_ly
+ float shoulder_power; // s_p
+ float shoulder_transition_x; // s_tx
+ float shoulder_transition_y; // s_ty
+ float shoulder_scale; // s_s
+ gboolean need_concave_shoulder;
+ float shoulder_fallback_coefficient;
+ float shoulder_fallback_power;
+
+ // look
+ float look_offset;
+ float look_slope;
+ float look_power;
+ float look_saturation;
+ float look_original_hue_mix_ratio;
+} tone_mapping_params_t;
+
+typedef struct primaries_params_t
+{
+ float inset[3];
+ float rotation[3];
+
+ float master_outset_ratio;
+ float master_unrotation_ratio;
+ float outset[3];
+ float unrotation[3];
+} primaries_params_t;
+
+typedef struct dt_iop_agx_data_t
+{
+ tone_mapping_params_t tone_mapping_params;
+ dt_colormatrix_t pipe_to_base_transposed;
+ dt_colormatrix_t base_to_rendering_transposed;
+ dt_colormatrix_t rendering_to_pipe_transposed;
+ dt_iop_order_iccprofile_info_t rendering_profile;
+} dt_iop_agx_data_t;
+
+// Primaries preset deduplication: hashtable key type, hash and equality functions
+typedef struct
+{
+ dt_iop_agx_base_primaries_t base_primaries;
+ gboolean disable_primaries_adjustments;
+ float red_inset;
+ float red_rotation;
+ float green_inset;
+ float green_rotation;
+ float blue_inset;
+ float blue_rotation;
+ float master_outset_ratio;
+ float master_unrotation_ratio;
+ float red_outset;
+ float red_unrotation;
+ float green_outset;
+ float green_unrotation;
+ float blue_outset;
+ float blue_unrotation;
+} _agx_primaries_key;
+
+// djb2 hash
+static inline guint _agx_primaries_hash(const gconstpointer p)
+{
+ guint hash = 5381;
+ const unsigned char *data = p;
+ size_t len = sizeof(_agx_primaries_key);
+
+ while(len-- > 0)
+ {
+ hash = (hash << 5) + hash + *data++;
+ }
+ return hash;
+}
+
+static inline gboolean _agx_primaries_equal(const gconstpointer a, const gconstpointer b)
+{
+ return memcmp(a, b, sizeof(_agx_primaries_key)) == 0;
+}
+
+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;
+}
+
+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)
+{
+ typedef dt_iop_agx_user_params_t dt_iop_agx_params_v2_t;
+
+ if (old_version == 1)
+ {
+ typedef struct dt_iop_agx_params_v1_t
+ {
+ float look_offset;
+ float look_slope;
+ float look_power;
+ float look_saturation;
+ float look_original_hue_mix_ratio;
+
+ // log mapping
+ float range_black_relative_exposure;
+ float range_white_relative_exposure;
+ float security_factor;
+
+ // curve params
+ float curve_pivot_x_shift;
+ float curve_pivot_y_linear;
+ float curve_contrast_around_pivot;
+ float curve_linear_percent_below_pivot;
+ float curve_linear_percent_above_pivot;
+ float curve_toe_power;
+ float curve_shoulder_power;
+ float curve_gamma;
+ gboolean auto_gamma;
+ float curve_target_display_black_percent;
+ float curve_target_display_white_percent;
+
+ // custom primaries
+ dt_iop_agx_base_primaries_t base_primaries;
+ // 'disable_primaries_adjustments' is missing here in v1
+ float red_inset;
+ float red_rotation;
+ float green_inset;
+ float green_rotation;
+ float blue_inset;
+ float blue_rotation;
+
+ float master_outset_ratio;
+ float master_unrotation_ratio;
+ float red_outset;
+ float red_unrotation;
+ float green_outset;
+ float green_unrotation;
+ float blue_outset;
+ float blue_unrotation;
+ } dt_iop_agx_params_v1_t;
+
+ dt_iop_agx_params_v2_t *np = calloc(1, sizeof(dt_iop_agx_params_v2_t));
+ const dt_iop_agx_params_v1_t *op = old_params;
+
+ // Because the new 'disable_primaries_adjustments' field was added in the middle of the struct,
+ // we must copy the data in two parts, around the new field.
+
+ // Part 1: All fields before 'disable_primaries_adjustments'.
+ const size_t part1_size = offsetof(dt_iop_agx_params_v2_t, disable_primaries_adjustments);
+ memcpy(np, op, part1_size);
+
+ // Initialize the new parameter to its default value.
+ np->disable_primaries_adjustments = FALSE;
+
+ // Part 2: All fields after 'disable_primaries_adjustments'.
+ const void *old_part2_start = &op->red_inset;
+ void *new_part2_start = &np->red_inset;
+ const size_t part2_size = sizeof(dt_iop_agx_params_v1_t) - offsetof(dt_iop_agx_params_v1_t, red_inset);
+ memcpy(new_part2_start, old_part2_start, part2_size);
+
+ // Set the output parameters for the framework.
+ *new_params = np;
+ *new_params_size = sizeof(dt_iop_agx_params_v2_t);
+ *new_version = 2;
+
+ return 0; // success
+ }
+
+ return 1; // no other conversion possible
+}
+
+static inline dt_colorspaces_color_profile_type_t
+_get_base_profile_type_from_enum(const dt_iop_agx_base_primaries_t base_primaries_enum)
+{
+ switch(base_primaries_enum)
+ {
+ case DT_AGX_SRGB:
+ return DT_COLORSPACE_SRGB;
+ case DT_AGX_DISPLAY_P3:
+ return DT_COLORSPACE_DISPLAY_P3;
+ case DT_AGX_ADOBE_RGB:
+ return DT_COLORSPACE_ADOBERGB;
+ case DT_AGX_REC2020: // Fall through
+ default:
+ return DT_COLORSPACE_LIN_REC2020; // Default/fallback
+ }
+}
+
+// Get the profile info struct based on the user selection
+static const dt_iop_order_iccprofile_info_t * _agx_get_base_profile(dt_develop_t *dev,
+ const dt_iop_order_iccprofile_info_t *pipe_work_profile,
+ const dt_iop_agx_base_primaries_t base_primaries_selection)
+{
+ dt_iop_order_iccprofile_info_t *selected_profile_info = NULL;
+
+ switch(base_primaries_selection)
+ {
+ case DT_AGX_EXPORT_PROFILE:
+ {
+ dt_colorspaces_color_profile_type_t export_type;
+ char export_filename[DT_IOP_COLOR_ICC_LEN];
+ dt_iop_color_intent_t export_intent;
+
+ // Get the configured export profile settings
+ const gboolean settings_ok = dt_ioppr_get_configured_export_profile_settings
+ (dev, &export_type, export_filename, sizeof(export_filename), &export_intent);
+
+ if (settings_ok)
+ {
+ selected_profile_info
+ = dt_ioppr_add_profile_info_to_list(dev, export_type, export_filename, export_intent);
+ if (!selected_profile_info || !dt_is_valid_colormatrix(selected_profile_info->matrix_in_transposed[0][0]))
+ {
+ dt_print(DT_DEBUG_PIPE, "[agx] Export profile '%s' unusable or missing matrix, falling back to Rec2020.",
+ dt_colorspaces_get_name(export_type, export_filename));
+ selected_profile_info = NULL; // Force fallback
+ }
+ }
+ else
+ {
+ dt_print(DT_DEBUG_ALWAYS,
+ "[agx] Failed to get configured export profile settings, falling back to Rec2020.");
+ // Fallback handled below
+ }
+ }
+ break;
+
+ case DT_AGX_WORK_PROFILE:
+ // Cast away const, as dt_ioppr_add_profile_info_to_list returns non-const
+ return (dt_iop_order_iccprofile_info_t *)pipe_work_profile;
+
+ case DT_AGX_REC2020:
+ case DT_AGX_DISPLAY_P3:
+ case DT_AGX_ADOBE_RGB:
+ case DT_AGX_SRGB:
+ {
+ const dt_colorspaces_color_profile_type_t profile_type
+ = _get_base_profile_type_from_enum(base_primaries_selection);
+ // Use relative intent for standard profiles when used as base
+ selected_profile_info
+ = dt_ioppr_add_profile_info_to_list(dev, profile_type, "", DT_INTENT_RELATIVE_COLORIMETRIC);
+ if (!selected_profile_info || !dt_is_valid_colormatrix(selected_profile_info->matrix_in_transposed[0][0]))
+ {
+ dt_print(DT_DEBUG_PIPE,
+ "[agx] Standard base profile '%s' unusable or missing matrix, falling back to Rec2020.",
+ dt_colorspaces_get_name(profile_type, ""));
+ selected_profile_info = NULL; // Force fallback
+ }
+ }
+ break;
+ }
+
+ // Fallback if selected profile wasn't found or was invalid
+ if (!selected_profile_info)
+ {
+ selected_profile_info =
+ dt_ioppr_add_profile_info_to_list(dev, DT_COLORSPACE_LIN_REC2020, "", DT_INTENT_RELATIVE_COLORIMETRIC);
+ // If even Rec2020 fails, something is very wrong, but let the caller handle NULL if necessary.
+ if (!selected_profile_info)
+ dt_print(DT_DEBUG_ALWAYS, "[agx] CRITICAL: Failed to get even Rec2020 base profile info.");
+ }
+
+ return selected_profile_info;
+}
+
+DT_OMP_DECLARE_SIMD(aligned(pixel : 16))
+static inline float _max(const dt_aligned_pixel_t pixel)
+{
+ return fmaxf(fmaxf(pixel[0], pixel[1]), pixel[2]);
+}
+
+DT_OMP_DECLARE_SIMD(aligned(pixel : 16))
+static inline float _min(const dt_aligned_pixel_t pixel)
+{
+ return fminf(fminf(pixel[0], pixel[1]), pixel[2]);
+}
+
+static inline float _luminance_from_matrix(const dt_aligned_pixel_t pixel,
+ const dt_colormatrix_t rgb_to_xyz_transposed)
+{
+ dt_aligned_pixel_t xyz;
+ dt_apply_transposed_color_matrix(pixel, rgb_to_xyz_transposed, xyz);
+ return xyz[1];
+}
+
+static inline float _luminance_from_profile(dt_aligned_pixel_t pixel,
+ const dt_iop_order_iccprofile_info_t *const profile)
+{
+ return dt_ioppr_get_rgb_matrix_luminance(pixel, profile->matrix_in, profile->lut_in,
+ profile->unbounded_coeffs_in, profile->lutsize, profile->nonlinearlut);
+}
+
+
+static inline float _line(const float x, const float slope, const float intercept)
+{
+ return slope * x + intercept;
+}
+
+/*
+ * s_t, s_t at https://www.desmos.com/calculator/yrysofmx8h
+ * The maths has been rewritten for symmetry, but is equivalent.
+ * Original:
+ * projected_rise = slope * (limit_x - transition_x)
+ * projected_rise_to_power = powf(projected_rise, -power)
+ * actual_rise = limit_y - transition_y
+ * linear_overshoot_ratio = projected_rise / actual_rise
+ * scale_adjustment_factor = powf(linear_overshoot_ratio, power) - 1
+ * base = projected_rise_to_power * scale_adjustment_factor
+ * scale_value = powf(base, -1 / power)
+ *
+ * We can substitute scale_adjustment_factor into base:
+ * base = projected_rise_to_power * (powf(linear_overshoot_ratio, power) - 1)
+ * Then substitute projected_rise_to_power and linear_overshoot_ratio:
+ * base = powf(projected_rise, -power) * (powf(projected_rise / actual_rise, power) - 1)
+ * Expand the brackets:
+ * base = powf(projected_rise, -power) * powf(projected_rise / actual_rise, power) - powf(projected_rise, -power)
+ * base = powf(projected_rise, -power) * powf(projected_rise, power) / powf(actual_rise, power) -
+ * powf(projected_rise, -power) base = 1 / powf(actual_rise, power) - powf(projected_rise, -power) base =
+ * powf(actual_rise, -power) - powf(projected_rise, -power)
+ */
+static float _scale(const float limit_x,
+ const float limit_y,
+ const float transition_x,
+ const float transition_y,
+ const float slope,
+ const float power)
+{
+ // the hypothetical 'rise' if the linear section were extended to the limit.
+ const float projected_rise = slope * fmaxf(_epsilon, limit_x - transition_x);
+
+ // the actual 'rise' the curve needs to cover.
+ const float actual_rise = fmaxf(_epsilon, limit_y - transition_y);
+
+ const float transformed_projected_rise = powf(projected_rise, -power);
+ const float transformed_actual_rise = powf(actual_rise, -power);
+
+ const float base = fmaxf(_epsilon, transformed_actual_rise - transformed_projected_rise);
+
+ const float scale_value = powf(base, -1.0f / power);
+
+ // avoid 'explosions'
+ return fminf(1e9, scale_value);
+}
+
+// f_t(x), f_s(x) at https://www.desmos.com/calculator/yrysofmx8h
+static inline float _sigmoid(const float x, const float power)
+{
+ return x / powf(1.0f + powf(x, power), 1.0f / power);
+}
+
+// f_ss, f_ts at https://www.desmos.com/calculator/yrysofmx8h
+static inline float _scaled_sigmoid(const float x,
+ const float scale,
+ const float slope,
+ const float power,
+ const float transition_x,
+ const float transition_y)
+{
+ return scale * _sigmoid(slope * (x - transition_x) / scale, power) + transition_y;
+}
+
+// Fallback toe/shoulder, so we can always reach black and white.
+// See https://www.desmos.com/calculator/gijzff3wlv
+static inline float _fallback_toe(const float x, const tone_mapping_params_t *params)
+{
+ return x <= 0 ? params->target_black
+ : params->target_black
+ + fmaxf(0, params->toe_fallback_coefficient * powf(x, params->toe_fallback_power));
+}
+
+static inline float _fallback_shoulder(const float x, const tone_mapping_params_t *params)
+{
+ return x >= 1 ? params->target_white
+ : params->target_white
+ - fmaxf(0, params->shoulder_fallback_coefficient
+ * powf(1 - x, params->shoulder_fallback_power));
+}
+
+static float _apply_curve(const float x, const tone_mapping_params_t *params)
+{
+ float result;
+
+ if (x < params->toe_transition_x)
+ {
+ result = params->need_convex_toe ? _fallback_toe(x, params)
+ : _scaled_sigmoid(x, params->toe_scale, params->slope, params->toe_power,
+ params->toe_transition_x, params->toe_transition_y);
+ }
+ else if (x <= params->shoulder_transition_x)
+ {
+ result = _line(x, params->slope, params->intercept);
+ }
+ else
+ {
+ result = params->need_concave_shoulder
+ ? _fallback_shoulder(x, params)
+ : _scaled_sigmoid(x, params->shoulder_scale, params->slope, params->shoulder_power,
+ params->shoulder_transition_x, params->shoulder_transition_y);
+ }
+ return CLAMPF(result, params->target_black, params->target_white);
+}
+
+static inline float _sanitize_hue(float hue)
+{
+ if (hue < 0.0f) hue += 1.0f;
+ if (hue >= 1.0f) hue -= 1.0f;
+ return hue;
+}
+
+// 'lerp', but take care of the boundary: hue wraps around 1->0
+static inline float _lerp_hue(float original_hue, float processed_hue, const float mix)
+{
+ original_hue = _sanitize_hue(original_hue);
+ processed_hue = _sanitize_hue(processed_hue);
+
+ const float hue_diff = processed_hue - original_hue;
+
+ if (hue_diff > 0.5)
+ {
+ processed_hue -= 1;
+ }
+ else if (hue_diff < -0.5)
+ {
+ processed_hue += 1;
+ }
+
+ const float restored_hue = processed_hue + (original_hue - processed_hue) * mix;
+ return _sanitize_hue(restored_hue);
+}
+
+static inline float _apply_slope_offset(const float x, const float slope, const float offset)
+{
+ // negative offset should darken the image; positive brighten it
+ // without the scale: m = 1 / (1 + offset)
+ // offset = 1, slope = 1, x = 0 -> m = 1 / (1+1) = 1/2, b = 1 * 1/2 = 1/2, y = 1/2*0 + 1/2 = 1/2
+ const float m = slope / (1 + offset);
+ const float b = offset * m;
+ return m * x + b;
+ // ASC CDL:
+ // return x * slope + offset;
+ // alternative:
+ // y = mx + b, b is the offset, m = (1 - offset), so the line runs from (0, offset) to (1, 1)
+ // return (1 - offset) * x + offset;
+}
+
+// https://docs.acescentral.com/specifications/acescct/#appendix-a-application-of-asc-cdl-parameters-to-acescct-image-data
+DT_OMP_DECLARE_SIMD(aligned(pixel_in_out : 16))
+static void _agx_look(dt_aligned_pixel_t pixel_in_out,
+ const tone_mapping_params_t *params,
+ const dt_colormatrix_t rendering_to_xyz_transposed)
+{
+ const float slope = params->look_slope;
+ const float offset = params->look_offset;
+ const float power = params->look_power;
+ const float sat = params->look_saturation;
+
+ // ASC CDL (Slope, Offset, Power) per channel
+ for_three_channels(k, aligned(pixel_in_out : 16))
+ {
+ const float slope_and_offset_val = _apply_slope_offset(pixel_in_out[k], slope, offset);
+ pixel_in_out[k] = slope_and_offset_val > 0.0f ? powf(slope_and_offset_val, power) : slope_and_offset_val;
+ }
+
+ const float luma = _luminance_from_matrix(pixel_in_out, rendering_to_xyz_transposed);
+
+ // saturation
+ for_three_channels(k, aligned(pixel_in_out : 16))
+ {
+ pixel_in_out[k] = luma + sat * (pixel_in_out[k] - luma);
+ }
+}
+
+static float _apply_log_encoding(float x, const float range_in_ev, const float min_ev)
+{
+ // Assume input is linear RGB relative to 0.18 mid-gray
+ // Ensure value > 0 before log
+ x = fmaxf(_epsilon, x / 0.18f);
+ // normalise to [0, 1] based on min_ev and range_in_ev
+ const float mapped = (log2f(fmaxf(x, 0)) - min_ev) / range_in_ev;
+ // Clamp result to [0, 1] - this is the input domain for the curve
+ return CLAMPF(mapped, 0.0f, 1.0f);
+}
+
+// see https://www.desmos.com/calculator/gijzff3wlv
+static inline float _calculate_slope_matching_power(const float slope,
+ const float dx_transition_to_limit,
+ const float dy_transition_to_limit)
+{
+ return slope * dx_transition_to_limit / dy_transition_to_limit;
+}
+
+static inline float _calculate_fallback_curve_coefficient(const float dx_transition_to_limit,
+ const float dy_transition_to_limit, const float B)
+{
+ return dy_transition_to_limit / powf(dx_transition_to_limit, B);
+}
+
+static inline void _compress_into_gamut(dt_aligned_pixel_t pixel_in_out)
+{
+ // Blender: https://github.com/EaryChow/AgX_LUT_Gen/blob/main/luminance_compenstation_bt2020.py
+ // Calculate original luminance
+ const float luminance_coeffs[] = { 0.2658180370250449, 0.59846986045365, 0.1357121025213052 };
+
+ const float input_y = pixel_in_out[0] * luminance_coeffs[0] + pixel_in_out[1] * luminance_coeffs[1]
+ + pixel_in_out[2] * luminance_coeffs[2];
+
+ // Calculate luminance of the opponent color, and use it to compensate for negative luminance values
+ const float max_rgb = _max(pixel_in_out);
+ dt_aligned_pixel_t inverse_rgb;
+ for_each_channel(c, aligned(inverse_rgb, pixel_in_out))
+ {
+ inverse_rgb[c] = max_rgb - pixel_in_out[c];
+ }
+ const float max_inverse = _max(inverse_rgb);
+
+ const float inverse_y = inverse_rgb[0] * luminance_coeffs[0] + inverse_rgb[1] * luminance_coeffs[1]
+ + inverse_rgb[2] * luminance_coeffs[2];
+
+ const float y_compensate_negative = max_inverse - inverse_y + input_y;
+
+ // Offset the input tristimulus such that there are no negatives
+ const float min_rgb = _min(pixel_in_out);
+ const float offset = fmaxf(-min_rgb, 0);
+ dt_aligned_pixel_t rgb_offset;
+ for_each_channel(c, aligned(rgb_offset, pixel_in_out))
+ {
+ rgb_offset[c] = pixel_in_out[c] + offset;
+ }
+
+ const float max_of_rgb_offset = _max(rgb_offset);
+
+ // Calculate luminance of the opponent color, and use it to compensate for negative luminance values
+ dt_aligned_pixel_t inverse_rgb_offset;
+ for_each_channel(c, aligned(inverse_rgb_offset, rgb_offset))
+ {
+ inverse_rgb_offset[c] = max_of_rgb_offset - rgb_offset[c];
+ }
+
+ const float max_inverse_rgb_offset = _max(inverse_rgb_offset);
+ const float y_inverse_rgb_offset = inverse_rgb_offset[0] * luminance_coeffs[0]
+ + inverse_rgb_offset[1] * luminance_coeffs[1]
+ + inverse_rgb_offset[2] * luminance_coeffs[2];
+ float y_new = rgb_offset[0] * luminance_coeffs[0] + rgb_offset[1] * luminance_coeffs[1]
+ + rgb_offset[2] * luminance_coeffs[2];
+ y_new = max_inverse_rgb_offset - y_inverse_rgb_offset + y_new;
+
+ // Compensate the intensity to match the original luminance
+ const float luminance_ratio = y_new > y_compensate_negative ? y_compensate_negative / y_new : 1.0;
+ for_each_channel(c, aligned(pixel_in_out, rgb_offset))
+ {
+ pixel_in_out[c] = luminance_ratio * rgb_offset[c];
+ }
+}
+
+static void _adjust_pivot(const dt_iop_agx_user_params_t *user_params, tone_mapping_params_t *params)
+{
+ const float mid_gray_in_log_range = fabsf(params->min_ev / params->range_in_ev);
+ if (user_params->curve_pivot_x_shift < 0)
+ {
+ const float black_ratio = -user_params->curve_pivot_x_shift;
+ const float gray_ratio = 1 - black_ratio;
+ params->pivot_x = gray_ratio * mid_gray_in_log_range;
+ }
+ else if (user_params->curve_pivot_x_shift > 0)
+ {
+ const float white_ratio = user_params->curve_pivot_x_shift;
+ const float gray_ratio = 1 - white_ratio;
+ params->pivot_x = mid_gray_in_log_range * gray_ratio + white_ratio;
+ }
+ else
+ {
+ params->pivot_x = mid_gray_in_log_range;
+ }
+
+ // don't allow pivot_x to touch the endpoints
+ params->pivot_x = CLAMPF(params->pivot_x, _epsilon, 1 - _epsilon);
+
+ if (user_params->auto_gamma)
+ {
+ params->curve_gamma = params->pivot_x > 0 && user_params->curve_pivot_y_linear > 0
+ ? log2f(user_params->curve_pivot_y_linear) / log2f(params->pivot_x)
+ : user_params->curve_gamma;
+ }
+ else
+ {
+ params->curve_gamma = user_params->curve_gamma;
+ }
+
+ params->pivot_y
+ = powf(CLAMPF(user_params->curve_pivot_y_linear, user_params->curve_target_display_black_percent / 100.0,
+ user_params->curve_target_display_white_percent / 100.0),
+ 1.0f / params->curve_gamma);
+}
+
+static void _calculate_log_mapping_params(const dt_iop_agx_user_params_t *user_params,
+ tone_mapping_params_t *curve_and_look_params)
+{
+ curve_and_look_params->max_ev = user_params->range_white_relative_exposure;
+ curve_and_look_params->min_ev = user_params->range_black_relative_exposure;
+ curve_and_look_params->range_in_ev = curve_and_look_params->max_ev - curve_and_look_params->min_ev;
+}
+
+static tone_mapping_params_t _calculate_tone_mapping_params(const dt_iop_agx_user_params_t *user_params)
+{
+ tone_mapping_params_t tone_mapping_params;
+
+ // look
+ tone_mapping_params.look_offset = user_params->look_offset;
+ tone_mapping_params.look_slope = user_params->look_slope;
+ tone_mapping_params.look_saturation = user_params->look_saturation;
+ tone_mapping_params.look_power = user_params->look_power;
+ tone_mapping_params.look_original_hue_mix_ratio = user_params->look_original_hue_mix_ratio;
+
+ // log mapping
+ _calculate_log_mapping_params(user_params, &tone_mapping_params);
+
+ _adjust_pivot(user_params, &tone_mapping_params);
+
+ // avoid range altering slope - 16.5 EV is the default AgX range; keep the meaning of slope
+ tone_mapping_params.slope = user_params->curve_contrast_around_pivot * (tone_mapping_params.range_in_ev / 16.5f);
+
+ // toe
+ tone_mapping_params.target_black
+ = powf(user_params->curve_target_display_black_percent / 100.0f, 1.0f / tone_mapping_params.curve_gamma);
+ tone_mapping_params.toe_power = user_params->curve_toe_power;
+
+ const float remaining_y_below_pivot = tone_mapping_params.pivot_y - tone_mapping_params.target_black;
+ const float toe_length_y = remaining_y_below_pivot * user_params->curve_linear_percent_below_pivot / 100.0f;
+ float dx_linear_below_pivot = toe_length_y / tone_mapping_params.slope;
+ // ...and subtract it from pivot_x to get the x coordinate where the linear section joins the toe
+ // ... but keep the transition point above x = 0
+ tone_mapping_params.toe_transition_x = fmaxf(_epsilon, tone_mapping_params.pivot_x - dx_linear_below_pivot);
+ // fix up in case the limitation kicked in
+ dx_linear_below_pivot = tone_mapping_params.pivot_x - tone_mapping_params.toe_transition_x;
+
+ // from the 'run' pivot_x->toe_transition_x, we calculate the 'rise'
+ const float toe_y_below_pivot_y = tone_mapping_params.slope * dx_linear_below_pivot;
+ tone_mapping_params.toe_transition_y = tone_mapping_params.pivot_y - toe_y_below_pivot_y;
+
+ // we use the same calculation as for the shoulder, so we flip the toe left <-> right, up <-> down
+ const float inverse_toe_limit_x = 1.0f; // 1 - toe_limix_x (toe_limix_x = 0, so inverse = 1)
+ const float inverse_toe_limit_y = 1.0f - tone_mapping_params.target_black; // Inverse limit y
+
+ const float inverse_toe_transition_x = 1.0f - tone_mapping_params.toe_transition_x;
+ const float inverse_toe_transition_y = 1.0f - tone_mapping_params.toe_transition_y;
+
+ // and then flip the scale
+ tone_mapping_params.toe_scale = -_scale(inverse_toe_limit_x, inverse_toe_limit_y,
+ inverse_toe_transition_x, inverse_toe_transition_y,
+ tone_mapping_params.slope, tone_mapping_params.toe_power);
+
+ // limit_x is 0 -> dx to limit is just toe_transition_x
+ // use epsilon to avoid division by 0 later
+ const float toe_dx_transition_to_limit = fmaxf(_epsilon, tone_mapping_params.toe_transition_x);
+ const float toe_dy_transition_to_limit
+ = fmaxf(_epsilon, tone_mapping_params.toe_transition_y - tone_mapping_params.target_black);
+ const float toe_slope_transition_to_limit = toe_dy_transition_to_limit / toe_dx_transition_to_limit;
+ tone_mapping_params.need_convex_toe = toe_slope_transition_to_limit > tone_mapping_params.slope;
+
+ // toe fallback curve params
+ tone_mapping_params.toe_fallback_power = _calculate_slope_matching_power(
+ tone_mapping_params.slope, toe_dx_transition_to_limit, toe_dy_transition_to_limit);
+ tone_mapping_params.toe_fallback_coefficient = _calculate_fallback_curve_coefficient(
+ toe_dx_transition_to_limit, toe_dy_transition_to_limit, tone_mapping_params.toe_fallback_power);
+
+ // if x went from toe_transition_x to 0, at the given slope, starting from toe_transition_y, where would we
+ // intersect the y-axis?
+ tone_mapping_params.intercept
+ = tone_mapping_params.toe_transition_y - tone_mapping_params.slope * tone_mapping_params.toe_transition_x;
+
+ // shoulder
+ tone_mapping_params.target_white
+ = powf(user_params->curve_target_display_white_percent / 100.0, 1.0f / tone_mapping_params.curve_gamma);
+ const float remaining_y_above_pivot = tone_mapping_params.target_white - tone_mapping_params.pivot_y;
+ const float shoulder_length_y = remaining_y_above_pivot * user_params->curve_linear_percent_above_pivot / 100.0f;
+ float dx_linear_above_pivot = shoulder_length_y / tone_mapping_params.slope;
+
+ // don't allow shoulder_transition_x to reach 1
+ tone_mapping_params.shoulder_transition_x
+ = fminf(1 - _epsilon, tone_mapping_params.pivot_x + dx_linear_above_pivot);
+ dx_linear_above_pivot = tone_mapping_params.pivot_x - tone_mapping_params.shoulder_transition_x;
+ tone_mapping_params.shoulder_transition_y = tone_mapping_params.pivot_y + shoulder_length_y;
+ tone_mapping_params.shoulder_power = user_params->curve_shoulder_power;
+
+ const float shoulder_limit_x = 1;
+ tone_mapping_params.shoulder_scale = _scale(
+ shoulder_limit_x, tone_mapping_params.target_white, tone_mapping_params.shoulder_transition_x,
+ tone_mapping_params.shoulder_transition_y, tone_mapping_params.slope, tone_mapping_params.shoulder_power);
+
+ const float shoulder_dx_transition_to_limit
+ = fmaxf(_epsilon, 1 - tone_mapping_params.shoulder_transition_x); // dx to 0, avoid division by 0 later
+ const float shoulder_dy_transition_to_limit
+ = fmaxf(_epsilon, tone_mapping_params.target_white - tone_mapping_params.shoulder_transition_y);
+ const float shoulder_slope_transition_to_limit
+ = shoulder_dy_transition_to_limit / shoulder_dx_transition_to_limit;
+ tone_mapping_params.need_concave_shoulder = shoulder_slope_transition_to_limit > tone_mapping_params.slope;
+
+ // shoulder fallback curve params
+ tone_mapping_params.shoulder_fallback_power = _calculate_slope_matching_power(
+ tone_mapping_params.slope, shoulder_dx_transition_to_limit, shoulder_dy_transition_to_limit);
+ tone_mapping_params.shoulder_fallback_coefficient
+ = _calculate_fallback_curve_coefficient(shoulder_dx_transition_to_limit, shoulder_dy_transition_to_limit,
+ tone_mapping_params.shoulder_fallback_power);
+
+ return tone_mapping_params;
+}
+
+static primaries_params_t _get_primaries_params(const dt_iop_agx_user_params_t *user_params)
+{
+ primaries_params_t primaries_params;
+
+ primaries_params.inset[0] = user_params->red_inset;
+ primaries_params.inset[1] = user_params->green_inset;
+ primaries_params.inset[2] = user_params->blue_inset;
+ primaries_params.rotation[0] = user_params->red_rotation;
+ primaries_params.rotation[1] = user_params->green_rotation;
+ primaries_params.rotation[2] = user_params->blue_rotation;
+ primaries_params.master_outset_ratio = user_params->master_outset_ratio;
+ primaries_params.master_unrotation_ratio = user_params->master_unrotation_ratio;
+ primaries_params.outset[0] = user_params->red_outset;
+ primaries_params.outset[1] = user_params->green_outset;
+ primaries_params.outset[2] = user_params->blue_outset;
+ primaries_params.unrotation[0] = user_params->red_unrotation;
+ primaries_params.unrotation[1] = user_params->green_unrotation;
+ primaries_params.unrotation[2] = user_params->blue_unrotation;
+
+ if (user_params->disable_primaries_adjustments)
+ {
+ for(int i = 0; i < 3; i++)
+ primaries_params.inset[i] = primaries_params.rotation[i] = primaries_params.outset[i]
+ = primaries_params.unrotation[i] = 0.0f;
+ }
+
+ return primaries_params;
+}
+
+static void _agx_tone_mapping(dt_aligned_pixel_t rgb_in_out, const tone_mapping_params_t *params,
+ const dt_colormatrix_t rendering_to_xyz_transposed)
+{
+ // record current chromaticity angle
+ dt_aligned_pixel_t hsv_pixel = { 0.0f };
+ dt_RGB_2_HSV(rgb_in_out, hsv_pixel);
+ const float h_before = hsv_pixel[0];
+
+ dt_aligned_pixel_t transformed_pixel = { 0.0f };
+
+ for_three_channels(k, aligned(rgb_in_out, transformed_pixel : 16))
+ {
+ const float log_value = _apply_log_encoding(rgb_in_out[k], params->range_in_ev, params->min_ev);
+ transformed_pixel[k] = _apply_curve(log_value, params);
+ }
+
+ _agx_look(transformed_pixel, params, rendering_to_xyz_transposed);
+
+ // Linearize
+ for_three_channels(k, aligned(transformed_pixel : 16))
+ {
+ transformed_pixel[k] = powf(fmaxf(0.0f, transformed_pixel[k]), params->curve_gamma);
+ }
+
+ // get post-curve chroma angle
+ dt_RGB_2_HSV(transformed_pixel, hsv_pixel);
+
+ float h_after = hsv_pixel[0];
+
+ // Mix hue back if requested
+ h_after = _lerp_hue(h_before, h_after, params->look_original_hue_mix_ratio);
+
+ hsv_pixel[0] = h_after;
+ dt_HSV_2_RGB(hsv_pixel, rgb_in_out);
+}
+
+// Apply logic for black point picker
+static void apply_auto_black_exposure(dt_iop_module_t *self)
+{
+ if (darktable.gui->reset) return;
+ dt_iop_agx_user_params_t *user_params = self->params;
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+
+ const float black_norm = _min(self->picked_color_min);
+ user_params->range_black_relative_exposure = CLAMPF(log2f(fmaxf(_epsilon, black_norm) / 0.18f), -20.0f, -0.1f)
+ * (1.0f + user_params->security_factor / 100.0f);
+
+ ++darktable.gui->reset;
+ dt_bauhaus_slider_set(gui_data->black_exposure_picker, user_params->range_black_relative_exposure);
+ --darktable.gui->reset;
+
+ gtk_widget_queue_draw(GTK_WIDGET(gui_data->graph_drawing_area));
+}
+
+// Apply logic for white point picker
+static void apply_auto_white_exposure(dt_iop_module_t *self)
+{
+ if (darktable.gui->reset) return;
+ dt_iop_agx_user_params_t *user_params = self->params;
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+
+ const float white_norm = _max(self->picked_color_max);
+ user_params->range_white_relative_exposure = CLAMPF(log2f(fmaxf(_epsilon, white_norm) / 0.18f), 0.1f, 20.0f)
+ * (1.0f + user_params->security_factor / 100.0f);
+
+ ++darktable.gui->reset;
+ dt_bauhaus_slider_set(gui_data->white_exposure_picker, user_params->range_white_relative_exposure);
+ --darktable.gui->reset;
+}
+
+// Apply logic for auto-tuning both black and white points
+static void apply_auto_tune_exposure(dt_iop_module_t *self)
+{
+ if (darktable.gui->reset) return;
+ dt_iop_agx_user_params_t *user_params = self->params;
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+
+ // Black point
+ const float black_norm = _min(self->picked_color_min);
+ user_params->range_black_relative_exposure = CLAMPF(log2f(fmaxf(_epsilon, black_norm) / 0.18f), -20.0f, -0.1f)
+ * (1.0f + user_params->security_factor / 100.0f);
+
+ // White point
+ const float white_norm = _max(self->picked_color_max);
+ user_params->range_white_relative_exposure = CLAMPF(log2f(fmaxf(_epsilon, white_norm) / 0.18f), 0.1f, 20.0f)
+ * (1.0f + user_params->security_factor / 100.0f);
+
+ ++darktable.gui->reset;
+ dt_bauhaus_slider_set(gui_data->black_exposure_picker, user_params->range_black_relative_exposure);
+ dt_bauhaus_slider_set(gui_data->white_exposure_picker, user_params->range_white_relative_exposure);
+ --darktable.gui->reset;
+}
+
+// Apply logic for pivot x picker
+static void apply_auto_pivot_x(dt_iop_module_t *self, const dt_iop_order_iccprofile_info_t *profile)
+{
+ if (darktable.gui->reset) return;
+ dt_iop_agx_user_params_t *user_params = self->params;
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+
+ // Calculate norm and EV of the picked color
+ const float norm = _luminance_from_profile(self->picked_color, profile);
+ const float picked_ev = log2f(fmaxf(_epsilon, norm) / 0.18f);
+
+ // Calculate the target pivot_x based on the picked EV and the current EV range
+ const float min_ev = user_params->range_black_relative_exposure;
+ const float max_ev = user_params->range_white_relative_exposure;
+ const float range_in_ev = fmaxf(_epsilon, max_ev - min_ev);
+ const float target_pivot_x = CLAMPF((picked_ev - min_ev) / range_in_ev, 0.0f, 1.0f);
+
+ // Calculate the required pivot_x_shift to achieve the target_pivot_x
+ const float base_pivot_x = fabsf(min_ev / range_in_ev); // Pivot representing 0 EV (mid-gray)
+
+ dt_iop_agx_user_params_t params_with_mid_gray = *user_params;
+ params_with_mid_gray.curve_pivot_y_linear = 0.18;
+ params_with_mid_gray.curve_pivot_x_shift = 0;
+
+ const tone_mapping_params_t tone_mapping_params = _calculate_tone_mapping_params(¶ms_with_mid_gray);
+
+ // see where the target_pivot would be mapped with defaults of mid-gray to mid-gray mapped
+ const float target_y = _apply_curve(target_pivot_x, &tone_mapping_params);
+ // try to avoid changing the brightness of the pivot
+ const float target_y_linearised = powf(target_y, tone_mapping_params.curve_gamma);
+ user_params->curve_pivot_y_linear = target_y_linearised;
+
+ float shift;
+ if (fabsf(target_pivot_x - base_pivot_x) < _epsilon)
+ {
+ shift = 0.0f;
+ }
+ else if (base_pivot_x > target_pivot_x)
+ {
+ // Solve target_pivot_x = (1 + s) * base_pivot_x for s
+ shift = (base_pivot_x > _epsilon) ? (target_pivot_x / base_pivot_x) - 1.0f : -1.0f;
+ }
+ else // target_pivot_x > base_pivot_x
+ {
+ // Solve target_pivot_x = base_pivot_x * (1 - s) + s for s
+ const float denominator = 1.0f - base_pivot_x;
+ shift = (denominator > _epsilon) ? (target_pivot_x - base_pivot_x) / denominator : 1.0f;
+ }
+
+ user_params->curve_pivot_x_shift = CLAMPF(shift, -1.0f, 1.0f);
+
+ // Update the slider visually
+ ++darktable.gui->reset;
+ dt_bauhaus_slider_set(gui_data->basic_curve_controls_settings_page.curve_pivot_x_shift,
+ user_params->curve_pivot_x_shift);
+ dt_bauhaus_slider_set(gui_data->basic_curve_controls_settings_page.curve_pivot_y_linear,
+ user_params->curve_pivot_y_linear);
+ if (gui_data->curve_tab_enabled)
+ {
+ dt_bauhaus_slider_set(gui_data->basic_curve_controls_curve_page.curve_pivot_x_shift,
+ user_params->curve_pivot_x_shift);
+ dt_bauhaus_slider_set(gui_data->basic_curve_controls_curve_page.curve_pivot_y_linear,
+ user_params->curve_pivot_y_linear);
+ }
+ --darktable.gui->reset;
+}
+
+static void _create_matrices(const dt_iop_agx_user_params_t *user_params,
+ const dt_iop_order_iccprofile_info_t *pipe_work_profile,
+ const dt_iop_order_iccprofile_info_t *base_profile,
+ // outputs
+ dt_colormatrix_t rendering_to_xyz_transposed,
+ dt_colormatrix_t pipe_to_base_transposed,
+ dt_colormatrix_t base_to_rendering_transposed,
+ dt_colormatrix_t rendering_to_pipe_transposed)
+{
+ const primaries_params_t params = _get_primaries_params(user_params);
+
+ // Make adjusted primaries for generating the inset matrix
+ //
+ // References:
+ // AgX by Troy Sobotka - https://github.com/sobotka/AgX-S2O3
+ // Related discussions on Blender Artists forums -
+ // https://blenderartists.org/t/feedback-development-filmic-baby-step-to-a-v2/1361663
+ //
+ // The idea is to "inset" the work RGB data toward achromatic
+ // along spectral lines before per-channel curves. This makes
+ // handling of bright, saturated colors much better as the
+ // per-channel process desaturates them.
+ // The primaries are also rotated to compensate for Abney etc.
+ // and achieve a favourable shift towards yellow.
+
+ // First, calculate the matrix from pipe the work profile to the base profile whose primaries
+ // will be rotated/inset.
+ dt_colormatrix_mul(pipe_to_base_transposed,
+ pipe_work_profile->matrix_in_transposed, // pipe->XYZ
+ base_profile->matrix_out_transposed); // XYZ->base
+
+ dt_colormatrix_t base_to_pipe_transposed;
+ mat3SSEinv(base_to_pipe_transposed, pipe_to_base_transposed);
+
+ // inbound path, base RGB->inset and rotated rendering space for the curve
+
+ // Rotated, scaled primaries are calculated based on the base profile.
+ float inset_and_rotated_primaries[3][2];
+ for(size_t i = 0; i < 3; i++)
+ dt_rotate_and_scale_primary(base_profile, 1.f - params.inset[i], params.rotation[i], i,
+ inset_and_rotated_primaries[i]);
+
+ // The matrix to convert from the inset/rotated to XYZ. When applying to the RGB values that are actually
+ // in the 'base' space, it will convert them to XYZ coordinates that represent colors that are partly
+ // desaturated (due to the inset) and skewed (do to the rotation).
+ dt_make_transposed_matrices_from_primaries_and_whitepoint(inset_and_rotated_primaries, base_profile->whitepoint,
+ rendering_to_xyz_transposed);
+
+ // The matrix to convert colors from the original 'base' space to their partially desaturated and skewed
+ // versions, using the inset RGB->XYZ and the original base XYZ->RGB matrices.
+ dt_colormatrix_mul(base_to_rendering_transposed, rendering_to_xyz_transposed,
+ base_profile->matrix_out_transposed);
+
+ // outbound path, inset and rotated working space for the curve->base RGB
+
+ // Rotated, primaries, with optional restoration of purity. This is to be applied after the sigmoid curve;
+ // it can undo the skew and recover purity (saturation).
+ float outset_and_unrotated_primaries[3][2];
+ for(size_t i = 0; i < 3; i++)
+ {
+ const float scaling = 1.f - params.master_outset_ratio * params.outset[i];
+ dt_rotate_and_scale_primary(base_profile, scaling, params.master_unrotation_ratio * params.unrotation[i], i,
+ outset_and_unrotated_primaries[i]);
+ }
+
+ // The matrix to convert the curve's output to XYZ; the primaries reflect the fact that the curve's output
+ // was inset and skewed at the start of the process.
+ // Its inverse (see the next steps), when applied to RGB values in the curve's working space (which actually uses
+ // the base primaries), will undo the rotation and, depending on purity, push colours further from achromatic,
+ // resaturating them.
+ dt_colormatrix_t outset_and_unrotated_to_xyz_transposed;
+ dt_make_transposed_matrices_from_primaries_and_whitepoint(
+ outset_and_unrotated_primaries, base_profile->whitepoint, outset_and_unrotated_to_xyz_transposed);
+
+ dt_colormatrix_t tmp;
+ dt_colormatrix_mul(tmp,
+ outset_and_unrotated_to_xyz_transposed, // custom (outset, unrotation)->XYZ
+ base_profile->matrix_out_transposed // XYZ->base
+ );
+
+ // 'tmp' is constructed the same way as inbound_inset_and_rotated_to_xyz_transposed,
+ // but this matrix will be used to remap colours to the 'base' profile, so we need to invert it.
+ dt_colormatrix_t rendering_to_base_transposed;
+ mat3SSEinv(rendering_to_base_transposed, tmp);
+
+ dt_colormatrix_mul(rendering_to_pipe_transposed, rendering_to_base_transposed, base_to_pipe_transposed);
+}
+
+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)
+{
+ if (!dt_iop_have_required_input_format(4, self, piece->colors, ivoid, ovoid, roi_in, roi_out))
+ {
+ return;
+ }
+
+ const dt_iop_agx_data_t *processing_params = piece->data;
+ const float *const in = ivoid;
+ float *const out = ovoid;
+ const size_t n_pixels = (size_t)roi_in->width * roi_in->height;
+
+ DT_OMP_FOR()
+ for(size_t k = 0; k < 4 * n_pixels; k += 4)
+ {
+ const float *const restrict pix_in = in + k;
+ float *const restrict pix_out = out + k;
+
+ // Convert from pipe working space to base space
+ dt_aligned_pixel_t base_rgb;
+ dt_apply_transposed_color_matrix(pix_in, processing_params->pipe_to_base_transposed, base_rgb);
+
+ _compress_into_gamut(base_rgb);
+
+ dt_aligned_pixel_t rendering_rgb;
+ dt_apply_transposed_color_matrix(base_rgb, processing_params->base_to_rendering_transposed, rendering_rgb);
+
+ // Apply the tone mapping curve and look adjustments
+ _agx_tone_mapping(rendering_rgb, &processing_params->tone_mapping_params,
+ processing_params->rendering_profile.matrix_in_transposed);
+
+ // Convert from internal rendering space back to pipe working space
+ dt_apply_transposed_color_matrix(rendering_rgb, processing_params->rendering_to_pipe_transposed, pix_out);
+
+ // Copy over the alpha channel
+ pix_out[3] = pix_in[3];
+ }
+}
+
+// Plot the curve
+static gboolean _agx_draw_curve(GtkWidget *widget, cairo_t *crf, const dt_iop_module_t *self)
+{
+ const dt_iop_agx_user_params_t *user_params = self->params;
+ dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+
+ const tone_mapping_params_t tone_mapping_params = _calculate_tone_mapping_params(user_params);
+
+ // --- Boilerplate cairo/pango setup ---
+ gtk_widget_get_allocation(widget, &gui_data->allocation);
+ gui_data->allocation.height -= DT_RESIZE_HANDLE_SIZE;
+
+ cairo_surface_t *cst = dt_cairo_image_surface_create(CAIRO_FORMAT_ARGB32, gui_data->allocation.width,
+ gui_data->allocation.height);
+ PangoFontDescription *desc = pango_font_description_copy_static(darktable.bauhaus->pango_font_desc);
+ cairo_t *cr = cairo_create(cst);
+ PangoLayout *layout = pango_cairo_create_layout(cr);
+
+ pango_layout_set_font_description(layout, desc);
+ pango_cairo_context_set_resolution(pango_layout_get_context(layout), darktable.gui->dpi);
+ gui_data->context = gtk_widget_get_style_context(widget);
+
+ char text[256];
+
+ // Get text metrics
+ const gint font_size = pango_font_description_get_size(desc);
+ pango_font_description_set_size(desc, 0.95 * font_size); // Slightly smaller font for graph
+ pango_layout_set_font_description(layout, desc);
+
+ g_strlcpy(text, "X", sizeof(text));
+ pango_layout_set_text(layout, text, -1);
+ pango_layout_get_pixel_extents(layout, &gui_data->ink, NULL);
+ const float line_height = gui_data->ink.height;
+
+ // Set graph dimensions and margins (simplified from filmic)
+ const int inner_padding = DT_PIXEL_APPLY_DPI(4);
+ const int inset = inner_padding;
+ const float margin_left = 3. * line_height + 2. * inset; // Room for Y labels
+ const float margin_bottom = 2. * line_height + 2. * inset; // Room for X labels
+ const float margin_top = inset + 0.5 * line_height;
+ const float margin_right = inset;
+
+ const float graph_width = gui_data->allocation.width - margin_right - margin_left;
+ const float graph_height = gui_data->allocation.height - margin_bottom - margin_top;
+
+ // --- Drawing starts ---
+ gtk_render_background(gui_data->context, cr, 0, 0, gui_data->allocation.width, gui_data->allocation.height);
+
+ // Translate origin to bottom-left of graph area for easier plotting
+ cairo_translate(cr, margin_left, margin_top + graph_height);
+ cairo_scale(cr, 1., -1.); // Flip Y axis
+
+ // Draw graph background and border
+ cairo_rectangle(cr, 0, 0, graph_width, graph_height);
+ set_color(cr, darktable.bauhaus->graph_bg);
+ cairo_fill_preserve(cr);
+ set_color(cr, darktable.bauhaus->graph_border);
+ cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(0.5));
+ cairo_stroke(cr);
+
+ // Draw identity line (y=x)
+ cairo_save(cr);
+ cairo_set_source_rgba(cr, darktable.bauhaus->graph_border.red, darktable.bauhaus->graph_border.green,
+ darktable.bauhaus->graph_border.blue, 0.5);
+ cairo_move_to(cr, 0, 0);
+ cairo_line_to(cr, graph_width, graph_height);
+ cairo_stroke(cr);
+ cairo_restore(cr);
+
+ // --- Draw Gamma Guide Lines ---
+ cairo_save(cr);
+ // Use a distinct style for guides, e.g., dashed and semi-transparent
+ set_color(cr, darktable.bauhaus->graph_fg); // Use foreground color for now
+ cairo_set_source_rgba(cr, darktable.bauhaus->graph_fg.red, darktable.bauhaus->graph_fg.green,
+ darktable.bauhaus->graph_fg.blue, 0.4); // Make it semi-transparent
+ const double dashes[] = { 4.0 / darktable.gui->ppd, 4.0 / darktable.gui->ppd }; // 4px dash, 4px gap
+ cairo_set_dash(cr, dashes, 2, 0);
+ cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(0.5));
+
+ const float linear_y_guides[]
+ = { 0.18f / 16, 0.18f / 8, 0.18f / 4, 0.18f / 2, 0.18f, 0.18f * 2, 0.18f * 4, 1.0f };
+ const int num_guides = sizeof(linear_y_guides) / sizeof(linear_y_guides[0]);
+
+ for(int i = 0; i < num_guides; ++i)
+ {
+ const float y_linear = linear_y_guides[i];
+ const float y_pre_gamma = powf(y_linear, 1.0f / tone_mapping_params.curve_gamma);
+
+ const float y_graph = y_pre_gamma * graph_height;
+
+ cairo_move_to(cr, 0, y_graph);
+ cairo_line_to(cr, graph_width, y_graph);
+ cairo_stroke(cr);
+
+ // Draw label for the guide line
+ cairo_save(cr);
+ cairo_identity_matrix(cr); // Reset transformations for text
+ set_color(cr, darktable.bauhaus->graph_fg); // Use standard text color
+
+ snprintf(text, sizeof(text), "%.2f", y_linear); // Format the linear value
+ pango_layout_set_text(layout, text, -1);
+ pango_layout_get_pixel_extents(layout, &gui_data->ink, NULL);
+
+ // Position label slightly to the left of the graph
+ const float label_x = margin_left - gui_data->ink.width - inset / 2.0f;
+ // Vertically center label on the guide line (remember Y is flipped)
+ float label_y = margin_top + graph_height - y_graph - gui_data->ink.height / 2.0f - gui_data->ink.y;
+
+ // Ensure label stays within vertical bounds of the graph area
+ label_y = CLAMPF(label_y, margin_top - gui_data->ink.height / 2.0f - gui_data->ink.y,
+ margin_top + graph_height - gui_data->ink.height / 2.0f - gui_data->ink.y);
+
+ cairo_move_to(cr, label_x, label_y);
+ pango_cairo_show_layout(cr, layout);
+ cairo_restore(cr);
+ }
+
+ // Restore original drawing state (solid line, etc.)
+ cairo_restore(cr); // Matches cairo_save(cr) at the beginning of this block
+ // --- End Draw Gamma Guide Lines ---
+
+ // --- Draw Vertical EV Guide Lines ---
+ cairo_save(cr);
+ // Use the same style as horizontal guides
+ set_color(cr, darktable.bauhaus->graph_fg);
+ cairo_set_source_rgba(cr, darktable.bauhaus->graph_fg.red, darktable.bauhaus->graph_fg.green,
+ darktable.bauhaus->graph_fg.blue, 0.4);
+ cairo_set_dash(cr, dashes, 2, 0); // Use the same dash pattern
+ cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(0.5));
+
+ const float min_ev = tone_mapping_params.min_ev;
+ const float max_ev = tone_mapping_params.max_ev;
+ const float range_in_ev = tone_mapping_params.range_in_ev;
+
+ if (range_in_ev > _epsilon) // Avoid division by zero or tiny ranges
+ {
+ for(int ev = ceilf(min_ev); ev <= floorf(max_ev); ++ev)
+ {
+ float x_norm = (ev - min_ev) / range_in_ev;
+ // Clamp to ensure it stays within the graph bounds if min/max_ev aren't exactly integers
+ x_norm = CLAMPF(x_norm, 0.0f, 1.0f);
+ const float x_graph = x_norm * graph_width;
+
+ cairo_move_to(cr, x_graph, 0);
+ cairo_line_to(cr, x_graph, graph_height);
+ cairo_stroke(cr);
+
+ // Draw label for the EV guide line
+ if (ev % 5 == 0 || ev == ceilf(min_ev) || ev == floorf(max_ev))
+ {
+ cairo_save(cr);
+ cairo_identity_matrix(cr); // Reset transformations for text
+ set_color(cr, darktable.bauhaus->graph_fg);
+ snprintf(text, sizeof(text), "%d", ev); // Format the EV value
+ pango_layout_set_text(layout, text, -1);
+ pango_layout_get_pixel_extents(layout, &gui_data->ink, NULL);
+ // Position label slightly below the x-axis, centered horizontally
+ float label_x = margin_left + x_graph - gui_data->ink.width / 2.0f - gui_data->ink.x;
+ const float label_y = margin_top + graph_height + inset / 2.0f;
+ // Ensure label stays within horizontal bounds
+ label_x = CLAMPF(label_x, margin_left - gui_data->ink.width / 2.0f - gui_data->ink.x,
+ margin_left + graph_width - gui_data->ink.width / 2.0f - gui_data->ink.x);
+ cairo_move_to(cr, label_x, label_y);
+ pango_cairo_show_layout(cr, layout);
+ cairo_restore(cr);
+ }
+ }
+ }
+ cairo_restore(cr); // Matches cairo_save(cr) at the beginning of this block
+ // --- End Draw Vertical EV Guide Lines ---
+
+ // Draw the curve
+ cairo_set_line_width(cr, DT_PIXEL_APPLY_DPI(2.));
+ set_color(cr, darktable.bauhaus->graph_fg);
+
+ const int steps = 200;
+ for(int k = 0; k <= steps; k++)
+ {
+ const float x_norm = (float)k / steps; // Input to the curve [0, 1]
+ const float y_norm = _apply_curve(x_norm, &tone_mapping_params);
+
+ // Map normalized coords [0,1] to graph pixel coords
+ const float x_graph = x_norm * graph_width;
+ const float y_graph = y_norm * graph_height;
+
+ if (k == 0)
+ cairo_move_to(cr, x_graph, y_graph);
+ else
+ cairo_line_to(cr, x_graph, y_graph);
+ }
+ cairo_stroke(cr);
+
+ // Draw the pivot point
+ cairo_save(cr);
+ cairo_rectangle(cr, -DT_PIXEL_APPLY_DPI(4.), -DT_PIXEL_APPLY_DPI(4.), graph_width + 2. * DT_PIXEL_APPLY_DPI(4.),
+ graph_height + 2. * DT_PIXEL_APPLY_DPI(4.));
+ cairo_clip(cr);
+
+ const float x_pivot_graph = tone_mapping_params.pivot_x * graph_width;
+ const float y_pivot_graph = tone_mapping_params.pivot_y * graph_height;
+ set_color(cr, darktable.bauhaus->graph_fg_active); // Use a distinct color, e.g., active foreground
+ cairo_arc(cr, x_pivot_graph, y_pivot_graph, DT_PIXEL_APPLY_DPI(4), 0, 2. * M_PI); // Adjust radius as needed
+ cairo_fill(cr);
+ cairo_stroke(cr);
+ cairo_restore(cr);
+
+ // --- Cleanup ---
+ cairo_destroy(cr);
+ cairo_set_source_surface(crf, cst, 0, 0);
+ cairo_paint(crf);
+ cairo_surface_destroy(cst);
+ g_object_unref(layout);
+ pango_font_description_free(desc);
+
+ return FALSE;
+}
+
+void init(dt_iop_module_t *self)
+{
+ dt_iop_default_init(self);
+}
+
+void init_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece)
+{
+ piece->data = dt_calloc1_align_type(dt_iop_agx_data_t);
+}
+
+void cleanup(dt_iop_module_t *self)
+{
+ dt_iop_default_cleanup(self);
+}
+
+void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece)
+{
+ dt_free_align(piece->data);
+ piece->data = NULL;
+}
+
+static void _update_primaries_checkbox_and_sliders(dt_iop_module_t *self)
+{
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+ const dt_iop_agx_user_params_t *user_params = self->params;
+
+ if (gui_data && gui_data->primaries_controls_vbox)
+ {
+ gtk_widget_set_visible(gui_data->primaries_controls_vbox, !user_params->disable_primaries_adjustments);
+ }
+}
+
+void gui_changed(dt_iop_module_t *self, GtkWidget *widget, void *previous)
+{
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+ dt_iop_agx_user_params_t *user_params = self->params;
+
+ if (widget == gui_data->security_factor)
+ {
+ darktable.gui->reset++;
+ const float prev = *(float *)previous;
+ const float ratio = (user_params->security_factor - prev) / (prev + 100.0f);
+
+ user_params->range_black_relative_exposure *= (1.0f + ratio);
+ user_params->range_white_relative_exposure *= (1.0f + ratio);
+
+ dt_bauhaus_slider_set(gui_data->black_exposure_picker, user_params->range_black_relative_exposure);
+ dt_bauhaus_slider_set(gui_data->white_exposure_picker, user_params->range_white_relative_exposure);
+ darktable.gui->reset--;
+ }
+
+ if (gui_data->curve_tab_enabled)
+ {
+ // --- START MANUAL SYNC ---
+ if (!darktable.gui->reset) // Check the global reset guard
+ {
+ darktable.gui->reset++; // Prevent recursion
+
+#define SYNC_SLIDER(param_name, slider1, slider2) \
+ if (widget == slider1) \
+ { \
+ dt_bauhaus_slider_set(slider2, dt_bauhaus_slider_get(slider1)); \
+ } \
+ else if (widget == slider2) \
+ { \
+ dt_bauhaus_slider_set(slider1, dt_bauhaus_slider_get(slider2)); \
+ }
+
+ SYNC_SLIDER("curve_pivot_x_shift", gui_data->basic_curve_controls_settings_page.curve_pivot_x_shift,
+ gui_data->basic_curve_controls_curve_page.curve_pivot_x_shift);
+ SYNC_SLIDER("curve_pivot_y_linear", gui_data->basic_curve_controls_settings_page.curve_pivot_y_linear,
+ gui_data->basic_curve_controls_curve_page.curve_pivot_y_linear);
+ SYNC_SLIDER("curve_contrast_around_pivot",
+ gui_data->basic_curve_controls_settings_page.curve_contrast_around_pivot,
+ gui_data->basic_curve_controls_curve_page.curve_contrast_around_pivot);
+ SYNC_SLIDER("curve_toe_power", gui_data->basic_curve_controls_settings_page.curve_toe_power,
+ gui_data->basic_curve_controls_curve_page.curve_toe_power);
+ SYNC_SLIDER("curve_shoulder_power", gui_data->basic_curve_controls_settings_page.curve_shoulder_power,
+ gui_data->basic_curve_controls_curve_page.curve_shoulder_power);
+
+#undef SYNC_SLIDER
+
+ darktable.gui->reset--; // Release the guard
+ }
+ // --- END MANUAL SYNC ---
+ }
+
+ _update_primaries_checkbox_and_sliders(self);
+
+ // Test which widget was changed.
+ // If allowing w == NULL, this can be called from gui_update, so that
+ // gui configuration adjustments only need to be dealt with once, here.
+
+ // Trigger redraw when any parameter changes
+ if (gui_data && gui_data->graph_drawing_area)
+ {
+ gtk_widget_queue_draw(GTK_WIDGET(gui_data->graph_drawing_area));
+ }
+
+ if (gui_data && user_params->auto_gamma)
+ {
+ tone_mapping_params_t tone_mapping_params;
+ _calculate_log_mapping_params(self->params, &tone_mapping_params);
+ _adjust_pivot(self->params, &tone_mapping_params);
+ dt_bauhaus_slider_set(gui_data->curve_gamma, tone_mapping_params.curve_gamma);
+ }
+}
+
+static void _add_basic_curve_controls(dt_iop_module_t *self, dt_iop_basic_curve_controls_t *controls)
+{
+ GtkWidget *slider;
+
+ // curve_pivot_x_shift with picker
+ slider = dt_color_picker_new(self, DT_COLOR_PICKER_AREA | DT_COLOR_PICKER_DENOISE,
+ dt_bauhaus_slider_from_params(self, "curve_pivot_x_shift"));
+ controls->curve_pivot_x_shift = slider;
+ dt_bauhaus_slider_set_soft_range(slider, -0.5f, 0.5f);
+ gtk_widget_set_tooltip_text(slider, _("shift the pivot input towards black(-) or white(+)"));
+
+ // curve_pivot_y_linear
+ slider = dt_bauhaus_slider_from_params(self, "curve_pivot_y_linear");
+ controls->curve_pivot_y_linear = slider;
+ dt_bauhaus_slider_set_soft_range(slider, 0.0f, 1.0f);
+ gtk_widget_set_tooltip_text(slider, _("darken or brighten the pivot (output)"));
+
+ // curve_contrast_around_pivot
+ slider = dt_bauhaus_slider_from_params(self, "curve_contrast_around_pivot");
+ controls->curve_contrast_around_pivot = slider;
+ dt_bauhaus_slider_set_soft_range(slider, 0.1f, 5.0f);
+ gtk_widget_set_tooltip_text(slider, _("slope of the linear section"));
+
+ // curve_toe_power
+ slider = dt_bauhaus_slider_from_params(self, "curve_toe_power");
+ controls->curve_toe_power = slider;
+ dt_bauhaus_slider_set_soft_range(slider, 1.0f, 5.0f);
+ gtk_widget_set_tooltip_text(slider, _("contrast in shadows"));
+
+ // curve_shoulder_power
+ slider = dt_bauhaus_slider_from_params(self, "curve_shoulder_power");
+ controls->curve_shoulder_power = slider;
+ dt_bauhaus_slider_set_soft_range(slider, 1.0f, 5.0f);
+ gtk_widget_set_tooltip_text(slider, _("contrast in highlights"));
+}
+
+static void _add_basic_curve_controls_settings_page(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ GtkWidget *parent = self->widget;
+
+ self->widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
+
+ gtk_box_pack_start(GTK_BOX(parent), self->widget, FALSE, FALSE, 0);
+
+ dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "basic curve parameters")));
+
+ _add_basic_curve_controls(self, &gui_data->basic_curve_controls_settings_page);
+
+ self->widget = parent;
+}
+
+static void _add_basic_curve_controls_curve_page(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ _add_basic_curve_controls(self, &gui_data->basic_curve_controls_curve_page);
+}
+
+static void _add_look_sliders(dt_iop_module_t *self, GtkWidget *parent_widget)
+{
+ GtkWidget *original_self_widget = self->widget;
+ self->widget = parent_widget;
+
+ // Reuse the slider variable for all sliders instead of creating new ones in each scope
+ GtkWidget *slider;
+
+ // look_offset
+ slider = dt_bauhaus_slider_from_params(self, "look_offset");
+ dt_bauhaus_slider_set_soft_range(slider, -0.5f, 0.5f);
+ gtk_widget_set_tooltip_text(slider, _("deepen or lift shadows"));
+
+ // look_slope
+ slider = dt_bauhaus_slider_from_params(self, "look_slope");
+ dt_bauhaus_slider_set_soft_range(slider, 0.0f, 2.0f);
+ gtk_widget_set_tooltip_text(slider, _("decrease or increase contrast and brightness"));
+
+ // look_power
+ slider = dt_bauhaus_slider_from_params(self, "look_power");
+ dt_bauhaus_slider_set_soft_range(slider, 0.5f, 2.0f);
+ gtk_widget_set_tooltip_text(slider, _("increase or decrease brightness"));
+
+ // look_saturation
+ slider = dt_bauhaus_slider_from_params(self, "look_saturation");
+ dt_bauhaus_slider_set_soft_range(slider, 0.0f, 2.0f);
+ gtk_widget_set_tooltip_text(slider, _("decrease or increase saturation"));
+
+ // look_original_hue_mix_ratio
+ slider = dt_bauhaus_slider_from_params(self, "look_original_hue_mix_ratio");
+ dt_bauhaus_slider_set_soft_range(slider, 0.0f, 1.0f);
+ gtk_widget_set_tooltip_text(slider, _("increase to bring hues closer to original"));
+
+ self->widget = original_self_widget;
+}
+
+static void _add_look_box(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ GtkWidget *parent = self->widget;
+
+ GtkWidget *look_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
+ self->widget = look_box;
+ dt_gui_new_collapsible_section(&gui_data->look_section, "plugins/darkroom/agx/expand_look_params", _("look"),
+ GTK_BOX(look_box), DT_ACTION(self));
+ _add_look_sliders(self, GTK_WIDGET(gui_data->look_section.container));
+
+ self->widget = parent;
+
+ gtk_box_pack_start(GTK_BOX(parent), look_box, FALSE, FALSE, 0);
+}
+
+static void _add_curve_graph(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ GtkWidget *parent = self->widget;
+
+ GtkWidget *graph_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
+ gtk_box_pack_start(GTK_BOX(parent), graph_box, FALSE, FALSE, 0);
+ self->widget = graph_box;
+
+ dt_gui_new_collapsible_section(&gui_data->graph_section, "plugins/darkroom/agx/expand_curve_graph",
+ _("show curve"), GTK_BOX(graph_box), DT_ACTION(self));
+ GtkWidget *graph_container = GTK_WIDGET(gui_data->graph_section.container);
+ gui_data->graph_drawing_area
+ = GTK_DRAWING_AREA(dt_ui_resize_wrap(NULL, 0, "plugins/darkroom/agx/curve_graph_height"));
+ g_object_set_data(G_OBJECT(gui_data->graph_drawing_area), "iop-instance", self);
+ dt_action_define_iop(self, NULL, N_("graph"), GTK_WIDGET(gui_data->graph_drawing_area), NULL);
+ gtk_widget_set_can_focus(GTK_WIDGET(gui_data->graph_drawing_area), TRUE);
+ g_signal_connect(G_OBJECT(gui_data->graph_drawing_area), "draw", G_CALLBACK(_agx_draw_curve), self);
+ gtk_widget_set_tooltip_text(GTK_WIDGET(gui_data->graph_drawing_area), _("tone mapping curve"));
+
+ // Pack drawing area at the top
+ gtk_box_pack_start(GTK_BOX(graph_container), GTK_WIDGET(gui_data->graph_drawing_area), TRUE, TRUE, 0);
+
+ self->widget = parent;
+}
+
+static void _add_advanced_box(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ GtkWidget *parent = self->widget;
+
+ GtkWidget *advanced_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
+ gtk_box_pack_start(GTK_BOX(parent), advanced_box, FALSE, FALSE, 0);
+ self->widget = advanced_box;
+
+ dt_gui_new_collapsible_section(&gui_data->advanced_section, "plugins/darkroom/agx/expand_curve_advanced",
+ _("advanced"), GTK_BOX(advanced_box), DT_ACTION(self));
+ self->widget = GTK_WIDGET(gui_data->advanced_section.container);
+
+ // Reuse the slider variable for all sliders
+ GtkWidget *slider;
+
+ // Toe length
+ slider = dt_bauhaus_slider_from_params(self, "curve_linear_percent_below_pivot");
+ dt_bauhaus_slider_set_soft_range(slider, 0.0f, 100.0f);
+ dt_bauhaus_slider_set_format(slider, "%");
+ gtk_widget_set_tooltip_text(slider,
+ _("length to keep curve linear below pivot.\n"
+ "may crush shadows"));
+
+ // Toe intersection point
+ slider = dt_bauhaus_slider_from_params(self, "curve_target_display_black_percent");
+ dt_bauhaus_slider_set_digits(slider, 3);
+ dt_bauhaus_slider_set_format(slider, "%");
+ dt_bauhaus_slider_set_soft_range(slider, 0.0f, 1.0f);
+ gtk_widget_set_tooltip_text(slider, _("raise for a faded look"));
+
+ // Shoulder length
+ slider = dt_bauhaus_slider_from_params(self, "curve_linear_percent_above_pivot");
+ dt_bauhaus_slider_set_soft_range(slider, 0.0f, 100.0f);
+ dt_bauhaus_slider_set_format(slider, "%");
+ gtk_widget_set_tooltip_text(slider,
+ _("length to keep curve linear above pivot.\n"
+ "may clip highlights"));
+
+ // Shoulder intersection point
+ slider = dt_bauhaus_slider_from_params(self, "curve_target_display_white_percent");
+ dt_bauhaus_slider_set_soft_range(slider, 50.0f, 100.0f);
+ dt_bauhaus_slider_set_digits(slider, 0);
+ dt_bauhaus_slider_set_format(slider, "%");
+ gtk_widget_set_tooltip_text(slider, _("max output brightness"));
+
+ // curve_gamma
+ gui_data->auto_gamma = dt_bauhaus_toggle_from_params(self, "auto_gamma");
+ gtk_widget_set_tooltip_text(gui_data->auto_gamma,
+ _("tries to make sure the curve always remains S-shaped,\n"
+ "given that contrast is high enough, so toe and shoulder\n"
+ "controls remain effective.\n"
+ "affects overall contrast, you may have to counteract it with the contrast slider."));
+
+ slider = dt_bauhaus_slider_from_params(self, "curve_gamma");
+ gui_data->curve_gamma = slider;
+ dt_bauhaus_slider_set_soft_range(slider, 1.0f, 5.0f);
+ gtk_widget_set_tooltip_text(slider,
+ _("shifts representation (but not output brightness) of pivot\n"
+ "along the y axis of the curve.\n"
+ "affects overall contrast, you may have to counteract it with the contrast slider."));
+
+ self->widget = parent;
+}
+
+static void _add_curve_section(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ GtkWidget *parent = self->widget;
+
+ GtkWidget *curve_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
+ gtk_box_pack_start(GTK_BOX(self->widget), curve_box, TRUE, TRUE, 0);
+ self->widget = curve_box;
+
+ dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "curve parameters")));
+
+ _add_basic_curve_controls_curve_page(self, gui_data);
+
+ _add_advanced_box(self, gui_data);
+
+ self->widget = parent;
+}
+
+static void _add_exposure_box(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ GtkWidget *parent = self->widget;
+
+ self->widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
+
+ // Create section label
+ dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "input exposure range")));
+
+ // white point slider and picker
+ gui_data->white_exposure_picker
+ = dt_color_picker_new(self, DT_COLOR_PICKER_AREA | DT_COLOR_PICKER_DENOISE,
+ dt_bauhaus_slider_from_params(self, "range_white_relative_exposure"));
+ dt_bauhaus_slider_set_soft_range(gui_data->white_exposure_picker, 1.0f, 20.0f);
+ dt_bauhaus_slider_set_format(gui_data->white_exposure_picker, _(" EV"));
+ gtk_widget_set_tooltip_text(gui_data->white_exposure_picker,
+ _("relative exposure above mid-grey (white point)"));
+
+ // black point slider and picker
+ gui_data->black_exposure_picker
+ = dt_color_picker_new(self, DT_COLOR_PICKER_AREA | DT_COLOR_PICKER_DENOISE,
+ dt_bauhaus_slider_from_params(self, "range_black_relative_exposure"));
+ dt_bauhaus_slider_set_soft_range(gui_data->black_exposure_picker, -20.0f, -1.0f);
+ dt_bauhaus_slider_set_format(gui_data->black_exposure_picker, _(" EV"));
+ gtk_widget_set_tooltip_text(gui_data->black_exposure_picker,
+ _("relative exposure below mid-grey (black point)"));
+
+ // Dynamic range scaling
+ gui_data->security_factor = dt_bauhaus_slider_from_params(self, "security_factor");
+ dt_bauhaus_slider_set_soft_max(gui_data->security_factor, 50);
+ dt_bauhaus_slider_set_format(gui_data->security_factor, "%");
+ gtk_widget_set_tooltip_text(gui_data->security_factor,
+ _("symmetrically increase or decrease the computed dynamic range.\n"
+ "useful to give a safety margin to extreme luminances."));
+
+ // auto-tune picker
+ gui_data->range_exposure_picker
+ = dt_color_picker_new(self, DT_COLOR_PICKER_AREA | DT_COLOR_PICKER_DENOISE, dt_bauhaus_combobox_new(self));
+ dt_bauhaus_widget_set_label(gui_data->range_exposure_picker, NULL, N_("auto tune levels"));
+ gtk_widget_set_tooltip_text(gui_data->range_exposure_picker,
+ _("pick image area to automatically set black and white exposure"));
+ gtk_box_pack_start(GTK_BOX(self->widget), gui_data->range_exposure_picker, FALSE, FALSE, 0);
+
+ gtk_box_pack_start(GTK_BOX(parent), self->widget, FALSE, FALSE, 0);
+
+ self->widget = parent;
+}
+
+static void _populate_primaries_presets_combobox(dt_iop_module_t *self)
+{
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+ gtk_combo_box_text_remove_all(GTK_COMBO_BOX_TEXT(gui_data->primaries_preset_combo));
+
+ // Use a hash table to track unique primaries configurations.
+ GHashTable *seen_presets = g_hash_table_new_full(_agx_primaries_hash, _agx_primaries_equal, g_free, NULL);
+
+ sqlite3_stmt *stmt;
+ // Fetch name and parameters, filtering by current module version to ensure struct compatibility.
+ DT_DEBUG_SQLITE3_PREPARE_V2(dt_database_get(darktable.db),
+ "SELECT name, op_params FROM data.presets WHERE operation = ?1 AND op_version = ?2 "
+ "ORDER BY writeprotect DESC, LOWER(name), rowid",
+ -1, &stmt, NULL);
+ DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 1, self->op, -1, SQLITE_TRANSIENT);
+ DT_DEBUG_SQLITE3_BIND_INT(stmt, 2, self->version());
+
+ gtk_combo_box_text_append(gui_data->primaries_preset_combo, NULL, _("select a preset..."));
+
+ while(sqlite3_step(stmt) == SQLITE_ROW)
+ {
+ const char *preset_name = (const char *)sqlite3_column_text(stmt, 0);
+ const int op_params_size = sqlite3_column_bytes(stmt, 1);
+ if (op_params_size != sizeof(dt_iop_agx_user_params_t))
+ {
+ dt_print(DT_DEBUG_ALWAYS, "invalid params size %u for preset %s", op_params_size, preset_name);
+ continue;
+ }
+
+ const dt_iop_agx_user_params_t *preset_params = sqlite3_column_blob(stmt, 1);
+
+ _agx_primaries_key *key = g_new0(_agx_primaries_key, 1);
+ key->base_primaries = preset_params->base_primaries;
+ key->disable_primaries_adjustments = preset_params->disable_primaries_adjustments;
+ key->red_inset = preset_params->red_inset;
+ key->red_rotation = preset_params->red_rotation;
+ key->green_inset = preset_params->green_inset;
+ key->green_rotation = preset_params->green_rotation;
+ key->blue_inset = preset_params->blue_inset;
+ key->blue_rotation = preset_params->blue_rotation;
+ key->master_outset_ratio = preset_params->master_outset_ratio;
+ key->master_unrotation_ratio = preset_params->master_unrotation_ratio;
+ key->red_outset = preset_params->red_outset;
+ key->red_unrotation = preset_params->red_unrotation;
+ key->green_outset = preset_params->green_outset;
+ key->green_unrotation = preset_params->green_unrotation;
+ key->blue_outset = preset_params->blue_outset;
+ key->blue_unrotation = preset_params->blue_unrotation;
+
+ if (!g_hash_table_contains(seen_presets, key))
+ {
+ g_hash_table_insert(seen_presets, key, (gpointer)1);
+
+ gchar *local_name = dt_util_localize_segmented_name(preset_name, TRUE);
+ gtk_combo_box_text_append(gui_data->primaries_preset_combo, preset_name, local_name);
+ g_free(local_name); // 'name', OTOH, is managed by sqlite
+ }
+ else
+ {
+ // duplicate, discard
+ g_free(key);
+ }
+ }
+
+ sqlite3_finalize(stmt);
+ g_hash_table_destroy(seen_presets);
+
+ gtk_combo_box_set_active(GTK_COMBO_BOX(gui_data->primaries_preset_combo), 0);
+}
+
+static void _apply_primaries_from_preset_callback(GtkButton *button, dt_iop_module_t *self)
+{
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+ dt_iop_agx_user_params_t *current_params = self->params;
+ const gchar *preset_name = gtk_combo_box_get_active_id(GTK_COMBO_BOX(gui_data->primaries_preset_combo));
+
+ if (preset_name && gtk_combo_box_get_active(GTK_COMBO_BOX(gui_data->primaries_preset_combo)))
+ {
+ sqlite3_stmt *stmt;
+ DT_DEBUG_SQLITE3_PREPARE_V2(dt_database_get(darktable.db),
+ "SELECT op_params FROM data.presets"
+ " WHERE operation = ?1 AND name = ?2 AND op_version = ?3",
+ -1, &stmt, NULL);
+ DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 1, self->op, -1, SQLITE_TRANSIENT);
+ DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 2, preset_name, -1, SQLITE_TRANSIENT);
+ DT_DEBUG_SQLITE3_BIND_INT(stmt, 3, self->version());
+
+ if (sqlite3_step(stmt) == SQLITE_ROW)
+ {
+ const int op_params_size = sqlite3_column_bytes(stmt, 0);
+ if (op_params_size == sizeof(dt_iop_agx_user_params_t))
+ {
+ const dt_iop_agx_user_params_t *preset_params = sqlite3_column_blob(stmt, 0);
+
+ // Copy only the primaries settings
+ current_params->base_primaries = preset_params->base_primaries;
+ current_params->disable_primaries_adjustments = preset_params->disable_primaries_adjustments;
+ current_params->red_inset = preset_params->red_inset;
+ current_params->red_rotation = preset_params->red_rotation;
+ current_params->green_inset = preset_params->green_inset;
+ current_params->green_rotation = preset_params->green_rotation;
+ current_params->blue_inset = preset_params->blue_inset;
+ current_params->blue_rotation = preset_params->blue_rotation;
+ current_params->master_outset_ratio = preset_params->master_outset_ratio;
+ current_params->master_unrotation_ratio = preset_params->master_unrotation_ratio;
+ current_params->red_outset = preset_params->red_outset;
+ current_params->red_unrotation = preset_params->red_unrotation;
+ current_params->green_outset = preset_params->green_outset;
+ current_params->green_unrotation = preset_params->green_unrotation;
+ current_params->blue_outset = preset_params->blue_outset;
+ current_params->blue_unrotation = preset_params->blue_unrotation;
+
+ // Update UI and commit changes
+ dt_iop_gui_update(self);
+ dt_dev_add_history_item(darktable.develop, self, TRUE);
+ }
+ }
+
+ sqlite3_finalize(stmt);
+ }
+
+ // refresh the list and set selection prompt
+ _populate_primaries_presets_combobox(self);
+}
+
+// GUI update (called when module UI is shown/refreshed)
+void gui_update(dt_iop_module_t *self)
+{
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+ const dt_iop_agx_user_params_t *user_params = self->params;
+
+ if (gui_data)
+ {
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(gui_data->auto_gamma), user_params->auto_gamma);
+ if (user_params->auto_gamma)
+ {
+ tone_mapping_params_t tone_mapping_params;
+ _calculate_log_mapping_params(self->params, &tone_mapping_params);
+ _adjust_pivot(self->params, &tone_mapping_params);
+ dt_bauhaus_slider_set(gui_data->curve_gamma, tone_mapping_params.curve_gamma);
+ }
+ }
+
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(gui_data->disable_primaries_adjustments),
+ user_params->disable_primaries_adjustments);
+
+ _update_primaries_checkbox_and_sliders(self);
+
+ // Ensure the graph is drawn initially
+ if (gui_data && gui_data->graph_drawing_area)
+ {
+ gtk_widget_queue_draw(GTK_WIDGET(gui_data->graph_drawing_area));
+ }
+}
+
+static GtkWidget *_add_primaries_box(dt_iop_module_t *self)
+{
+ dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+ GtkWidget *main_box = self->widget;
+
+ GtkWidget *primaries_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
+ self->widget = primaries_box;
+
+ gui_data->disable_primaries_adjustments = dt_bauhaus_toggle_from_params(self, "disable_primaries_adjustments");
+ gtk_widget_set_tooltip_text(gui_data->disable_primaries_adjustments,
+ _("disable purity adjustments and rotations, only applying the curve.\n"
+ "note that those adjustments are at the heart of AgX,\n"
+ "without them the results are almost always going to be worse,\n"
+ "especially with bright, saturated lights (e.g. LEDs).\n"
+ "mainly intended to be used for experimenting."));
+
+ gui_data->primaries_controls_vbox = self->widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
+ gtk_box_pack_start(GTK_BOX(primaries_box), self->widget, FALSE, FALSE, 0);
+
+ GtkWidget *base_primaries_combo = dt_bauhaus_combobox_from_params(self, "base_primaries");
+ gtk_widget_set_tooltip_text(base_primaries_combo,
+ _("color space primaries to use as the base for below adjustments.\n"
+ "'export profile' uses the profile set in 'output color profile'."));
+
+
+ GtkWidget *preset_hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, DT_BAUHAUS_SPACE);
+ gtk_box_pack_start(GTK_BOX(gui_data->primaries_controls_vbox), preset_hbox, FALSE, FALSE, 0);
+
+ gui_data->primaries_preset_combo = GTK_COMBO_BOX_TEXT(gtk_combo_box_text_new());
+ gtk_widget_set_tooltip_text(GTK_WIDGET(gui_data->primaries_preset_combo),
+ _("load primaries settings from a preset"));
+ gtk_box_pack_start(GTK_BOX(preset_hbox), GTK_WIDGET(gui_data->primaries_preset_combo), TRUE, TRUE, 0);
+
+ _populate_primaries_presets_combobox(self);
+ gui_data->primaries_preset_apply_button = gtk_button_new_with_label(_("apply"));
+ gtk_widget_set_tooltip_text(gui_data->primaries_preset_apply_button,
+ _("apply primaries settings from the selected preset"));
+ g_signal_connect(gui_data->primaries_preset_apply_button, "clicked",
+ G_CALLBACK(_apply_primaries_from_preset_callback), self);
+ gtk_box_pack_start(GTK_BOX(preset_hbox), gui_data->primaries_preset_apply_button, FALSE, FALSE, 0);
+
+ dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "before tone mapping")));
+
+ GtkWidget *slider;
+ const float desaturation = 0.2f;
+#define SETUP_COLOR_COMBO(color, r, g, b, attenuation_suffix, inset_tooltip, rotation_suffix, rotation_tooltip) \
+ slider = dt_bauhaus_slider_from_params(self, #color attenuation_suffix); \
+ dt_bauhaus_slider_set_format(slider, "%"); \
+ dt_bauhaus_slider_set_digits(slider, 1); \
+ dt_bauhaus_slider_set_factor(slider, 100.f); \
+ dt_bauhaus_slider_set_soft_range(slider, 0.f, 0.5f); \
+ dt_bauhaus_slider_set_stop(slider, 0.f, r, g, b); \
+ gtk_widget_set_tooltip_text(slider, inset_tooltip); \
+ \
+ slider = dt_bauhaus_slider_from_params(self, #color rotation_suffix); \
+ dt_bauhaus_slider_set_format(slider, "°"); \
+ dt_bauhaus_slider_set_digits(slider, 1); \
+ dt_bauhaus_slider_set_factor(slider, 180.f / M_PI_F); \
+ dt_bauhaus_slider_set_stop(slider, 0.f, r, g, b); \
+ gtk_widget_set_tooltip_text(slider, rotation_tooltip);
+
+ SETUP_COLOR_COMBO(red, 1.f - desaturation, desaturation, desaturation, "_inset",
+ _("attenuate the purity of the red primary"), "_rotation", _("rotate the red primary"));
+ SETUP_COLOR_COMBO(green, desaturation, 1.f - desaturation, desaturation, "_inset",
+ _("attenuate the purity of the green primary"), "_rotation", _("rotate the green primary"));
+ SETUP_COLOR_COMBO(blue, desaturation, desaturation, 1.f - desaturation, "_inset",
+ _("attenuate the purity of the blue primary"), "_rotation", _("rotate the blue primary"));
+
+ dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "after tone mapping")));
+
+ slider = dt_bauhaus_slider_from_params(self, "master_outset_ratio");
+ dt_bauhaus_slider_set_format(slider, "%");
+ dt_bauhaus_slider_set_digits(slider, 0);
+ dt_bauhaus_slider_set_factor(slider, 100.f);
+ gtk_widget_set_tooltip_text(slider, _("overall purity boost"));
+
+ slider = dt_bauhaus_slider_from_params(self, "master_unrotation_ratio");
+ dt_bauhaus_slider_set_format(slider, "%");
+ dt_bauhaus_slider_set_digits(slider, 0);
+ dt_bauhaus_slider_set_factor(slider, 100.f);
+ gtk_widget_set_tooltip_text(slider, _("overall unrotation ratio"));
+
+ SETUP_COLOR_COMBO(red, 1.f - desaturation, desaturation, desaturation, "_outset",
+ _("boost the purity of the red primary"), "_unrotation", _("unrotate the red primary"));
+ SETUP_COLOR_COMBO(green, desaturation, 1.f - desaturation, desaturation, "_outset",
+ _("boost the purity of the green primary"), "_unrotation", _("unrotate the green primary"));
+ SETUP_COLOR_COMBO(blue, desaturation, desaturation, 1.f - desaturation, "_outset",
+ _("boost the purity of the blue primary"), "_unrotation", _("unrotate the blue primary"));
+#undef SETUP_COLOR_COMBO
+
+ self->widget = main_box;
+ return primaries_box;
+}
+
+static void _create_settings_page(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ GtkWidget *parent = self->widget;
+
+ GtkWidget *settings_page =
+ dt_ui_notebook_page(gui_data->notebook, N_("settings"), _("main look and curve settings"));
+ self->widget = settings_page;
+
+ _add_look_box(self, gui_data);
+
+ _add_exposure_box(self, gui_data);
+
+ if (!gui_data->curve_tab_enabled)
+ {
+ _add_curve_graph(self, gui_data);
+ }
+
+ _add_basic_curve_controls_settings_page(self, gui_data);
+
+ if (!gui_data->curve_tab_enabled)
+ {
+ _add_advanced_box(self, gui_data);
+ }
+
+ self->widget = parent;
+}
+
+static void _create_curve_page(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ GtkWidget *parent = self->widget;
+
+ GtkWidget *curve_page = dt_ui_notebook_page(gui_data->notebook, N_("curve"), _("detailed curve settings"));
+ self->widget = curve_page;
+
+ _add_curve_graph(self, gui_data);
+ _add_curve_section(self, gui_data);
+
+ self->widget = parent;
+}
+
+static void _create_primaries_page(dt_iop_module_t *self, dt_iop_agx_gui_data_t *gui_data)
+{
+ GtkWidget *parent = self->widget;
+
+ GtkWidget *page_primaries
+ = dt_ui_notebook_page(gui_data->notebook, N_("primaries"), _("color primaries adjustments"));
+ GtkWidget *primaries_box = _add_primaries_box(self);
+ gtk_box_pack_start(GTK_BOX(page_primaries), primaries_box, FALSE, FALSE, 0);
+
+ self->widget = parent;
+}
+
+void gui_init(dt_iop_module_t *self)
+{
+ dt_iop_agx_gui_data_t *gui_data = IOP_GUI_ALLOC(agx);
+ GtkWidget *main_vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);
+
+ gui_data->curve_tab_enabled = dt_conf_get_bool("plugins/darkroom/agx/enable_curve_tab");
+
+ // the notebook
+ static dt_action_def_t notebook_def = {};
+ gui_data->notebook = dt_ui_notebook_new(¬ebook_def);
+ GtkWidget *notebook_widget = GTK_WIDGET(gui_data->notebook);
+ dt_action_define_iop(self, NULL, N_("page"), notebook_widget, ¬ebook_def);
+ gtk_box_pack_start(GTK_BOX(main_vbox), notebook_widget, TRUE, TRUE, 0);
+
+ _create_settings_page(self, gui_data);
+
+ if (gui_data->curve_tab_enabled)
+ {
+ _create_curve_page(self, gui_data);
+ }
+
+ _create_primaries_page(self, gui_data);
+
+ self->widget = main_vbox;
+ gui_update(self);
+}
+
+static float _degrees_to_radians(const float degrees)
+{
+ return degrees * M_PI_F / 180.f;
+}
+
+static void _set_neutral_params(dt_iop_agx_user_params_t *user_params)
+{
+ user_params->look_slope = 1.0f;
+ user_params->look_power = 1.0f;
+ user_params->look_offset = 0.0f;
+ user_params->look_saturation = 1.0f;
+ user_params->look_original_hue_mix_ratio = 0.0f;
+
+ user_params->range_black_relative_exposure = -10;
+ user_params->range_white_relative_exposure = 6.5;
+ user_params->security_factor = 10.0f;
+
+ user_params->curve_contrast_around_pivot = 2.4;
+ user_params->curve_linear_percent_below_pivot = 0.0;
+ user_params->curve_linear_percent_below_pivot = 0.0;
+ user_params->curve_toe_power = 1.5;
+ user_params->curve_shoulder_power = 1.5;
+ user_params->curve_target_display_black_percent = 0.0;
+ user_params->curve_target_display_white_percent = 100.0;
+ user_params->auto_gamma = FALSE;
+ user_params->curve_gamma = 2.2;
+ user_params->curve_pivot_x_shift = 0.0;
+ user_params->curve_pivot_y_linear = 0.18;
+
+ user_params->disable_primaries_adjustments = FALSE;
+ user_params->red_inset = 0.0f;
+ user_params->red_rotation = 0.0;
+ user_params->green_inset = 0.0;
+ user_params->green_rotation = 0.0f;
+ user_params->blue_inset = 0.0f;
+ user_params->blue_rotation = 0.0f;
+
+ user_params->master_outset_ratio = 1.0f;
+ user_params->master_unrotation_ratio = 1.0f;
+ user_params->red_outset = 0.0f;
+ user_params->red_unrotation = 0.0f;
+ user_params->green_outset = 0.0f;
+ user_params->green_unrotation = 0.0f;
+ user_params->blue_outset = 0.0f;
+ user_params->blue_unrotation = 0.0f;
+
+ user_params->base_primaries = DT_AGX_REC2020;
+}
+
+void init_presets(dt_iop_module_so_t *self)
+{
+ // auto-applied scene-referred default
+ self->pref_based_presets = TRUE;
+
+ dt_iop_agx_user_params_t user_params = { 0 };
+
+ _set_neutral_params(&user_params);
+
+ dt_gui_presets_add_generic(_("unmodified base primaries"), self->op, self->version(), &user_params,
+ sizeof(user_params), 1, DEVELOP_BLEND_CS_RGB_SCENE);
+
+ // AgX primaries settings from Eary_Chow
+ // https://discuss.pixls.us/t/blender-agx-in-darktable-proof-of-concept/48697/1018
+ user_params.auto_gamma = FALSE; // uses a pre-configured gamma
+
+ // AgX primaries settings that produce the same matrices under D50 as those used in the Blender OCIO config
+ // https://github.com/EaryChow/AgX_LUT_Gen/blob/main/AgXBaseRec2020.py
+ user_params.red_inset = 0.29462451;
+ user_params.green_inset = 0.25861925;
+ user_params.blue_inset = 0.14641371;
+ user_params.red_rotation = 0.03540329;
+ user_params.green_rotation = -0.02108586;
+ user_params.blue_rotation = -0.06305724;
+
+ user_params.master_outset_ratio = 1.0f;
+ user_params.master_unrotation_ratio = 1.0f;
+
+ user_params.red_outset = 0.290696322918;
+ user_params.green_outset = 0.261332511902;
+ user_params.blue_outset = 0.047416660935;
+ user_params.red_unrotation = 0;
+ user_params.green_unrotation = 0;
+ user_params.blue_unrotation = 0;
+
+ // In Blender, a related param is set to 40%, but is actually used as 1 - param,
+ // so 60% would give almost identical results; however, Eary_Chow suggested
+ // that we leave this as 0, based on feedback he had received
+ user_params.look_original_hue_mix_ratio = 0;
+ user_params.base_primaries = DT_AGX_REC2020;
+
+ const char *workflow = dt_conf_get_string_const("plugins/darkroom/workflow");
+ const gboolean auto_apply_agx = strcmp(workflow, "scene-referred (agx)") == 0;
+
+ dt_gui_presets_add_generic(_("blender-like|base"), self->op, self->version(), &user_params, sizeof(user_params),
+ 1, DEVELOP_BLEND_CS_RGB_SCENE);
+ if (auto_apply_agx)
+ {
+ dt_gui_presets_update_format(BUILTIN_PRESET("blender-like|base"), self->op, self->version(),
+ FOR_RAW | FOR_MATRIX);
+ dt_gui_presets_update_autoapply(BUILTIN_PRESET("blender-like|base"), self->op, self->version(), TRUE);
+ }
+
+ // Punchy preset
+ user_params.look_power = 1.35f;
+ user_params.look_offset = 0.0f;
+ user_params.look_saturation = 1.4f;
+ dt_gui_presets_add_generic(_("blender-like|punchy"), self->op, self->version(), &user_params,
+ sizeof(user_params), 1, DEVELOP_BLEND_CS_RGB_SCENE);
+
+ _set_neutral_params(&user_params);
+ // Sigmoid 'smooth' primaries settings
+ user_params.red_inset = 0.1f;
+ user_params.green_inset = 0.1f;
+ user_params.blue_inset = 0.15f;
+ user_params.red_rotation = _degrees_to_radians(2.f);
+ user_params.green_rotation = _degrees_to_radians(-1.f);
+ user_params.blue_rotation = _degrees_to_radians(-3.f);
+ user_params.red_outset = 0.1f;
+ user_params.green_outset = 0.1f;
+ user_params.blue_outset = 0.15f;
+ user_params.red_unrotation = _degrees_to_radians(2.f);
+ user_params.green_unrotation = _degrees_to_radians(-1.f);
+ user_params.blue_unrotation = _degrees_to_radians(-3.f);
+ // Don't restore purity - try to avoid posterization.
+ user_params.master_outset_ratio = 0.0f;
+ user_params.master_unrotation_ratio = 1.0f;
+ user_params.base_primaries = DT_AGX_WORK_PROFILE;
+
+ dt_gui_presets_add_generic(_("smooth|base"), self->op, self->version(), &user_params, sizeof(user_params), 1,
+ DEVELOP_BLEND_CS_RGB_SCENE);
+
+ // 'Punchy' look
+ user_params.look_power = 1.35f;
+ user_params.look_offset = 0.0f;
+ user_params.look_saturation = 1.4f;
+ dt_gui_presets_add_generic(_("smooth|punchy"), self->op, self->version(), &user_params, sizeof(user_params), 1,
+ DEVELOP_BLEND_CS_RGB_SCENE);
+}
+
+// Callback for color pickers
+void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpipe_t *pipe)
+{
+ const dt_iop_agx_gui_data_t *gui_data = self->gui_data;
+
+ if (picker == gui_data->black_exposure_picker)
+ apply_auto_black_exposure(self);
+ else if (picker == gui_data->white_exposure_picker)
+ apply_auto_white_exposure(self);
+ else if (picker == gui_data->range_exposure_picker)
+ apply_auto_tune_exposure(self);
+ else if (picker == gui_data->basic_curve_controls_settings_page.curve_pivot_x_shift
+ || (gui_data->curve_tab_enabled
+ && picker == gui_data->basic_curve_controls_curve_page.curve_pivot_x_shift))
+ {
+ apply_auto_pivot_x(self, dt_ioppr_get_pipe_work_profile_info(pipe));
+ }
+
+ const dt_iop_agx_user_params_t *user_params = self->params;
+ if (user_params->auto_gamma)
+ {
+ ++darktable.gui->reset;
+ tone_mapping_params_t tone_mapping_params;
+ _calculate_log_mapping_params(self->params, &tone_mapping_params);
+ _adjust_pivot(self->params, &tone_mapping_params);
+ dt_bauhaus_slider_set(gui_data->curve_gamma, tone_mapping_params.curve_gamma);
+ --darktable.gui->reset;
+ }
+ gtk_widget_queue_draw(GTK_WIDGET(gui_data->graph_drawing_area));
+ dt_dev_add_history_item(darktable.develop, self, TRUE);
+}
+
+void commit_params(dt_iop_module_t *self,
+ dt_iop_params_t *gui_params,
+ dt_dev_pixelpipe_t *pipe,
+ dt_dev_pixelpipe_iop_t *piece)
+{
+ dt_iop_agx_data_t *processing_params = piece->data;
+ const dt_iop_agx_user_params_t *user_params = gui_params;
+
+ // Calculate curve parameters once
+ processing_params->tone_mapping_params = _calculate_tone_mapping_params(user_params);
+
+ // Get profiles and create matrices
+ const dt_iop_order_iccprofile_info_t *const pipe_work_profile = dt_ioppr_get_pipe_work_profile_info(piece->pipe);
+ const dt_iop_order_iccprofile_info_t *const base_profile
+ = _agx_get_base_profile(self->dev, pipe_work_profile, user_params->base_primaries);
+
+ if (!base_profile)
+ {
+ dt_print(DT_DEBUG_ALWAYS,
+ "[agx commit_params] Failed to obtain a valid base profile. Module will not run correctly.");
+ return;
+ }
+
+ _create_matrices(user_params, pipe_work_profile, base_profile,
+ processing_params->rendering_profile.matrix_in_transposed,
+ processing_params->pipe_to_base_transposed, processing_params->base_to_rendering_transposed,
+ processing_params->rendering_to_pipe_transposed);
+
+ dt_colormatrix_transpose(processing_params->rendering_profile.matrix_in,
+ processing_params->rendering_profile.matrix_in_transposed);
+ processing_params->rendering_profile.nonlinearlut = FALSE; // no LUT for this linear transform
+}
+
+void tiling_callback(dt_iop_module_t *self,
+ dt_dev_pixelpipe_iop_t *piece,
+ const dt_iop_roi_t *roi_in,
+ const dt_iop_roi_t *roi_out,
+ dt_develop_tiling_t *tiling)
+{
+ tiling->factor = 2.0f;
+ tiling->factor_cl = 3.0f;
+ tiling->maxbuf = 1.0f;
+ tiling->maxbuf_cl = 1.0f;
+ tiling->overhead = 0;
+ tiling->overlap = 0;
+ tiling->xalign = 1;
+ tiling->yalign = 1;
+}
diff --git a/src/libs/modulegroups.c b/src/libs/modulegroups.c
index e35273245129..7ca5fed41380 100644
--- a/src/libs/modulegroups.c
+++ b/src/libs/modulegroups.c
@@ -1539,6 +1539,8 @@ void init_presets(dt_lib_module_t *self)
dt_conf_is_equal("plugins/darkroom/workflow", "scene-referred (filmic)");
const gboolean wf_sigmoid =
dt_conf_is_equal("plugins/darkroom/workflow", "scene-referred (sigmoid)");
+ const gboolean wf_agx =
+ dt_conf_is_equal("plugins/darkroom/workflow", "scene-referred (agx)");
const gboolean wf_none =
dt_conf_is_equal("plugins/darkroom/workflow", "none");
@@ -1567,6 +1569,7 @@ void init_presets(dt_lib_module_t *self)
AM("toneequal");
SMG(C_("modulegroup", "tone"), "tone");
+ AM("agx");
AM("bilat");
AM("filmicrgb");
AM("levels");
@@ -1734,6 +1737,8 @@ void init_presets(dt_lib_module_t *self)
AM("filmicrgb");
if(wf_sigmoid || wf_none)
AM("sigmoid");
+ if(wf_agx || wf_none)
+ AM("agx");
AM("toneequal");
AM("crop");
AM("ashift");
@@ -1831,6 +1836,8 @@ static gchar *_presets_get_minimal(dt_lib_module_t *self)
"scene-referred (filmic)");
const gboolean wf_sigmoid = dt_conf_is_equal("plugins/darkroom/workflow",
"scene-referred (sigmoid)");
+ const gboolean wf_agx = dt_conf_is_equal("plugins/darkroom/workflow",
+ "scene-referred (agx)");
// all modules
gchar *tx = NULL;
@@ -1844,8 +1851,10 @@ static gchar *_presets_get_minimal(dt_lib_module_t *self)
{
if(wf_filmic)
AM("filmicrgb");
- else
+ else if(wf_sigmoid)
AM("sigmoid");
+ else if(wf_agx)
+ AM("agx");
}
else
AM("basecurve");