From 60a58713ef79e99735db265dc470525828e41576 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:34:32 +0000 Subject: [PATCH 01/13] pixelpipe: carry the output's peak luminance as output_headroom colorout maps 1.0 to the 203 nit reference white for PQ and HLG, so a tone mapper needs to know how far above that the output can go before it can use an HDR display's range. Nothing in the pipe says so today. The pipe now carries an output headroom, the peak of its output relative to 203 nits, set together with the output profile: 1 for SDR, 1000/203 for HLG (the nominal display colorout's OOTF assumes), and for PQ the new "PQ peak" setting of the export module (default 1000 nits) divided by 203. The setting is only shown when the export profile is PQ or taken from the image. It is part of the pipe cache hash, since the PQ peak can change without the profile changing, and a darkroom on a PQ display reprocesses when it changes there. Related: #22664 --- data/darktableconfig.xml.in | 7 +++++ src/common/iop_profile.c | 17 ++++++++++++ src/develop/pixelpipe_cache.c | 2 ++ src/develop/pixelpipe_hb.c | 1 + src/develop/pixelpipe_hb.h | 3 +++ src/libs/export.c | 51 ++++++++++++++++++++++++++++++++--- 6 files changed, 78 insertions(+), 3 deletions(-) diff --git a/data/darktableconfig.xml.in b/data/darktableconfig.xml.in index a427550de6d..a2fadd76060 100644 --- a/data/darktableconfig.xml.in +++ b/data/darktableconfig.xml.in @@ -2684,6 +2684,13 @@ always use LittleCMS 2 to apply output color profile this is slower than the default. + + plugins/lighttable/export/pq_peak_nits + int + 1000 + PQ peak luminance + the brightest highlight a PQ (HDR) output should reach, in nits. tone mappers that follow the output map their white to this peak. HLG output always peaks at 1000 nits and SDR output at the 203 nit reference white. + plugins/lighttable/export/high_quality_processing bool diff --git a/src/common/iop_profile.c b/src/common/iop_profile.c index bbe0a39b205..182fa705d4b 100644 --- a/src/common/iop_profile.c +++ b/src/common/iop_profile.c @@ -806,6 +806,22 @@ dt_ioppr_set_pipe_input_profile_info(struct dt_develop_t *dev, return profile_info; } +static float _output_headroom(const dt_colorspaces_color_profile_type_t type) +{ + switch(dt_colorspaces_hdr_transfer(type)) + { + case DT_HDR_PQ: + return fmaxf(1.0f, dt_conf_get_int("plugins/lighttable/export/pq_peak_nits") + / DT_HDR_REFERENCE_WHITE_NITS); + case DT_HDR_HLG_REC2020: + case DT_HDR_HLG_P3: + // colorout's OOTF clips HLG at the nominal 1000 nit display + return DT_HDR_HLG_PEAK_NITS / DT_HDR_REFERENCE_WHITE_NITS; + default: + return 1.0f; + } +} + dt_iop_order_iccprofile_info_t * dt_ioppr_set_pipe_output_profile_info(struct dt_develop_t *dev, struct dt_dev_pixelpipe_t *pipe, @@ -834,6 +850,7 @@ dt_ioppr_set_pipe_output_profile_info(struct dt_develop_t *dev, profile_info = dt_ioppr_add_profile_info_to_list(dev, DT_COLORSPACE_SRGB, "", intent); } pipe->output_profile_info = profile_info; + pipe->output_headroom = _output_headroom(profile_info->type); return profile_info; } diff --git a/src/develop/pixelpipe_cache.c b/src/develop/pixelpipe_cache.c index a1bd507d814..9fa7278863e 100644 --- a/src/develop/pixelpipe_cache.c +++ b/src/develop/pixelpipe_cache.c @@ -122,6 +122,8 @@ static dt_hash_t _dev_pixelpipe_cache_basichash(dt_dev_pixelpipe_t *pipe, hash = dt_hash(hash, &pipe->work_profile_info, sizeof(pipe->work_profile_info)); hash = dt_hash(hash, &pipe->output_profile_info, sizeof(pipe->output_profile_info)); hash = dt_hash(hash, &pipe->export_profile_info, sizeof(pipe->export_profile_info)); + // the PQ peak is a preference, so the output profile alone does not fix the headroom + hash = dt_hash(hash, &pipe->output_headroom, sizeof(pipe->output_headroom)); // go through all modules up to position and compute a hash using the operation and params. GList *pieces = pipe->nodes; diff --git a/src/develop/pixelpipe_hb.c b/src/develop/pixelpipe_hb.c index 5abcda2f3ec..9928aeae99f 100644 --- a/src/develop/pixelpipe_hb.c +++ b/src/develop/pixelpipe_hb.c @@ -318,6 +318,7 @@ static gboolean _dev_pixelpipe_init_cached(dt_dev_pixelpipe_t *pipe, pipe->input_profile_info = NULL; pipe->output_profile_info = NULL; pipe->export_profile_info = NULL; + pipe->output_headroom = 1.0f; pipe->runs = 0; pipe->bcache_data = NULL; pipe->bcache_size = 0; diff --git a/src/develop/pixelpipe_hb.h b/src/develop/pixelpipe_hb.h index bd97cfca498..b1e5c2d1480 100644 --- a/src/develop/pixelpipe_hb.h +++ b/src/develop/pixelpipe_hb.h @@ -237,6 +237,9 @@ typedef struct dt_dev_pixelpipe_t struct dt_iop_order_iccprofile_info_t *output_profile_info; /** used only as a cache-identity tag to invalidate the cache **/ struct dt_iop_order_iccprofile_info_t *export_profile_info; + /** peak luminance of the output relative to the 203 nit reference white, + 1.0 for SDR; set with the output profile, read by tone mappers **/ + float output_headroom; // instances of pixelpipe, stored in GList of dt_dev_pixelpipe_iop_t GList *nodes; diff --git a/src/libs/export.c b/src/libs/export.c index f17bcc0b2a4..da8bfc19256 100644 --- a/src/libs/export.c +++ b/src/libs/export.c @@ -27,6 +27,7 @@ #include "control/conf.h" #include "control/control.h" #include "control/signal.h" +#include "develop/develop.h" #include "dtgtk/button.h" #include "dtgtk/stylemenu.h" #include "gui/accelerators.h" @@ -34,6 +35,7 @@ #include "imageio/imageio_module.h" #include "libs/lib.h" #include "libs/lib_api.h" +#include "views/view.h" #include #include @@ -64,7 +66,7 @@ typedef struct dt_lib_export_t GtkWidget *storage, *format; int format_lut[128]; uint32_t max_allowed_width , max_allowed_height; - GtkWidget *upscale, *profile, *intent, *style, *style_mode; + GtkWidget *upscale, *profile, *pq_peak, *intent, *style, *style_mode; dt_gui_collapsible_section_t cs; GtkWidget *batch_treeview; GtkButton *export_button, *batch_export_button; @@ -735,6 +737,9 @@ void gui_reset(dt_lib_module_t *self) dt_confgen_get_int(CONFIG_PREFIX "iccintent", DT_DEFAULT) + 1); + dt_bauhaus_slider_set(d->pq_peak, dt_confgen_get_int(CONFIG_PREFIX "pq_peak_nits", + DT_DEFAULT)); + // iccprofile const int icctype = dt_confgen_get_int(CONFIG_PREFIX "icctype", DT_DEFAULT); gchar *iccfilename = dt_conf_get_string(CONFIG_PREFIX "iccprofile"); @@ -982,7 +987,15 @@ static void _storage_changed(GtkWidget *widget, dt_lib_module_t *self) dt_lib_gui_queue_update(self); } -static void _profile_changed(GtkWidget *widget, dt_lib_export_t *d) +// the PQ peak only matters when the export may be PQ +static void _update_pq_peak_visibility(dt_lib_export_t *d) +{ + const dt_colorspaces_color_profile_type_t icctype = dt_conf_get_int(CONFIG_PREFIX "icctype"); + gtk_widget_set_visible(d->pq_peak, icctype == DT_COLORSPACE_NONE + || dt_colorspaces_hdr_transfer(icctype) == DT_HDR_PQ); +} + +static void _set_profile_conf(GtkWidget *widget) { int pos = dt_bauhaus_combobox_get(widget); if(pos > 0) @@ -1008,6 +1021,21 @@ static void _profile_changed(GtkWidget *widget, dt_lib_export_t *d) dt_conf_set_string(CONFIG_PREFIX "iccprofile", ""); } +static void _profile_changed(GtkWidget *widget, dt_lib_export_t *d) +{ + _set_profile_conf(widget); + _update_pq_peak_visibility(d); +} + +static void _pq_peak_changed(GtkWidget *widget, dt_lib_export_t *d) +{ + dt_conf_set_int(CONFIG_PREFIX "pq_peak_nits", (int)roundf(dt_bauhaus_slider_get(widget))); + // colorout reads the peak when it commits, and a darkroom on a PQ + // display renders with it too + if(dt_view_get_current() == DT_VIEW_DARKROOM) + dt_dev_reprocess_all(darktable.develop); +} + static void _dimensions_type_changed(GtkWidget *widget, dt_lib_export_t *d) { @@ -1617,6 +1645,20 @@ void gui_init(dt_lib_module_t *self) gtk_widget_set_tooltip_markup(d->profile, tooltip); g_free(tooltip); + // Add PQ peak slider + + d->pq_peak = dt_bauhaus_slider_new_action(DT_ACTION(self), 203.f, 10000.f, 1.f, + dt_confgen_get_int(CONFIG_PREFIX "pq_peak_nits", + DT_DEFAULT), 0); + dt_bauhaus_widget_set_label(d->pq_peak, NULL, N_("PQ peak")); + dt_bauhaus_slider_set_log_curve(d->pq_peak); + dt_bauhaus_slider_set_format(d->pq_peak, _(" nits")); + dt_bauhaus_slider_set(d->pq_peak, dt_conf_get_int(CONFIG_PREFIX "pq_peak_nits")); + gtk_widget_set_tooltip_text(d->pq_peak, + _("the brightest highlight of a PQ (HDR) export, in nits.\n" + "tone mappers that follow the output map their white to it.\n" + "HLG exports always peak at 1000 nits.")); + // Add intent combo DT_BAUHAUS_COMBOBOX_NEW_FULL @@ -1683,6 +1725,8 @@ void gui_init(dt_lib_module_t *self) // Set callback signals g_signal_connect(G_OBJECT(d->profile), "value-changed", G_CALLBACK(_profile_changed), (gpointer)d); + g_signal_connect(G_OBJECT(d->pq_peak), "value-changed", + G_CALLBACK(_pq_peak_changed), (gpointer)d); // Export button d->export_button = GTK_BUTTON(dt_action_button_new @@ -1699,7 +1743,7 @@ void gui_init(dt_lib_module_t *self) dt_ui_section_label_new(C_("section", "global options")), d->dimensions_type, d->px_size, d->print_size, d->scale, d->size_in_px, d->upscale, d->high_quality, d->export_masks, - d->profile, d->intent, + d->profile, d->pq_peak, d->intent, style_box, d->style_mode, d->export_button); @@ -1791,6 +1835,7 @@ void gui_init(dt_lib_module_t *self) } g_free(iccfilename); + _update_pq_peak_visibility(d); // style // set it to none if the var is not set or the style doesn't exist anymore From c10bf49424dd1e61eeb2d6cab53e4c5aae0e575a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:34:33 +0000 Subject: [PATCH 02/13] agx: form the image for the output's peak, as Blender's AgX HDR colorout maps 1.0 to the 203 nit reference white for PQ and HLG, so AgX's target white, which stops at 100%, caps an HDR export at reference white, and one history cannot render correctly for both SDR and HDR. AgX now follows the output's peak, using the pipe's output headroom H and the HDR design of Blender 5's AgX (AgXHLG.py in github.com/EaryChow/AgX_LUT_Gen): - the curve's output, target white included, is relative to the output's peak; the shoulder power is multiplied by H^log10(2), so the shoulder stays smooth at the higher peak - after linearizing, a second sigmoid in log2 space moves the pivot output down by H, so shadows and the pivot keep their SDR brightness and the highlights extend up to the peak - per-channel darkening saturates highlights; a new "HDR purity" slider (default 50%, as in Blender) sets how much of that hue and saturation shift is kept In SDR (H = 1) nothing changes. The curve itself never sees a white above 1.0, so the regular shoulder always reaches the peak and the look's slope, lift and power keep their fixed point there. New instances and presets follow the output. Existing edits are migrated with it off, so they render exactly as before, and a "follow output peak" toggle, shown only while it is off, opts them in. The headroom is only known once colorout has committed, so the curve is rebuilt at process time. The shoulder power is capped at its slider maximum, since a higher one makes the curve fit overflow for a high pivot target at the highest PQ peaks. On DT_SIGNAL_DEVELOP_UI_PIPE_FINISHED the GUI reads the darkroom full pipe's headroom, with a trylock on busy_mutex, so that cache hits are covered too. The graph, its markers, the curve warnings and the pivot picker then show what the center view renders and follow display profile changes. In HDR the graph's top is the peak and its guide labels are relative to reference white. Fixes #22664 --- data/kernels/agx.cl | 43 ++++++++ src/iop/agx.c | 245 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 265 insertions(+), 23 deletions(-) diff --git a/data/kernels/agx.cl b/data/kernels/agx.cl index 19c3b01dfde..c5ee754a53b 100644 --- a/data/kernels/agx.cl +++ b/data/kernels/agx.cl @@ -55,6 +55,14 @@ typedef struct dt_iop_agx_tone_mapping_params_t float look_original_hue_mix_ratio; int look_tuned; int restore_hue; + int hdr; + float hdr_headroom; + float hdr_purity; + float hdr_log_min; + float hdr_pivot_x; + float hdr_pivot_y; + float hdr_toe_scale; + float hdr_shoulder_scale; } dt_iop_agx_tone_mapping_params_t; static inline void _agx_compress_into_gamut(float4 *pixel) @@ -175,6 +183,36 @@ static inline float _agx_lerp_hue(const float original_hue, const float processe return mixed_hue - floor(mixed_hue); } +// see _hdr_darken() in agx.c +static inline float _agx_hdr_darken(const float x, const dt_iop_agx_tone_mapping_params_t *params) +{ + const float log_x = clamp(1.0f - dtcl_log2(fmax(x, 1e-12f)) / params->hdr_log_min, 0.0f, 1.0f); + const float u = log_x - params->hdr_pivot_x; + const float log_y = u < 0.0f + ? _agx_scaled_sigmoid(log_x, params->hdr_toe_scale, 1.0f, 3.0f, params->hdr_pivot_x, params->hdr_pivot_y) + : params->hdr_pivot_y + params->hdr_shoulder_scale * u / (params->hdr_shoulder_scale + u); + return x > 0.0f ? dtcl_exp2(params->hdr_log_min * (1.0f - log_y)) : 0.0f; +} + +// see _agx_hdr() in agx.c +static inline void _agx_hdr(float4 *pixel, const dt_iop_agx_tone_mapping_params_t *params) +{ + const float4 hsv_before = RGB_2_HSV(*pixel); + float4 darkened = *pixel; + darkened.x = _agx_hdr_darken(pixel->x, params); + darkened.y = _agx_hdr_darken(pixel->y, params); + darkened.z = _agx_hdr_darken(pixel->z, params); + float4 hsv_after = RGB_2_HSV(darkened); + + hsv_after.x = _agx_lerp_hue(hsv_before.x, hsv_after.x, 1.0f - params->hdr_purity); + hsv_after.y = hsv_before.y + params->hdr_purity * (hsv_after.y - hsv_before.y); + darkened = HSV_2_RGB(hsv_after); + + pixel->x = params->hdr_headroom * darkened.x; + pixel->y = params->hdr_headroom * darkened.y; + pixel->z = params->hdr_headroom * darkened.z; +} + static inline void _agx_tone_mapping(float4 *rgb_in_out, const dt_iop_agx_tone_mapping_params_t *params, constant float *rendering_to_xyz) { float4 hsv_pixel = (float4)(0.0f, 0.0f, 0.0f, 0.0f); @@ -199,6 +237,11 @@ static inline void _agx_tone_mapping(float4 *rgb_in_out, const dt_iop_agx_tone_m transformed_pixel.y = dtcl_pow(fmax(0.0f, transformed_pixel.y), params->curve_gamma); transformed_pixel.z = dtcl_pow(fmax(0.0f, transformed_pixel.z), params->curve_gamma); + if(params->hdr) + { + _agx_hdr(&transformed_pixel, params); + } + if(params->restore_hue) { hsv_pixel = RGB_2_HSV(transformed_pixel); diff --git a/src/iop/agx.c b/src/iop/agx.c index f39ae9a4360..fd2ace93766 100644 --- a/src/iop/agx.c +++ b/src/iop/agx.c @@ -38,7 +38,7 @@ #include #include -DT_MODULE_INTROSPECTION(7, dt_iop_agx_params_t) +DT_MODULE_INTROSPECTION(8, dt_iop_agx_params_t) const char *name() { @@ -152,6 +152,10 @@ typedef struct dt_iop_agx_params_t // v5 gboolean completely_reverse_primaries; // $DEFAULT: FALSE $DESCRIPTION: "reverse all" + + // v8 + gboolean target_white_from_output; // $DEFAULT: TRUE $DESCRIPTION: "follow output peak" + float hdr_purity; // $MIN: 0.f $MAX: 1.f $DEFAULT: 0.5f $DESCRIPTION: "HDR purity" } dt_iop_agx_params_t; typedef struct dt_iop_basic_curve_controls_t @@ -168,8 +172,13 @@ typedef struct dt_iop_agx_gui_data_t GtkNotebook *notebook; GtkWidget *auto_gamma; GtkWidget *curve_gamma; + GtkWidget *target_white_from_output; + GtkWidget *hdr_purity; GtkDrawingArea *graph_drawing_area; + // the darkroom full pipe's headroom, see _ui_pipe_finished() + float output_headroom; + dt_gui_collapsible_section_t look_section; dt_gui_collapsible_section_t graph_section; dt_gui_collapsible_section_t advanced_section; @@ -249,6 +258,16 @@ typedef struct tone_mapping_params_t float look_original_hue_mix_ratio; gboolean look_tuned; gboolean restore_hue; + + // HDR output, see _set_hdr_params() + gboolean hdr; + float hdr_headroom; + float hdr_purity; + float hdr_log_min; + float hdr_pivot_x; + float hdr_pivot_y; + float hdr_toe_scale; + float hdr_shoulder_scale; } tone_mapping_params_t; typedef struct primaries_params_t @@ -269,6 +288,8 @@ typedef struct dt_iop_agx_data_t { tone_mapping_params_t tone_mapping_params; primaries_params_t primaries_params; + // to rebuild the curve for the output headroom, only known at process time + dt_iop_agx_params_t params; } dt_iop_agx_data_t; static void _set_scene_referred_default_params(dt_iop_agx_params_t *p); @@ -294,6 +315,21 @@ int legacy_params(dt_iop_module_t *self, return 0; } + if(old_version == 7) + { + dt_iop_agx_params_t *np = calloc(1, sizeof(dt_iop_agx_params_t)); + memcpy(np, old_params, offsetof(dt_iop_agx_params_t, target_white_from_output)); + // existing edits keep their white at the reference white and do not + // follow the output's peak, so that they render as before + np->target_white_from_output = FALSE; + np->hdr_purity = 0.5f; + *new_params = np; + *new_params_size = sizeof(dt_iop_agx_params_t); + *new_version = 8; + + return 0; + } + return 1; } @@ -839,7 +875,8 @@ static inline float _calculate_slope_gamma_compensation(const float gamma, static tone_mapping_params_t _calculate_tone_mapping_params(const dt_iop_agx_params_t *p) { - tone_mapping_params_t tone_mapping_params; + tone_mapping_params_t tone_mapping_params = { 0 }; + tone_mapping_params.hdr_headroom = 1.f; // look tone_mapping_params.look_lift = p->look_lift; @@ -960,6 +997,54 @@ static tone_mapping_params_t _calculate_tone_mapping_params(const dt_iop_agx_par return tone_mapping_params; } +// Blender's AgX HDR (AgXHLG.py in github.com/EaryChow/AgX_LUT_Gen) darkens +// the linear curve output with a second sigmoid in log2 space: 20 EV below +// the pivot up to 1.0, the peak, maps to [0, 1]. It moves the pivot output +// down by the headroom, so the pivot keeps its SDR brightness, with slope 1 +// and power 3 below it and power 1 above it +static void _set_hdr_params(tone_mapping_params_t *params, + const dt_iop_agx_params_t *p, + const float headroom) +{ + const float pivot = CLAMPF(p->curve_pivot_y_linear_output, _epsilon, 0.99f); + + params->hdr = TRUE; + params->hdr_headroom = headroom; + params->hdr_purity = p->hdr_purity; + params->hdr_log_min = log2f(pivot) - 20.f; + params->hdr_pivot_x = 1.f - log2f(pivot) / params->hdr_log_min; + params->hdr_pivot_y = 1.f - log2f(pivot / headroom) / params->hdr_log_min; + params->hdr_toe_scale = -_scale(1.f, 1.f, 1.f - params->hdr_pivot_x, 1.f - params->hdr_pivot_y, + 1.f, 3.f); + // _scale() cannot give the negative scale of a convex segment: with + // power 1 the shoulder is the hyperbola through the pivot and (1, 1) + const float dx = 1.f - params->hdr_pivot_x; + const float dy = 1.f - params->hdr_pivot_y; + params->hdr_shoulder_scale = -dx * dy / (dy - dx); +} + +// when following the output, the curve's output, target white included, +// is relative to the output's peak, and HDR output gets a harder shoulder +// and _agx_hdr(), as in Blender's AgX HDR. SDR output is unchanged +static tone_mapping_params_t _pipe_tone_mapping_params(const dt_iop_agx_params_t *p, + const float headroom) +{ + if(!p->target_white_from_output) + return _calculate_tone_mapping_params(p); + + const float hdr_headroom = fmaxf(1.f, headroom); + dt_iop_agx_params_t pipe_p = *p; + // capped at the slider's maximum: above it the curve fit in _scale() + // overflows, e.g. for a high pivot target and the highest PQ peaks + pipe_p.curve_shoulder_power = fminf(pipe_p.curve_shoulder_power + * powf(hdr_headroom, log10f(2.f)), 10.f); + + tone_mapping_params_t tone_mapping_params = _calculate_tone_mapping_params(&pipe_p); + if(hdr_headroom > 1.f) + _set_hdr_params(&tone_mapping_params, &pipe_p, hdr_headroom); + return tone_mapping_params; +} + static primaries_params_t _get_primaries_params(const dt_iop_agx_params_t *p) { primaries_params_t primaries_params; @@ -1057,6 +1142,43 @@ static void _adjust_relative_exposure_from_exposure_params(dt_iop_module_t *self _update_pivot_x(old_black_ev, old_white_ev, self, p); } +// x is linear, relative to the peak +static inline float _hdr_darken(const float x, + const tone_mapping_params_t *params) +{ + const float log_x = CLIP(1.f - log2f(fmaxf(x, 1e-12f)) / params->hdr_log_min); + const float u = log_x - params->hdr_pivot_x; + const float log_y = + u < 0.f + ? _scaled_sigmoid(log_x, params->hdr_toe_scale, 1.f, 3.f, + params->hdr_pivot_x, params->hdr_pivot_y) + : params->hdr_pivot_y + + params->hdr_shoulder_scale * u / (params->hdr_shoulder_scale + u); + return x > 0.f ? exp2f(params->hdr_log_min * (1.f - log_y)) : 0.f; +} + +static inline void _agx_hdr(dt_aligned_pixel_t pixel_in_out, + const tone_mapping_params_t *params) +{ + dt_aligned_pixel_t hsv_before = { 0.f }; + dt_aligned_pixel_t hsv_after = { 0.f }; + dt_aligned_pixel_t darkened = { 0.f }; + + dt_RGB_2_HSV(pixel_in_out, hsv_before); + for_three_channels(k, aligned(pixel_in_out, darkened : 16)) + darkened[k] = _hdr_darken(pixel_in_out[k], params); + dt_RGB_2_HSV(darkened, hsv_after); + + // keep part of the hue and saturation shift of the per-channel darkening + hsv_after[0] = _lerp_hue(hsv_before[0], hsv_after[0], 1.f - params->hdr_purity); + hsv_after[1] = hsv_before[1] + params->hdr_purity * (hsv_after[1] - hsv_before[1]); + dt_HSV_2_RGB(hsv_after, darkened); + + // back to 1.0 = reference white + for_three_channels(k, aligned(pixel_in_out, darkened : 16)) + pixel_in_out[k] = params->hdr_headroom * darkened[k]; +} + 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) @@ -1084,6 +1206,9 @@ static void _agx_tone_mapping(dt_aligned_pixel_t rgb_in_out, transformed_pixel[k] = powf(fmaxf(0.f, transformed_pixel[k]), params->curve_gamma); } + if(params->hdr) + _agx_hdr(transformed_pixel, params); + // get post-curve chroma angle if(params->restore_hue) { @@ -1103,6 +1228,16 @@ static void _agx_tone_mapping(dt_aligned_pixel_t rgb_in_out, } } +// the curve, plus the darkening of HDR output, gamma-encoded and relative to the peak +static float _rendered_curve(const float x, + const tone_mapping_params_t *params) +{ + const float y = _apply_curve(x, params); + return params->hdr + ? powf(_hdr_darken(powf(y, params->curve_gamma), params), 1.f / params->curve_gamma) + : y; +} + static void _apply_auto_black_exposure(const dt_iop_module_t *self) { dt_iop_agx_params_t *p = self->params; @@ -1189,13 +1324,15 @@ static void _apply_auto_pivot_xy(dt_iop_module_t *self, const dt_iop_order_iccpr const float picked_pivot_x = (picked_ev - p->range_black_relative_ev) / range; const tone_mapping_params_t tone_mapping_params = - _calculate_tone_mapping_params(p); + _pipe_tone_mapping_params(p, g->output_headroom); // see where the target_pivot is currently mapped - const float target_y = _apply_curve(picked_pivot_x, &tone_mapping_params); - // try to avoid changing the brightness of the pivot - const float target_y_linearised = powf(target_y, p->curve_gamma); - p->curve_pivot_y_linear_output = target_y_linearised; + const float target_y = _rendered_curve(picked_pivot_x, &tone_mapping_params); + // try to avoid changing the brightness of the pivot; with HDR + // output the pivot keeps its brightness relative to reference white + const float target_y_linearised = + powf(target_y, p->curve_gamma) * tone_mapping_params.hdr_headroom; + p->curve_pivot_y_linear_output = fminf(1.f, target_y_linearised); p->curve_pivot_x = picked_pivot_x; DT_ENTER_GUI_UPDATE(); @@ -1378,6 +1515,9 @@ void process(dt_iop_module_t *self, rendering_profile.nonlinearlut = FALSE; // no LUT for this linear transform const gboolean base_working_same_profile = pipe_work_profile == base_profile; + const tone_mapping_params_t tone_mapping_params = d->params.target_white_from_output + ? _pipe_tone_mapping_params(&d->params, piece->pipe->output_headroom) + : d->tone_mapping_params; DT_OMP_FOR() for(size_t k = 0; k < 4 * n_pixels; k += 4) @@ -1409,7 +1549,7 @@ void process(dt_iop_module_t *self, dt_apply_transposed_color_matrix(base_rgb, base_to_rendering_transposed, rendering_rgb); // Apply the tone mapping curve and look adjustments - _agx_tone_mapping(rendering_rgb, &d->tone_mapping_params, + _agx_tone_mapping(rendering_rgb, &tone_mapping_params, rendering_profile.matrix_in_transposed); // Convert from internal rendering space back to pipe working space @@ -1427,7 +1567,9 @@ static gboolean _agx_draw_curve(GtkWidget *widget, const dt_iop_agx_params_t *p = self->params; dt_iop_agx_gui_data_t *g = self->gui_data; - const tone_mapping_params_t tone_mapping_params = _calculate_tone_mapping_params(p); + // draw the curve the center view renders + const tone_mapping_params_t tone_mapping_params = + _pipe_tone_mapping_params(p, g->output_headroom); gtk_widget_get_allocation(widget, &g->allocation); g->allocation.height -= DT_RESIZE_HANDLE_SIZE; @@ -1530,7 +1672,8 @@ static gboolean _agx_draw_curve(GtkWidget *widget, cairo_identity_matrix(cr); // Reset transformations for text set_color(cr, darktable.bauhaus->graph_fg); // Use standard text color - snprintf(text, sizeof(text), "%.0f%%", 100.0f * y_linear); // Format the linear value + // the top of the graph is the peak, so HDR output labels it above 100% + snprintf(text, sizeof(text), "%.0f%%", 100.0f * y_linear * tone_mapping_params.hdr_headroom); pango_layout_set_text(layout, text, -1); pango_layout_get_pixel_extents(layout, &g->ink, NULL); @@ -1614,11 +1757,11 @@ static gboolean _agx_draw_curve(GtkWidget *widget, const int steps = 200; // draw the main curve - cairo_move_to(cr, 0, _apply_curve(0, &tone_mapping_params) * graph_height); + cairo_move_to(cr, 0, _rendered_curve(0, &tone_mapping_params) * graph_height); for(int k = 1; k <= steps; k++) { const float x_norm = (float)k / steps; - const float y_norm = _apply_curve(x_norm, &tone_mapping_params); + const float y_norm = _rendered_curve(x_norm, &tone_mapping_params); cairo_line_to(cr, x_norm * graph_width, y_norm * graph_height); } cairo_stroke(cr); @@ -1628,11 +1771,11 @@ static gboolean _agx_draw_curve(GtkWidget *widget, { cairo_set_source_rgb(cr, 0.75, .5, 0.); const int toe_end_step = ceilf(tone_mapping_params.toe_transition_x * steps); - cairo_move_to(cr, 0, _apply_curve(0, &tone_mapping_params) * graph_height); + cairo_move_to(cr, 0, _rendered_curve(0, &tone_mapping_params) * graph_height); for(int k = 1; k <= toe_end_step; k++) { const float x_norm = (float)k / steps; - const float y_norm = _apply_curve(x_norm, &tone_mapping_params); + const float y_norm = _rendered_curve(x_norm, &tone_mapping_params); cairo_line_to(cr, x_norm * graph_width, y_norm * graph_height); } cairo_stroke(cr); @@ -1643,18 +1786,18 @@ static gboolean _agx_draw_curve(GtkWidget *widget, cairo_set_source_rgb(cr, 0.75, .5, 0.); const int shoulder_start_step = floorf(tone_mapping_params.shoulder_transition_x * steps); float x_norm = (float)shoulder_start_step / steps; - float y_norm = _apply_curve(x_norm, &tone_mapping_params); + float y_norm = _rendered_curve(x_norm, &tone_mapping_params); cairo_move_to(cr, x_norm * graph_width, y_norm * graph_height); for(int k = shoulder_start_step + 1; k <= steps; k++) { x_norm = (float)k / steps; - y_norm = _apply_curve(x_norm, &tone_mapping_params); + y_norm = _rendered_curve(x_norm, &tone_mapping_params); cairo_line_to(cr, x_norm * graph_width, y_norm * graph_height); } cairo_stroke(cr); } - // draw the toe start, shoulder start, pivot + // draw the toe start, shoulder start, pivot, on the curve as rendered cairo_save(cr); // restore line width and color for points cairo_set_line_width(cr, line_width); @@ -1666,21 +1809,24 @@ static gboolean _agx_draw_curve(GtkWidget *widget, cairo_clip(cr); const float x_toe_graph = tone_mapping_params.toe_transition_x * graph_width; - const float y_toe_graph = tone_mapping_params.toe_transition_y * graph_height; + const float y_toe_graph = + _rendered_curve(tone_mapping_params.toe_transition_x, &tone_mapping_params) * graph_height; set_color(cr, darktable.bauhaus->graph_fg_active); cairo_arc(cr, x_toe_graph, y_toe_graph, DT_PIXEL_APPLY_DPI(4), 0, 2. * M_PI); cairo_fill(cr); cairo_stroke(cr); const float x_shoulder_graph = tone_mapping_params.shoulder_transition_x * graph_width; - const float y_shoulder_graph = tone_mapping_params.shoulder_transition_y * graph_height; + const float y_shoulder_graph = + _rendered_curve(tone_mapping_params.shoulder_transition_x, &tone_mapping_params) * graph_height; set_color(cr, darktable.bauhaus->graph_fg_active); cairo_arc(cr, x_shoulder_graph, y_shoulder_graph, DT_PIXEL_APPLY_DPI(4), 0, 2. * M_PI); cairo_fill(cr); cairo_stroke(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; + const float y_pivot_graph = + _rendered_curve(tone_mapping_params.pivot_x, &tone_mapping_params) * graph_height; set_color(cr, darktable.bauhaus->graph_fg_active); cairo_arc(cr, x_pivot_graph, y_pivot_graph, DT_PIXEL_APPLY_DPI(4), 0, 2. * M_PI); cairo_fill(cr); @@ -1732,7 +1878,8 @@ static void _update_curve_warnings(dt_iop_module_t *self) if(!g) return; const gboolean warnings_enabled = dt_conf_get_bool("plugins/darkroom/agx/enable_curve_warnings"); - const tone_mapping_params_t params = _calculate_tone_mapping_params(p); + const tone_mapping_params_t params = + _pipe_tone_mapping_params(p, g->output_headroom); dt_bauhaus_widget_set_quad_paint(g->basic_curve_controls.curve_toe_power, params.need_convex_toe && warnings_enabled @@ -1747,6 +1894,9 @@ static void _update_redraw_dynamic_gui(dt_iop_module_t* const self, const dt_iop_agx_params_t* const p) { gtk_widget_set_visible(g->curve_gamma, !p->auto_gamma); + // only edits made before HDR output existed have it off + gtk_widget_set_visible(g->target_white_from_output, !p->target_white_from_output); + gtk_widget_set_visible(g->hdr_purity, p->target_white_from_output); gtk_widget_set_visible(g->primaries_controls_vbox, !p->disable_primaries_adjustments); const gboolean post_curve_primaries_available = !p->completely_reverse_primaries && !p->disable_primaries_adjustments; gtk_widget_set_visible(g->post_curve_primaries_controls_vbox, post_curve_primaries_available); @@ -2000,7 +2150,24 @@ static GtkWidget* _create_advanced_box(dt_iop_module_t *self, dt_bauhaus_slider_set_format(slider, "%"); dt_bauhaus_slider_set_digits(slider, 2); dt_bauhaus_slider_set_factor(slider, 100.f); - gtk_widget_set_tooltip_text(slider, _("max linear output power")); + gtk_widget_set_tooltip_text(slider, _("max linear output power, relative to the peak of the output")); + + g->target_white_from_output = + dt_bauhaus_toggle_from_params(section, "target_white_from_output"); + gtk_widget_set_tooltip_text + (g->target_white_from_output, + _("this edit predates HDR output and keeps its white at the 203 nit\n" + "reference white. turn on to extend the highlights to the peak of\n" + "an HDR output (1000 nits for HLG, the PQ peak set in export),\n" + "as in Blender's AgX HDR. SDR output does not change")); + + g->hdr_purity = dt_bauhaus_slider_from_params(section, "hdr_purity"); + dt_bauhaus_slider_set_format(g->hdr_purity, "%"); + dt_bauhaus_slider_set_digits(g->hdr_purity, 0); + dt_bauhaus_slider_set_factor(g->hdr_purity, 100.f); + gtk_widget_set_tooltip_text(g->hdr_purity, + _("for HDR output, how much of the extra saturation and hue shift\n" + "from extending the highlights to the peak is kept")); // Toe length slider = dt_bauhaus_slider_from_params(section, "curve_linear_ratio_below_pivot"); @@ -2475,9 +2642,33 @@ static void _notebook_page_changed(GtkNotebook *notebook, } } +// a new display profile or PQ peak changes the curve of the darkroom view +static void _ui_pipe_finished(gpointer instance, + dt_iop_module_t *self) +{ + dt_iop_agx_gui_data_t *g = self->gui_data; + dt_dev_pixelpipe_t *pipe = self->dev->full.pipe; + if(!g || !pipe) return; + + // read on every finished run, cache hits included, since AgX's + // process() does not run then. colorout sets the headroom while + // synching under busy_mutex; a pipe that is busy again will signal + // once more, and a blocking lock could deadlock against process() + if(dt_pthread_mutex_trylock(&pipe->busy_mutex)) return; + const float headroom = pipe->output_headroom; + dt_pthread_mutex_unlock(&pipe->busy_mutex); + + if(headroom == g->output_headroom) return; + + g->output_headroom = headroom; + _update_curve_warnings(self); + gtk_widget_queue_draw(GTK_WIDGET(g->graph_drawing_area)); +} + void gui_init(dt_iop_module_t *self) { dt_iop_agx_gui_data_t *g = IOP_GUI_ALLOC(agx); + g->output_headroom = 1.f; static dt_action_def_t notebook_def = {}; g->notebook = dt_ui_notebook_new(¬ebook_def); @@ -2509,10 +2700,14 @@ void gui_init(dt_iop_module_t *self) // Finally, add the remaining sections to the settings page _add_look_box(settings_section, g); _create_primaries_page(self, g); + + DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_DEVELOP_UI_PIPE_FINISHED, _ui_pipe_finished); } static void _set_default_curve_and_look_params(dt_iop_agx_params_t *p) { + p->target_white_from_output = TRUE; + p->hdr_purity = 0.5f; p->look_slope = 1.f; p->look_brightness = 1.f; p->look_lift = 0.f; @@ -2729,6 +2924,7 @@ void commit_params(dt_iop_module_t *self, // Calculate curve parameters once processing_params->tone_mapping_params = _calculate_tone_mapping_params(p); processing_params->primaries_params = _get_primaries_params(p); + processing_params->params = *p; } void reload_defaults(dt_iop_module_t *self) @@ -2843,11 +3039,14 @@ int process_cl(dt_iop_module_t *self, } const int base_working_same_profile = pipe_work_profile == base_profile; + const tone_mapping_params_t tone_mapping_params = d->params.target_white_from_output + ? _pipe_tone_mapping_params(&d->params, piece->pipe->output_headroom) + : d->tone_mapping_params; err = dt_opencl_enqueue_kernel_2d_args( devid, gd->kernel_agx, width, height, CLARG(dev_in), CLARG(dev_out), CLARG(width), CLARG(height), - CLARG(d->tone_mapping_params), + CLARG(tone_mapping_params), CLARG(dev_pipe_to_base), CLARG(dev_base_to_rendering), CLARG(dev_rendering_to_pipe), From 228cbffe880cd2760dfbd5ae1511ea0f2d578aba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:34:33 +0000 Subject: [PATCH 03/13] sigmoid: scale target white to the output's peak Like AgX, sigmoid's target white sets how bright an HDR export peaks, since colorout maps 1.0 to the 203 nit reference white for PQ and HLG. Sigmoid now multiplies its target white by the pipe's output headroom, capped at the slider's own maximum of 1600% (3248 nits). Above that the curve fit can overflow for extreme contrast, skew and target black, which renders midtones black. In SDR nothing changes. The curve fit moves out of commit_params() so it can be redone at process time, when the headroom is known. As in AgX, new instances and presets follow the output, existing edits are migrated with it off, and a "follow output peak" toggle, shown only while it is off, opts them in. Related: #22664 --- src/iop/sigmoid.c | 174 ++++++++++++++++++++++++++++++---------------- 1 file changed, 116 insertions(+), 58 deletions(-) diff --git a/src/iop/sigmoid.c b/src/iop/sigmoid.c index bfdba9731e3..f11252e0077 100644 --- a/src/iop/sigmoid.c +++ b/src/iop/sigmoid.c @@ -31,7 +31,7 @@ #include #include -DT_MODULE_INTROSPECTION(3, dt_iop_sigmoid_params_t) +DT_MODULE_INTROSPECTION(4, dt_iop_sigmoid_params_t) #define MIDDLE_GREY 0.1845f @@ -70,6 +70,7 @@ typedef struct dt_iop_sigmoid_params_t float blue_rotation; // $MIN: -0.4 $MAX: 0.4 $DEFAULT: 0.0 $DESCRIPTION: "blue rotation" float purity; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "recover purity" dt_iop_sigmoid_base_primaries_t base_primaries; // $DEFAULT: DT_SIGMOID_WORK_PROFILE $DESCRIPTION: "base primaries" + gboolean white_target_from_output; // $DEFAULT: TRUE $DESCRIPTION: "follow output peak" } dt_iop_sigmoid_params_t; int legacy_params(dt_iop_module_t *self, @@ -153,6 +154,20 @@ int legacy_params(dt_iop_module_t *self, return 0; } + else if(old_version == 3) + { + dt_iop_sigmoid_params_t *n = calloc(1, sizeof(dt_iop_sigmoid_params_t)); + memcpy(n, old_params, sizeof(dt_iop_sigmoid_params_v3_t)); + // existing edits keep their target white and do not follow the + // output's peak, so that they render as before + n->white_target_from_output = FALSE; + + *new_params = n; + *new_params_size = sizeof(dt_iop_sigmoid_params_t); + *new_version = 4; + + return 0; + } return 1; } @@ -171,11 +186,16 @@ typedef struct dt_iop_sigmoid_data_t float rotation[3]; float purity; dt_iop_sigmoid_base_primaries_t base_primaries; + // to rebuild the curve for the output headroom, only known at process time + gboolean white_target_from_output; + float middle_grey_contrast; + float contrast_skewness; } dt_iop_sigmoid_data_t; typedef struct dt_iop_sigmoid_gui_data_t { GtkWidget *color_processing_list, *hue_preservation_slider; + GtkWidget *white_target_toggle; dt_gui_collapsible_section_t display_luminance_section, primaries_section; } dt_iop_sigmoid_gui_data_t; @@ -248,6 +268,7 @@ void init_presets(dt_iop_module_so_t *self) // others dt_iop_sigmoid_params_t p = { 0 }; + p.white_target_from_output = TRUE; p.display_white_target = 100.0f; p.display_black_target = 0.0152f; p.color_processing = DT_SIGMOID_METHOD_PER_CHANNEL; @@ -315,13 +336,9 @@ static inline float _generalized_loglogistic_sigmoid(const float value, return dt_isnan(paper_response) ? magnitude : paper_response; } -void commit_params(dt_iop_module_t *self, - dt_iop_params_t *p1, - dt_dev_pixelpipe_t *pipe, - dt_dev_pixelpipe_iop_t *piece) +// fit the curve to the contrast, skew, black target and the given white target ratio +static void _compute_curve(dt_iop_sigmoid_data_t *d, const float white_target) { - const dt_iop_sigmoid_params_t *params = (dt_iop_sigmoid_params_t *)p1; - dt_iop_sigmoid_data_t *module_data = piece->data; /* Calculate actual skew log logistic parameters to fulfill the following: * f(scene_zero) = display_black_target * f(scene_grey) = MIDDLE_GREY @@ -330,7 +347,7 @@ void commit_params(dt_iop_module_t *self, */ // Calculate a reference slope for no skew and a normalized display - const float ref_film_power = params->middle_grey_contrast; + const float ref_film_power = d->middle_grey_contrast; const float ref_paper_power = 1.0f; const float ref_magnitude = 1.0f; const float ref_film_fog = 0.0f; @@ -345,50 +362,80 @@ void commit_params(dt_iop_module_t *self, / 2.0f / delta; // Add skew - module_data->paper_power = powf(5.0f, -params->contrast_skewness); + d->paper_power = powf(5.0f, -d->contrast_skewness); // Slope at low film power const float temp_film_power = 1.0f; - const float temp_white_target = 0.01f * params->display_white_target; + const float temp_white_target = white_target; const float temp_white_grey_relation - = powf(temp_white_target / MIDDLE_GREY, 1.0f / module_data->paper_power) - 1.0f; + = powf(temp_white_target / MIDDLE_GREY, 1.0f / d->paper_power) - 1.0f; const float temp_paper_exposure = powf(MIDDLE_GREY, temp_film_power) * temp_white_grey_relation; const float temp_slope = (_generalized_loglogistic_sigmoid(MIDDLE_GREY + delta, temp_white_target, temp_paper_exposure, - ref_film_fog, temp_film_power, module_data->paper_power) + ref_film_fog, temp_film_power, d->paper_power) - _generalized_loglogistic_sigmoid(MIDDLE_GREY - delta, temp_white_target, temp_paper_exposure, - ref_film_fog, temp_film_power, module_data->paper_power)) + ref_film_fog, temp_film_power, d->paper_power)) / 2.0f / delta; // Figure out what film power fulfills the target slope // (linear when assuming display_black = 0.0) - module_data->film_power = ref_slope / temp_slope; + d->film_power = ref_slope / temp_slope; // Calculate the other parameters now that both film and paper power is known - module_data->white_target = 0.01f * params->display_white_target; - module_data->black_target = 0.01f * params->display_black_target; + d->white_target = white_target; const float white_grey_relation - = powf(module_data->white_target / MIDDLE_GREY, 1.0f / module_data->paper_power) - 1.0f; + = powf(d->white_target / MIDDLE_GREY, 1.0f / d->paper_power) - 1.0f; const float white_black_relation - = powf(module_data->black_target / module_data->white_target, -1.0f / module_data->paper_power) - 1.0f; - - module_data->film_fog = MIDDLE_GREY * powf(white_grey_relation, 1.0f / module_data->film_power) - / (powf(white_black_relation, 1.0f / module_data->film_power) - - powf(white_grey_relation, 1.0f / module_data->film_power)); - module_data->paper_exposure - = powf(module_data->film_fog + MIDDLE_GREY, module_data->film_power) * white_grey_relation; - - module_data->color_processing = params->color_processing; - module_data->hue_preservation = fminf(fmaxf(0.01f * params->hue_preservation, 0.0f), 1.0f); - - module_data->purity = params->purity; - module_data->inset[0] = params->red_inset; - module_data->inset[1] = params->green_inset; - module_data->inset[2] = params->blue_inset; - module_data->rotation[0] = params->red_rotation; - module_data->rotation[1] = params->green_rotation; - module_data->rotation[2] = params->blue_rotation; - module_data->base_primaries = params->base_primaries; + = powf(d->black_target / d->white_target, -1.0f / d->paper_power) - 1.0f; + + d->film_fog = MIDDLE_GREY * powf(white_grey_relation, 1.0f / d->film_power) + / (powf(white_black_relation, 1.0f / d->film_power) + - powf(white_grey_relation, 1.0f / d->film_power)); + d->paper_exposure + = powf(d->film_fog + MIDDLE_GREY, d->film_power) * white_grey_relation; +} + +void commit_params(dt_iop_module_t *self, + dt_iop_params_t *p1, + dt_dev_pixelpipe_t *pipe, + dt_dev_pixelpipe_iop_t *piece) +{ + const dt_iop_sigmoid_params_t *p = (dt_iop_sigmoid_params_t *)p1; + dt_iop_sigmoid_data_t *d = piece->data; + + d->middle_grey_contrast = p->middle_grey_contrast; + d->contrast_skewness = p->contrast_skewness; + d->black_target = 0.01f * p->display_black_target; + d->white_target_from_output = p->white_target_from_output; + _compute_curve(d, 0.01f * p->display_white_target); + + d->color_processing = p->color_processing; + d->hue_preservation = fminf(fmaxf(0.01f * p->hue_preservation, 0.0f), 1.0f); + + d->purity = p->purity; + d->inset[0] = p->red_inset; + d->inset[1] = p->green_inset; + d->inset[2] = p->blue_inset; + d->rotation[0] = p->red_rotation; + d->rotation[1] = p->green_rotation; + d->rotation[2] = p->blue_rotation; + d->base_primaries = p->base_primaries; +} + +// when following the output, target white is scaled by the output's headroom, +// so SDR output is unchanged +static const dt_iop_sigmoid_data_t *_pipe_data(const dt_dev_pixelpipe_iop_t *piece, + dt_iop_sigmoid_data_t *pipe_d) +{ + const dt_iop_sigmoid_data_t *d = piece->data; + const float headroom = piece->pipe->output_headroom; + if(!d->white_target_from_output || headroom <= 1.0f) return d; + + // capped at the slider's 1600% (3248 nits): the fit overflows for + // extreme contrast, skew and target black at the highest PQ peaks + *pipe_d = *d; + _compute_curve(pipe_d, fminf(d->white_target * headroom, 16.0f)); + return pipe_d; } static void _calculate_adjusted_primaries(const dt_iop_sigmoid_data_t *const module_data, @@ -563,23 +610,22 @@ static void _pixel_channel_order(const dt_aligned_pixel_t pix_in, dt_iop_sigmoid } } -void process_loglogistic_rgb_ratio(const dt_dev_pixelpipe_iop_t *piece, +void process_loglogistic_rgb_ratio(const dt_iop_sigmoid_data_t *d, const void *const ivoid, void *const ovoid, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) { - const dt_iop_sigmoid_data_t *module_data = piece->data; const float *const in = (const float *)ivoid; float *const out = (float *)ovoid; const size_t npixels = (size_t)roi_in->width * roi_in->height; - const float white_target = module_data->white_target; - const float black_target = module_data->black_target; - const float paper_exp = module_data->paper_exposure; - const float film_fog = module_data->film_fog; - const float contrast_power = module_data->film_power; - const float skew_power = module_data->paper_power; + const float white_target = d->white_target; + const float black_target = d->black_target; + const float paper_exp = d->paper_exposure; + const float film_fog = d->film_fog; + const float contrast_power = d->film_power; + const float skew_power = d->paper_power; DT_OMP_FOR() for(size_t k = 0; k < 4 * npixels; k += 4) @@ -702,27 +748,27 @@ static inline void _preserve_hue_and_energy(const dt_aligned_pixel_t pix_in, void process_loglogistic_per_channel(dt_develop_t *dev, const dt_dev_pixelpipe_iop_t *piece, + const dt_iop_sigmoid_data_t *d, const void *const ivoid, void *const ovoid, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) { - const dt_iop_sigmoid_data_t *module_data = piece->data; const float *const in = (const float *)ivoid; float *const out = (float *)ovoid; const size_t npixels = (size_t)roi_in->width * roi_in->height; - const float white_target = module_data->white_target; - const float paper_exp = module_data->paper_exposure; - const float film_fog = module_data->film_fog; - const float contrast_power = module_data->film_power; - const float skew_power = module_data->paper_power; - const float hue_preservation = module_data->hue_preservation; + const float white_target = d->white_target; + const float paper_exp = d->paper_exposure; + const float film_fog = d->film_fog; + const float contrast_power = d->film_power; + const float skew_power = d->paper_power; + const float hue_preservation = d->hue_preservation; const dt_iop_order_iccprofile_info_t *pipe_work_profile = dt_ioppr_get_pipe_work_profile_info(piece->pipe); - const dt_iop_order_iccprofile_info_t *base_profile = _get_base_profile(dev, pipe_work_profile, module_data->base_primaries); + const dt_iop_order_iccprofile_info_t *base_profile = _get_base_profile(dev, pipe_work_profile, d->base_primaries); dt_colormatrix_t pipe_to_base, base_to_rendering, rendering_to_pipe; - _calculate_adjusted_primaries(module_data, pipe_work_profile, base_profile, pipe_to_base, base_to_rendering, rendering_to_pipe); + _calculate_adjusted_primaries(d, pipe_work_profile, base_profile, pipe_to_base, base_to_rendering, rendering_to_pipe); DT_OMP_FOR() for(size_t k = 0; k < 4 * npixels; k += 4) @@ -769,15 +815,16 @@ void process(dt_iop_module_t *self, const dt_iop_roi_t *const roi_out) { // this is called for preview and full pipe separately, each with its own pixelpipe piece. - const dt_iop_sigmoid_data_t *module_data = piece->data; + dt_iop_sigmoid_data_t pipe_d; + const dt_iop_sigmoid_data_t *d = _pipe_data(piece, &pipe_d); - if(module_data->color_processing == DT_SIGMOID_METHOD_PER_CHANNEL) + if(d->color_processing == DT_SIGMOID_METHOD_PER_CHANNEL) { - process_loglogistic_per_channel(self->dev, piece, ivoid, ovoid, roi_in, roi_out); + process_loglogistic_per_channel(self->dev, piece, d, ivoid, ovoid, roi_in, roi_out); } else // DT_SIGMOID_METHOD_RGB_RATIO { - process_loglogistic_rgb_ratio(piece, ivoid, ovoid, roi_in, roi_out); + process_loglogistic_rgb_ratio(d, ivoid, ovoid, roi_in, roi_out); } } @@ -789,7 +836,8 @@ int process_cl(dt_iop_module_t *self, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) { - const dt_iop_sigmoid_data_t *const d = piece->data; + dt_iop_sigmoid_data_t pipe_d; + const dt_iop_sigmoid_data_t *const d = _pipe_data(piece, &pipe_d); const dt_iop_sigmoid_global_data_t *const gd = self->global_data; cl_int err = CL_MEM_OBJECT_ALLOCATION_FAILURE; @@ -866,6 +914,9 @@ void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous) const dt_iop_sigmoid_gui_data_t *g = self->gui_data; const dt_iop_sigmoid_params_t *p = self->params; + // only edits made before HDR output existed have it off + gtk_widget_set_visible(g->white_target_toggle, !p->white_target_from_output); + if(!w || w == g->color_processing_list) { const gboolean is_per_channel = p->color_processing == DT_SIGMOID_METHOD_PER_CHANNEL; @@ -970,6 +1021,13 @@ void gui_init(dt_iop_module_t *self) dt_bauhaus_slider_set_format(slider, "%"); gtk_widget_set_tooltip_text(slider, _("the white luminance of the target display or print.\n" "can be used creatively for a faded look or blowing out whites earlier.")); + g->white_target_toggle = dt_bauhaus_toggle_from_params(self, "white_target_from_output"); + gtk_widget_set_tooltip_text + (g->white_target_toggle, + _("this edit predates HDR output and keeps target white as set.\n" + "turn on to scale target white to the peak of an HDR output\n" + "(1000 nits for HLG, the PQ peak set in export), up to 1600%\n" + "(3248 nits). SDR output does not change")); self->widget = main_box; } From 89f1be0ec86d604507b9d14d0f75c5aabcb298f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:34:33 +0000 Subject: [PATCH 04/13] RELEASE_NOTES.md: tone mappers follow the output's peak --- RELEASE_NOTES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4af89ce3e3e..b2ef4ac972c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -230,6 +230,16 @@ changes (where available). ## Other Changes +- AgX and sigmoid now adapt to HDR output, so one edit exports to + SDR, HLG and PQ alike. HLG output peaks at 1000 nits, and PQ output + at the new "PQ peak" setting of the export module, 1000 nits by + default. With AgX, shadows and midtones keep their SDR brightness + and highlights extend to the peak, following Blender's AgX HDR, with + a new "HDR purity" slider for the highlights' saturation. Sigmoid + scales its target white to the peak, up to 1600% (3248 nits). SDR + output is unchanged. Existing edits render as before, and a "follow + output peak" toggle in each module opts them in. + - PNG exports using a PQ output color profile now include content light level metadata (MaxCLL and MaxFALL) for HDR tone mapping. From e5d050c91efa873840c94075354046707232576a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:32:31 +0000 Subject: [PATCH 05/13] agx: group the HDR output fields of the tone mapping params The eight hdr_* fields of tone_mapping_params_t, and their copy in the OpenCL kernel, become one hdr_params_t member. _set_hdr_params(), _hdr_darken() and _agx_hdr() now take only that struct. No change in output. --- data/kernels/agx.cl | 56 ++++++++++++++++------------- src/iop/agx.c | 88 +++++++++++++++++++++++---------------------- 2 files changed, 77 insertions(+), 67 deletions(-) diff --git a/data/kernels/agx.cl b/data/kernels/agx.cl index c5ee754a53b..49a1b7e923f 100644 --- a/data/kernels/agx.cl +++ b/data/kernels/agx.cl @@ -21,6 +21,19 @@ #define _epsilon 1E-6f +// Must match hdr_params_t in C code +typedef struct dt_iop_agx_hdr_params_t +{ + int enabled; + float headroom; + float purity; + float log_min; + float pivot_x; + float pivot_y; + float toe_scale; + float shoulder_scale; +} dt_iop_agx_hdr_params_t; + // Must match tone_mapping_params_t in C code typedef struct dt_iop_agx_tone_mapping_params_t { @@ -55,14 +68,7 @@ typedef struct dt_iop_agx_tone_mapping_params_t float look_original_hue_mix_ratio; int look_tuned; int restore_hue; - int hdr; - float hdr_headroom; - float hdr_purity; - float hdr_log_min; - float hdr_pivot_x; - float hdr_pivot_y; - float hdr_toe_scale; - float hdr_shoulder_scale; + dt_iop_agx_hdr_params_t hdr; } dt_iop_agx_tone_mapping_params_t; static inline void _agx_compress_into_gamut(float4 *pixel) @@ -184,33 +190,33 @@ static inline float _agx_lerp_hue(const float original_hue, const float processe } // see _hdr_darken() in agx.c -static inline float _agx_hdr_darken(const float x, const dt_iop_agx_tone_mapping_params_t *params) +static inline float _agx_hdr_darken(const float x, const dt_iop_agx_hdr_params_t *hdr) { - const float log_x = clamp(1.0f - dtcl_log2(fmax(x, 1e-12f)) / params->hdr_log_min, 0.0f, 1.0f); - const float u = log_x - params->hdr_pivot_x; + const float log_x = clamp(1.0f - dtcl_log2(fmax(x, 1e-12f)) / hdr->log_min, 0.0f, 1.0f); + const float u = log_x - hdr->pivot_x; const float log_y = u < 0.0f - ? _agx_scaled_sigmoid(log_x, params->hdr_toe_scale, 1.0f, 3.0f, params->hdr_pivot_x, params->hdr_pivot_y) - : params->hdr_pivot_y + params->hdr_shoulder_scale * u / (params->hdr_shoulder_scale + u); - return x > 0.0f ? dtcl_exp2(params->hdr_log_min * (1.0f - log_y)) : 0.0f; + ? _agx_scaled_sigmoid(log_x, hdr->toe_scale, 1.0f, 3.0f, hdr->pivot_x, hdr->pivot_y) + : hdr->pivot_y + hdr->shoulder_scale * u / (hdr->shoulder_scale + u); + return x > 0.0f ? dtcl_exp2(hdr->log_min * (1.0f - log_y)) : 0.0f; } // see _agx_hdr() in agx.c -static inline void _agx_hdr(float4 *pixel, const dt_iop_agx_tone_mapping_params_t *params) +static inline void _agx_hdr(float4 *pixel, const dt_iop_agx_hdr_params_t *hdr) { const float4 hsv_before = RGB_2_HSV(*pixel); float4 darkened = *pixel; - darkened.x = _agx_hdr_darken(pixel->x, params); - darkened.y = _agx_hdr_darken(pixel->y, params); - darkened.z = _agx_hdr_darken(pixel->z, params); + darkened.x = _agx_hdr_darken(pixel->x, hdr); + darkened.y = _agx_hdr_darken(pixel->y, hdr); + darkened.z = _agx_hdr_darken(pixel->z, hdr); float4 hsv_after = RGB_2_HSV(darkened); - hsv_after.x = _agx_lerp_hue(hsv_before.x, hsv_after.x, 1.0f - params->hdr_purity); - hsv_after.y = hsv_before.y + params->hdr_purity * (hsv_after.y - hsv_before.y); + hsv_after.x = _agx_lerp_hue(hsv_before.x, hsv_after.x, 1.0f - hdr->purity); + hsv_after.y = hsv_before.y + hdr->purity * (hsv_after.y - hsv_before.y); darkened = HSV_2_RGB(hsv_after); - pixel->x = params->hdr_headroom * darkened.x; - pixel->y = params->hdr_headroom * darkened.y; - pixel->z = params->hdr_headroom * darkened.z; + pixel->x = hdr->headroom * darkened.x; + pixel->y = hdr->headroom * darkened.y; + pixel->z = hdr->headroom * darkened.z; } static inline void _agx_tone_mapping(float4 *rgb_in_out, const dt_iop_agx_tone_mapping_params_t *params, constant float *rendering_to_xyz) @@ -237,9 +243,9 @@ static inline void _agx_tone_mapping(float4 *rgb_in_out, const dt_iop_agx_tone_m transformed_pixel.y = dtcl_pow(fmax(0.0f, transformed_pixel.y), params->curve_gamma); transformed_pixel.z = dtcl_pow(fmax(0.0f, transformed_pixel.z), params->curve_gamma); - if(params->hdr) + if(params->hdr.enabled) { - _agx_hdr(&transformed_pixel, params); + _agx_hdr(&transformed_pixel, ¶ms->hdr); } if(params->restore_hue) diff --git a/src/iop/agx.c b/src/iop/agx.c index fd2ace93766..dce07664633 100644 --- a/src/iop/agx.c +++ b/src/iop/agx.c @@ -210,6 +210,19 @@ typedef struct dt_iop_agx_gui_data_t GtkWidget *set_post_curve_primaries_from_pre_button; } dt_iop_agx_gui_data_t; +// HDR output, see _set_hdr_params() +typedef struct hdr_params_t +{ + gboolean enabled; + float headroom; + float purity; + float log_min; + float pivot_x; + float pivot_y; + float toe_scale; + float shoulder_scale; +} hdr_params_t; + typedef struct tone_mapping_params_t { float black_relative_ev; @@ -259,15 +272,7 @@ typedef struct tone_mapping_params_t gboolean look_tuned; gboolean restore_hue; - // HDR output, see _set_hdr_params() - gboolean hdr; - float hdr_headroom; - float hdr_purity; - float hdr_log_min; - float hdr_pivot_x; - float hdr_pivot_y; - float hdr_toe_scale; - float hdr_shoulder_scale; + hdr_params_t hdr; } tone_mapping_params_t; typedef struct primaries_params_t @@ -876,7 +881,7 @@ static inline float _calculate_slope_gamma_compensation(const float gamma, static tone_mapping_params_t _calculate_tone_mapping_params(const dt_iop_agx_params_t *p) { tone_mapping_params_t tone_mapping_params = { 0 }; - tone_mapping_params.hdr_headroom = 1.f; + tone_mapping_params.hdr.headroom = 1.f; // look tone_mapping_params.look_lift = p->look_lift; @@ -1002,25 +1007,24 @@ static tone_mapping_params_t _calculate_tone_mapping_params(const dt_iop_agx_par // the pivot up to 1.0, the peak, maps to [0, 1]. It moves the pivot output // down by the headroom, so the pivot keeps its SDR brightness, with slope 1 // and power 3 below it and power 1 above it -static void _set_hdr_params(tone_mapping_params_t *params, +static void _set_hdr_params(hdr_params_t *hdr, const dt_iop_agx_params_t *p, const float headroom) { const float pivot = CLAMPF(p->curve_pivot_y_linear_output, _epsilon, 0.99f); - params->hdr = TRUE; - params->hdr_headroom = headroom; - params->hdr_purity = p->hdr_purity; - params->hdr_log_min = log2f(pivot) - 20.f; - params->hdr_pivot_x = 1.f - log2f(pivot) / params->hdr_log_min; - params->hdr_pivot_y = 1.f - log2f(pivot / headroom) / params->hdr_log_min; - params->hdr_toe_scale = -_scale(1.f, 1.f, 1.f - params->hdr_pivot_x, 1.f - params->hdr_pivot_y, - 1.f, 3.f); + hdr->enabled = TRUE; + hdr->headroom = headroom; + hdr->purity = p->hdr_purity; + hdr->log_min = log2f(pivot) - 20.f; + hdr->pivot_x = 1.f - log2f(pivot) / hdr->log_min; + hdr->pivot_y = 1.f - log2f(pivot / headroom) / hdr->log_min; + hdr->toe_scale = -_scale(1.f, 1.f, 1.f - hdr->pivot_x, 1.f - hdr->pivot_y, 1.f, 3.f); // _scale() cannot give the negative scale of a convex segment: with // power 1 the shoulder is the hyperbola through the pivot and (1, 1) - const float dx = 1.f - params->hdr_pivot_x; - const float dy = 1.f - params->hdr_pivot_y; - params->hdr_shoulder_scale = -dx * dy / (dy - dx); + const float dx = 1.f - hdr->pivot_x; + const float dy = 1.f - hdr->pivot_y; + hdr->shoulder_scale = -dx * dy / (dy - dx); } // when following the output, the curve's output, target white included, @@ -1041,7 +1045,7 @@ static tone_mapping_params_t _pipe_tone_mapping_params(const dt_iop_agx_params_t tone_mapping_params_t tone_mapping_params = _calculate_tone_mapping_params(&pipe_p); if(hdr_headroom > 1.f) - _set_hdr_params(&tone_mapping_params, &pipe_p, hdr_headroom); + _set_hdr_params(&tone_mapping_params.hdr, &pipe_p, hdr_headroom); return tone_mapping_params; } @@ -1144,21 +1148,21 @@ static void _adjust_relative_exposure_from_exposure_params(dt_iop_module_t *self // x is linear, relative to the peak static inline float _hdr_darken(const float x, - const tone_mapping_params_t *params) + const hdr_params_t *hdr) { - const float log_x = CLIP(1.f - log2f(fmaxf(x, 1e-12f)) / params->hdr_log_min); - const float u = log_x - params->hdr_pivot_x; + const float log_x = CLIP(1.f - log2f(fmaxf(x, 1e-12f)) / hdr->log_min); + const float u = log_x - hdr->pivot_x; const float log_y = u < 0.f - ? _scaled_sigmoid(log_x, params->hdr_toe_scale, 1.f, 3.f, - params->hdr_pivot_x, params->hdr_pivot_y) - : params->hdr_pivot_y - + params->hdr_shoulder_scale * u / (params->hdr_shoulder_scale + u); - return x > 0.f ? exp2f(params->hdr_log_min * (1.f - log_y)) : 0.f; + ? _scaled_sigmoid(log_x, hdr->toe_scale, 1.f, 3.f, + hdr->pivot_x, hdr->pivot_y) + : hdr->pivot_y + + hdr->shoulder_scale * u / (hdr->shoulder_scale + u); + return x > 0.f ? exp2f(hdr->log_min * (1.f - log_y)) : 0.f; } static inline void _agx_hdr(dt_aligned_pixel_t pixel_in_out, - const tone_mapping_params_t *params) + const hdr_params_t *hdr) { dt_aligned_pixel_t hsv_before = { 0.f }; dt_aligned_pixel_t hsv_after = { 0.f }; @@ -1166,17 +1170,17 @@ static inline void _agx_hdr(dt_aligned_pixel_t pixel_in_out, dt_RGB_2_HSV(pixel_in_out, hsv_before); for_three_channels(k, aligned(pixel_in_out, darkened : 16)) - darkened[k] = _hdr_darken(pixel_in_out[k], params); + darkened[k] = _hdr_darken(pixel_in_out[k], hdr); dt_RGB_2_HSV(darkened, hsv_after); // keep part of the hue and saturation shift of the per-channel darkening - hsv_after[0] = _lerp_hue(hsv_before[0], hsv_after[0], 1.f - params->hdr_purity); - hsv_after[1] = hsv_before[1] + params->hdr_purity * (hsv_after[1] - hsv_before[1]); + hsv_after[0] = _lerp_hue(hsv_before[0], hsv_after[0], 1.f - hdr->purity); + hsv_after[1] = hsv_before[1] + hdr->purity * (hsv_after[1] - hsv_before[1]); dt_HSV_2_RGB(hsv_after, darkened); // back to 1.0 = reference white for_three_channels(k, aligned(pixel_in_out, darkened : 16)) - pixel_in_out[k] = params->hdr_headroom * darkened[k]; + pixel_in_out[k] = hdr->headroom * darkened[k]; } static void _agx_tone_mapping(dt_aligned_pixel_t rgb_in_out, @@ -1206,8 +1210,8 @@ static void _agx_tone_mapping(dt_aligned_pixel_t rgb_in_out, transformed_pixel[k] = powf(fmaxf(0.f, transformed_pixel[k]), params->curve_gamma); } - if(params->hdr) - _agx_hdr(transformed_pixel, params); + if(params->hdr.enabled) + _agx_hdr(transformed_pixel, ¶ms->hdr); // get post-curve chroma angle if(params->restore_hue) @@ -1233,8 +1237,8 @@ static float _rendered_curve(const float x, const tone_mapping_params_t *params) { const float y = _apply_curve(x, params); - return params->hdr - ? powf(_hdr_darken(powf(y, params->curve_gamma), params), 1.f / params->curve_gamma) + return params->hdr.enabled + ? powf(_hdr_darken(powf(y, params->curve_gamma), ¶ms->hdr), 1.f / params->curve_gamma) : y; } @@ -1331,7 +1335,7 @@ static void _apply_auto_pivot_xy(dt_iop_module_t *self, const dt_iop_order_iccpr // try to avoid changing the brightness of the pivot; with HDR // output the pivot keeps its brightness relative to reference white const float target_y_linearised = - powf(target_y, p->curve_gamma) * tone_mapping_params.hdr_headroom; + powf(target_y, p->curve_gamma) * tone_mapping_params.hdr.headroom; p->curve_pivot_y_linear_output = fminf(1.f, target_y_linearised); p->curve_pivot_x = picked_pivot_x; @@ -1673,7 +1677,7 @@ static gboolean _agx_draw_curve(GtkWidget *widget, set_color(cr, darktable.bauhaus->graph_fg); // Use standard text color // the top of the graph is the peak, so HDR output labels it above 100% - snprintf(text, sizeof(text), "%.0f%%", 100.0f * y_linear * tone_mapping_params.hdr_headroom); + snprintf(text, sizeof(text), "%.0f%%", 100.0f * y_linear * tone_mapping_params.hdr.headroom); pango_layout_set_text(layout, text, -1); pango_layout_get_pixel_extents(layout, &g->ink, NULL); From ecb9c6344d06725a730fc8f5eba4672293c15a23 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:21:47 +0000 Subject: [PATCH 06/13] export: keep the PQ peak an export was started with The PQ peak was read from the export module's setting whenever an image's output profile was set, so changing it while a batch ran also changed the images still waiting in that batch. The export module now reads it once, when the export is queued, and the job stores it with its other settings. The job hands it to the pipes it runs through a per-thread value in iop_profile.c, rather than by writing the setting back, which would change the darkroom and undo a new value picked in the export module during the batch. darktable-cli and other pipes still read the setting. --- src/common/iop_profile.c | 17 +++++++++++++++-- src/common/iop_profile.h | 4 ++++ src/control/jobs/control_jobs.c | 7 +++++++ src/control/jobs/control_jobs.h | 1 + src/libs/export.c | 3 ++- 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/common/iop_profile.c b/src/common/iop_profile.c index 182fa705d4b..442a11d2f35 100644 --- a/src/common/iop_profile.c +++ b/src/common/iop_profile.c @@ -806,13 +806,26 @@ dt_ioppr_set_pipe_input_profile_info(struct dt_develop_t *dev, return profile_info; } +// per thread, so that an export job keeps the peak it was started with +// without writing the setting shared with the export module and the darkroom +static __thread int _thread_pq_peak_nits = 0; + +void dt_ioppr_set_thread_pq_peak_nits(const int nits) +{ + _thread_pq_peak_nits = nits; +} + static float _output_headroom(const dt_colorspaces_color_profile_type_t type) { switch(dt_colorspaces_hdr_transfer(type)) { case DT_HDR_PQ: - return fmaxf(1.0f, dt_conf_get_int("plugins/lighttable/export/pq_peak_nits") - / DT_HDR_REFERENCE_WHITE_NITS); + { + const int nits = _thread_pq_peak_nits > 0 + ? _thread_pq_peak_nits + : dt_conf_get_int("plugins/lighttable/export/pq_peak_nits"); + return fmaxf(1.0f, nits / DT_HDR_REFERENCE_WHITE_NITS); + } case DT_HDR_HLG_REC2020: case DT_HDR_HLG_P3: // colorout's OOTF clips HLG at the nominal 1000 nit display diff --git a/src/common/iop_profile.h b/src/common/iop_profile.h index e645538fd92..13f28f0c421 100644 --- a/src/common/iop_profile.h +++ b/src/common/iop_profile.h @@ -116,6 +116,10 @@ dt_ioppr_set_pipe_output_profile_info(struct dt_develop_t *dev, const char *filename, const int intent); +/** set the PQ peak, in nits, for the pipes processed by the calling thread, + or 0 to read it from the export module's setting again */ +void dt_ioppr_set_thread_pq_peak_nits(const int nits); + /** Record the export profile, used as a cache-identity tag. Returns the (possibly NULL) interned profile-info pointer, no fallback for sRGB in case of failure. */ dt_iop_order_iccprofile_info_t * diff --git a/src/control/jobs/control_jobs.c b/src/control/jobs/control_jobs.c index f7c446f1bac..60fde57a422 100644 --- a/src/control/jobs/control_jobs.c +++ b/src/control/jobs/control_jobs.c @@ -105,6 +105,7 @@ typedef struct dt_control_export_t dt_colorspaces_color_profile_type_t icc_type; gchar *icc_filename; dt_iop_color_intent_t icc_intent; + int pq_peak_nits; gchar *metadata_export; } dt_control_export_t; @@ -2096,6 +2097,9 @@ static int32_t _control_export_job_run(dt_job_t *job) GList *t = params->index; double prev_time = 0; + // the peak set when the export was queued, not the setting's current value + dt_ioppr_set_thread_pq_peak_nits(settings->pq_peak_nits); + while(t && !_job_cancelled(job)) { const dt_imgid_t imgid = GPOINTER_TO_INT(t->data); @@ -2148,6 +2152,7 @@ static int32_t _control_export_job_run(dt_job_t *job) fraction += 1.0 / total; _update_progress(job, fraction, &prev_time); } + dt_ioppr_set_thread_pq_peak_nits(0); g_list_free_full(metadata.list, g_free); if(mstorage->finalize_store) mstorage->finalize_store(mstorage, sdata); @@ -2747,6 +2752,7 @@ void dt_control_export(GList *imgid_list, const dt_colorspaces_color_profile_type_t icc_type, const gchar *icc_filename, const dt_iop_color_intent_t icc_intent, + const int pq_peak_nits, const gchar *metadata_export) { dt_job_t *job = dt_control_job_create(&_control_export_job_run, "export"); @@ -2803,6 +2809,7 @@ void dt_control_export(GList *imgid_list, data->icc_type = icc_type; data->icc_filename = g_strdup(icc_filename); data->icc_intent = icc_intent; + data->pq_peak_nits = pq_peak_nits; data->metadata_export = g_strdup(metadata_export); dt_control_job_add_progress(job, _("export images"), TRUE); diff --git a/src/control/jobs/control_jobs.h b/src/control/jobs/control_jobs.h index 5544a255de1..8cf519dc216 100644 --- a/src/control/jobs/control_jobs.h +++ b/src/control/jobs/control_jobs.h @@ -64,6 +64,7 @@ void dt_control_export(GList *imgid_list, const dt_colorspaces_color_profile_type_t icc_type, const gchar *icc_filename, const dt_iop_color_intent_t icc_intent, + const int pq_peak_nits, const gchar *metadata_export); void dt_control_merge_hdr(void); void dt_control_import(GList *imgs, const char *datetime_override, const gboolean inplace); diff --git a/src/libs/export.c b/src/libs/export.c index da8bfc19256..1a15212a073 100644 --- a/src/libs/export.c +++ b/src/libs/export.c @@ -421,13 +421,14 @@ static void _export_with_current_settings(dt_lib_module_t *self) dt_conf_get_int(CONFIG_PREFIX "icctype"); gchar *icc_filename = dt_conf_get_string(CONFIG_PREFIX "iccprofile"); const dt_iop_color_intent_t icc_intent = dt_conf_get_int(CONFIG_PREFIX "iccintent"); + const int pq_peak_nits = dt_conf_get_int(CONFIG_PREFIX "pq_peak_nits"); GList *list = dt_act_on_get_images(TRUE, TRUE, TRUE); dt_control_export(list, max_width, max_height, format_index, storage_index, high_quality, upscale, scaledimension, is_scaling, scale_factor, export_masks, style, style_append, - icc_type, icc_filename, icc_intent, + icc_type, icc_filename, icc_intent, pq_peak_nits, d->metadata_export); g_free(icc_filename); From 5372ab35a9f9f982169c0793ef802549e1116228 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 02:36:51 +0000 Subject: [PATCH 07/13] export: store the PQ peak in export presets Export presets did not include the PQ peak, so applying a preset kept whatever peak was set, and multi-preset export could not give each preset its own peak. The peak is now stored after print_dpi, so the export module's params go to version 9. Presets saved by older versions get the default peak of 1000 nits. Since an export job keeps the peak it was queued with, each preset of a multi-preset export renders with its own peak. --- src/libs/export.c | 48 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/libs/export.c b/src/libs/export.c index 1a15212a073..d43d8bf0579 100644 --- a/src/libs/export.c +++ b/src/libs/export.c @@ -44,7 +44,7 @@ #include -DT_MODULE(8) +DT_MODULE(9) typedef enum dt_export_batch_cols_t { @@ -1937,9 +1937,10 @@ void init_presets(dt_lib_module_t *self) // extract the interesting parts from the blob const char *buf = (const char *)op_params; - // skip 9*int32_t: max_width, max_height, upscale, high_quality, - // export_masks, iccintent, icctype, dimensions_type, print_dpi - buf += 9 * sizeof(int32_t); + // skip 10*int32_t: max_width, max_height, upscale, high_quality, + // export_masks, iccintent, icctype, dimensions_type, print_dpi, + // pq_peak_nits + buf += 10 * sizeof(int32_t); // skip scale string buf += strlen(buf) + 1; // skip metadata presets string @@ -2351,6 +2352,35 @@ void *legacy_params(dt_lib_module_t *self, *new_version = 8; return new_params; } + else if(old_version == 8) + { + // add pq_peak_nits + + // format of v8: + // - 9 x int32_t (max_width, max_height, upscale, high_quality, + // export_masks, iccintent, icctype, dimensions_type, print_dpi) + // - old rest + // format of v9: + // - 10 x int32_t (max_width, max_height, upscale, high_quality, + // export_masks, iccintent, icctype, dimensions_type, print_dpi, + // pq_peak_nits) + // - old rest + + const int32_t pq_peak_nits = + dt_confgen_get_int(CONFIG_PREFIX "pq_peak_nits", DT_DEFAULT); + + const size_t new_params_size = old_params_size + sizeof(int32_t); + void *new_params = calloc(1, new_params_size); + + memcpy(new_params, old_params, sizeof(int32_t) * 9); + memcpy((uint8_t *)new_params + sizeof(int32_t) * 9, &pq_peak_nits, sizeof(int32_t)); + memcpy((uint8_t *)new_params + sizeof(int32_t) * 10, + (uint8_t *)old_params + sizeof(int32_t) * 9, + old_params_size - sizeof(int32_t) * 9); + *new_size = new_params_size; + *new_version = 9; + return new_params; + } return NULL; } @@ -2395,6 +2425,7 @@ void *get_params(dt_lib_module_t *self, int *size) const int32_t upscale = dt_conf_get_bool(CONFIG_PREFIX "upscale") ? 1 : 0; const int32_t dimensions_type = dt_conf_get_int(CONFIG_PREFIX "dimensions_type"); const int32_t print_dpi = dt_conf_get_int(CONFIG_PREFIX "print_dpi"); + const int32_t pq_peak_nits = dt_conf_get_int(CONFIG_PREFIX "pq_peak_nits"); gchar *scale = dt_conf_get_string(CONFIG_PREFIX "resizing_factor"); const int32_t high_quality = dt_conf_get_bool(CONFIG_PREFIX "high_quality_processing") ? 1 : 0; @@ -2428,7 +2459,7 @@ void *get_params(dt_lib_module_t *self, int *size) + 4 * sizeof(int32_t) + fsize + ssize - + 9 * sizeof(int32_t) + + 10 * sizeof(int32_t) + strlen(scale) + 1 + strlen(iccfilename) + 1 + strlen(metadata_export) + 1; @@ -2455,6 +2486,8 @@ void *get_params(dt_lib_module_t *self, int *size) pos += sizeof(int32_t); memcpy(params + pos, &print_dpi, sizeof(int32_t)); pos += sizeof(int32_t); + memcpy(params + pos, &pq_peak_nits, sizeof(int32_t)); + pos += sizeof(int32_t); memcpy(params + pos, scale, strlen(scale) + 1); pos += strlen(scale) + 1; memcpy(params + pos, metadata_export, strlen(metadata_export) + 1); @@ -2522,6 +2555,8 @@ int set_params(dt_lib_module_t *self, buf += sizeof(int32_t); const int print_dpi = *(const int *)buf; buf += sizeof(int32_t); + const int pq_peak_nits = *(const int *)buf; + buf += sizeof(int32_t); const char *scale = buf; buf += strlen(scale) + 1; const char *metadata_export = buf; @@ -2580,7 +2615,7 @@ int set_params(dt_lib_module_t *self, + 4 * sizeof(int32_t) + fsize + ssize - + 9 * sizeof(int32_t) + + 10 * sizeof(int32_t) + strlen(scale) + 1 + strlen(metadata_export) + 1 + strlen(iccfilename) + 1) @@ -2605,6 +2640,7 @@ int set_params(dt_lib_module_t *self, dt_bauhaus_combobox_set(d->high_quality, high_quality ? 1 : 0); dt_bauhaus_combobox_set(d->export_masks, export_masks ? 1 : 0); dt_bauhaus_combobox_set(d->dimensions_type, dimensions_type); + dt_bauhaus_slider_set(d->pq_peak, pq_peak_nits); _size_update_display(d); // propagate to modules From ec545b807330a646f95c90a0b3b8eee1a8a21a7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:54:50 +0000 Subject: [PATCH 08/13] cli: add --pq-peak to set the peak of PQ output darktable-cli could only set the PQ peak through the export module's setting, with --core --conf plugins/lighttable/export/pq_peak_nits=... It now takes --pq-peak , from 203 to 10000, which applies to the exports of that run only. --- src/cli/main.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/cli/main.c b/src/cli/main.c index a4bdeea5e49..ca827673ffe 100644 --- a/src/cli/main.c +++ b/src/cli/main.c @@ -34,6 +34,7 @@ #include "common/history.h" #include "common/image.h" #include "common/image_cache.h" +#include "common/iop_profile.h" #include "common/points.h" #include "control/conf.h" #include "develop/imageop.h" @@ -102,6 +103,8 @@ fprintf(stderr, "darktable %s\n" " --icc-file specify icc filename, default to NONE\n" " --icc-intent specify icc intent, default to LAST\n" " use --help icc-intent for list of supported intents\n" + " --pq-peak peak luminance of PQ output, 203 to 10000,\n" + " default from the export module's setting\n" " --print-paths print the resolved configdir, cachedir, tmpdir,\n" " datadir, moduledir, localedir and library paths\n" " for this install, then exit\n" @@ -250,6 +253,7 @@ int main(int argc, char *arg[]) dt_colorspaces_color_profile_type_t icc_type = DT_COLORSPACE_NONE; gchar *icc_filename = NULL; dt_iop_color_intent_t icc_intent = DT_INTENT_LAST; + int pq_peak_nits = 0; int k; for(k = 1; k < argc; k++) @@ -438,6 +442,17 @@ int main(int argc, char *arg[]) exit(1); } } + else if(!strcmp(arg[k], "--pq-peak") && argc > k + 1) + { + k++; + pq_peak_nits = atoi(arg[k]); + if(pq_peak_nits < DT_HDR_REFERENCE_WHITE_NITS || pq_peak_nits > 10000) + { + fprintf(stderr, _("incorrect PQ peak for --pq-peak: '%s'\n"), arg[k]); + usage(arg[0]); + exit(1); + } + } else if(!strcmp(arg[k], "-v") || !strcmp(arg[k], "--verbose")) { verbose = TRUE; @@ -880,6 +895,9 @@ int main(int argc, char *arg[]) // TODO: add a callback to set the bpp without going through the config + // the exports below run in this thread + dt_ioppr_set_thread_pq_peak_nits(pq_peak_nits); + int num = 1, res = 0; for(GList *iter = id_list; iter; iter = g_list_next(iter), num++) { From c4fb0bf54f439251950c2e719fa16f660e1823dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:01:00 +0000 Subject: [PATCH 09/13] RELEASE_NOTES.md: PQ peak in export presets and darktable-cli --- RELEASE_NOTES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b2ef4ac972c..cfd6db011a6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -233,7 +233,8 @@ changes (where available). - AgX and sigmoid now adapt to HDR output, so one edit exports to SDR, HLG and PQ alike. HLG output peaks at 1000 nits, and PQ output at the new "PQ peak" setting of the export module, 1000 nits by - default. With AgX, shadows and midtones keep their SDR brightness + default, which export presets store and darktable-cli sets with + `--pq-peak`. With AgX, shadows and midtones keep their SDR brightness and highlights extend to the peak, following Blender's AgX HDR, with a new "HDR purity" slider for the highlights' saturation. Sigmoid scales its target white to the peak, up to 1600% (3248 nits). SDR From a7053e1369eb43d0a333184efcc028c765c16be3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:57:51 +0000 Subject: [PATCH 10/13] agx: keep the shoulder fit finite when the pivot reaches target white With pivot target output at 100%, the rise left for the shoulder is clamped to _epsilon, and _scale() raised it to -power in float. Above a shoulder power of about 6.4 that overflowed to inf, the scale became zero, and _scaled_sigmoid() returned NaN, so everything above the pivot rendered black. This already happened in SDR with shoulder power 7 and above; following an HDR output's peak raises the power enough to hit it at the default 3.3. _scale() now computes the powers in double. Normal settings render as before. --- src/iop/agx.c | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/iop/agx.c b/src/iop/agx.c index dce07664633..8070d28d407 100644 --- a/src/iop/agx.c +++ b/src/iop/agx.c @@ -582,12 +582,15 @@ static inline float _line(const float 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); + // in double: when the pivot reaches target white, the rise is clamped to + // _epsilon and powf() overflows to inf for powers above about 6.4, which + // gives a zero scale and NaN in _scaled_sigmoid() + const double transformed_projected_rise = pow(projected_rise, -power); + const double transformed_actual_rise = pow(actual_rise, -power); - const float base = fmaxf(_epsilon, transformed_actual_rise - transformed_projected_rise); + const double base = fmax(_epsilon, transformed_actual_rise - transformed_projected_rise); - const float scale_value = powf(base, -1.f / power); + const float scale_value = pow(base, -1.0 / power); // avoid 'explosions' return fminf(1e9f, scale_value); @@ -1038,7 +1041,7 @@ static tone_mapping_params_t _pipe_tone_mapping_params(const dt_iop_agx_params_t const float hdr_headroom = fmaxf(1.f, headroom); dt_iop_agx_params_t pipe_p = *p; - // capped at the slider's maximum: above it the curve fit in _scale() + // capped at the slider's maximum: well above it, powf() in _sigmoid() // overflows, e.g. for a high pivot target and the highest PQ peaks pipe_p.curve_shoulder_power = fminf(pipe_p.curve_shoulder_power * powf(hdr_headroom, log10f(2.f)), 10.f); From 84532d8eae7d12a1091d1f14918bd47c9c9de8c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:58:17 +0000 Subject: [PATCH 11/13] agx: pick the pivot on the SDR curve, also on an HDR display The pivot picker read the picked value off the curve for the darkroom's output, so on an HDR display it stored a different pivot target output than the same pick on an SDR display, and the edit's SDR export then depended on the display it was made on. Pivot target output is an SDR value that HDR output keeps, so the picker now uses the SDR curve, as it did before HDR output. --- src/iop/agx.c | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/iop/agx.c b/src/iop/agx.c index 8070d28d407..04159e5f005 100644 --- a/src/iop/agx.c +++ b/src/iop/agx.c @@ -1330,16 +1330,16 @@ static void _apply_auto_pivot_xy(dt_iop_module_t *self, const dt_iop_order_iccpr const float range = p->range_white_relative_ev - p->range_black_relative_ev; const float picked_pivot_x = (picked_ev - p->range_black_relative_ev) / range; + // the SDR curve, also with HDR output: pivot target output is an SDR + // value, and the pick must not depend on the display it was made on const tone_mapping_params_t tone_mapping_params = - _pipe_tone_mapping_params(p, g->output_headroom); + _calculate_tone_mapping_params(p); // see where the target_pivot is currently mapped - const float target_y = _rendered_curve(picked_pivot_x, &tone_mapping_params); - // try to avoid changing the brightness of the pivot; with HDR - // output the pivot keeps its brightness relative to reference white - const float target_y_linearised = - powf(target_y, p->curve_gamma) * tone_mapping_params.hdr.headroom; - p->curve_pivot_y_linear_output = fminf(1.f, target_y_linearised); + const float target_y = _apply_curve(picked_pivot_x, &tone_mapping_params); + // try to avoid changing the brightness of the pivot + const float target_y_linearised = powf(target_y, p->curve_gamma); + p->curve_pivot_y_linear_output = target_y_linearised; p->curve_pivot_x = picked_pivot_x; DT_ENTER_GUI_UPDATE(); From 020d1a5ac9fce08b829f04f9aaa14e3d5f2aaea1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:59:24 +0000 Subject: [PATCH 12/13] export: keep presets from changing a PQ peak they don't use Every export preset stored the PQ peak, and presets from before it gave 1000 nits. Applying an sRGB preset, or an older one, therefore changed the PQ peak while its slider was hidden, and later PQ exports used that value. Multi-preset export also left the last preset's peak behind when the current profile was not PQ. Presets now store the peak only for a PQ or "image settings" profile, and 0 otherwise, which leaves the peak as it is when applied. Older presets get 0 too. Multi-preset export puts the peak back afterwards. The upgrade of an older preset also checks that it is long enough before copying it. --- src/libs/export.c | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/libs/export.c b/src/libs/export.c index d43d8bf0579..4ff8290c3d4 100644 --- a/src/libs/export.c +++ b/src/libs/export.c @@ -445,6 +445,8 @@ static void _export_with_preset(const gchar *preset_name, dt_lib_module_t *self) // get current settings from the GUI int params_size; void *params = get_params(self, ¶ms_size); + // not in params when the current profile cannot be PQ + const int pq_peak_nits = dt_conf_get_int(CONFIG_PREFIX "pq_peak_nits"); sqlite3_stmt *stmt; DT_DEBUG_SQLITE3_PREPARE_V2( @@ -475,6 +477,7 @@ static void _export_with_preset(const gchar *preset_name, dt_lib_module_t *self) // restore previous settings set_params(self, params, params_size); + dt_bauhaus_slider_set(d->pq_peak, pq_peak_nits); } static void _export_button_clicked(GtkWidget *widget, dt_lib_module_t *self) @@ -989,11 +992,14 @@ static void _storage_changed(GtkWidget *widget, dt_lib_module_t *self) } // the PQ peak only matters when the export may be PQ +static gboolean _export_may_be_pq(const dt_colorspaces_color_profile_type_t icctype) +{ + return icctype == DT_COLORSPACE_NONE || dt_colorspaces_hdr_transfer(icctype) == DT_HDR_PQ; +} + static void _update_pq_peak_visibility(dt_lib_export_t *d) { - const dt_colorspaces_color_profile_type_t icctype = dt_conf_get_int(CONFIG_PREFIX "icctype"); - gtk_widget_set_visible(d->pq_peak, icctype == DT_COLORSPACE_NONE - || dt_colorspaces_hdr_transfer(icctype) == DT_HDR_PQ); + gtk_widget_set_visible(d->pq_peak, _export_may_be_pq(dt_conf_get_int(CONFIG_PREFIX "icctype"))); } static void _set_profile_conf(GtkWidget *widget) @@ -2366,8 +2372,10 @@ void *legacy_params(dt_lib_module_t *self, // pq_peak_nits) // - old rest - const int32_t pq_peak_nits = - dt_confgen_get_int(CONFIG_PREFIX "pq_peak_nits", DT_DEFAULT); + if(old_params_size < sizeof(int32_t) * 9) return NULL; + + // 0: applying the preset leaves the PQ peak as it is, as before + const int32_t pq_peak_nits = 0; const size_t new_params_size = old_params_size + sizeof(int32_t); void *new_params = calloc(1, new_params_size); @@ -2425,7 +2433,10 @@ void *get_params(dt_lib_module_t *self, int *size) const int32_t upscale = dt_conf_get_bool(CONFIG_PREFIX "upscale") ? 1 : 0; const int32_t dimensions_type = dt_conf_get_int(CONFIG_PREFIX "dimensions_type"); const int32_t print_dpi = dt_conf_get_int(CONFIG_PREFIX "print_dpi"); - const int32_t pq_peak_nits = dt_conf_get_int(CONFIG_PREFIX "pq_peak_nits"); + // 0 when the export cannot be PQ, so that applying the preset does not + // change a PQ peak that is hidden and unused for its profile + const int32_t pq_peak_nits = + _export_may_be_pq(icctype) ? dt_conf_get_int(CONFIG_PREFIX "pq_peak_nits") : 0; gchar *scale = dt_conf_get_string(CONFIG_PREFIX "resizing_factor"); const int32_t high_quality = dt_conf_get_bool(CONFIG_PREFIX "high_quality_processing") ? 1 : 0; @@ -2640,7 +2651,7 @@ int set_params(dt_lib_module_t *self, dt_bauhaus_combobox_set(d->high_quality, high_quality ? 1 : 0); dt_bauhaus_combobox_set(d->export_masks, export_masks ? 1 : 0); dt_bauhaus_combobox_set(d->dimensions_type, dimensions_type); - dt_bauhaus_slider_set(d->pq_peak, pq_peak_nits); + if(pq_peak_nits > 0) dt_bauhaus_slider_set(d->pq_peak, pq_peak_nits); _size_update_display(d); // propagate to modules From 9430cb4af432a9a0bb44745d84498fc6188e5580 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:59:35 +0000 Subject: [PATCH 13/13] RELEASE_NOTES.md: AgX black highlights with pivot output at 100% --- RELEASE_NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cfd6db011a6..dc8ec53da1b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -371,6 +371,9 @@ changes (where available). opened: going back in the history showed masks from before the step that added them. +- Fixed AgX rendering everything above the pivot black when pivot + target output was 100% and shoulder power was 7 or more. + - Fixed shift-click on the selected entry of the history stack, which did nothing instead of revealing that module in the right-hand panel.