From 7b728230ff14f46ad065921f201178c3d3335934 Mon Sep 17 00:00:00 2001 From: Andrii Ryzhkov Date: Tue, 14 Apr 2026 12:52:53 +0200 Subject: [PATCH 1/6] Denoise with working profile to sRGB primaries conversion and wide-gamut pass-through --- src/common/ai/restore.c | 199 +++++++++++++++++++++++++-- src/common/ai/restore.h | 23 ++++ src/libs/neural_restore.c | 273 +++++++++++++++++++++++++++++++++----- 3 files changed, 456 insertions(+), 39 deletions(-) diff --git a/src/common/ai/restore.c b/src/common/ai/restore.c index c344da35d8b1..26d9b3ea62e4 100644 --- a/src/common/ai/restore.c +++ b/src/common/ai/restore.c @@ -20,7 +20,11 @@ #include "ai/backend.h" #include "common/darktable.h" #include "common/ai_models.h" +#include "common/colorspaces.h" +#include "common/colorspaces_inline_conversions.h" #include "common/imagebuf.h" +#include "common/math.h" +#include "common/matrices.h" #include "control/jobs.h" // forward-declare to avoid pulling in dwt.h (which @@ -60,6 +64,13 @@ struct dt_restore_context_t int tile_size; // tile size used to create the current session char *dim_h; // symbolic height dim name used for session overrides char *dim_w; // symbolic width dim name used for session overrides + // color management: convert from working profile to sRGB before + // inference (model was trained on sRGB primaries) and back after. + // if has_profile is FALSE, fall back to gamma-only conversion + // (treats working-profile numbers as if they were sRGB) + gboolean has_profile; + float wp_to_srgb[9]; // working profile RGB -> sRGB linear (row-major) + float srgb_to_wp[9]; // sRGB linear -> working profile RGB (row-major) gint ref_count; }; @@ -282,6 +293,63 @@ void dt_restore_unref(dt_restore_context_t *ctx) } } +void dt_restore_set_profile(dt_restore_context_t *ctx, void *profile) +{ + if(!ctx) return; + if(!profile) + { + ctx->has_profile = FALSE; + return; + } + + float primaries[3][2], whitepoint[2]; + if(!dt_colorspaces_get_primaries_and_whitepoint_from_profile( + (cmsHPROFILE)profile, primaries, whitepoint)) + { + dt_print(DT_DEBUG_AI, + "[restore] could not read primaries from working profile, " + "falling back to gamma-only conversion"); + ctx->has_profile = FALSE; + return; + } + + // build WP -> XYZ (stored transposed by dt, convert to row-major) + dt_colormatrix_t wp_to_xyz_T; + dt_make_transposed_matrices_from_primaries_and_whitepoint(primaries, + whitepoint, + wp_to_xyz_T); + float wp_to_xyz[9]; + for(int i = 0; i < 3; i++) + for(int j = 0; j < 3; j++) + wp_to_xyz[3 * i + j] = wp_to_xyz_T[j][i]; + + // transpose dt's sRGB<->XYZ matrices (Bradford D50) to row-major + float xyz_to_srgb[9], srgb_to_xyz[9]; + for(int i = 0; i < 3; i++) + for(int j = 0; j < 3; j++) + { + xyz_to_srgb[3 * i + j] = xyz_to_srgb_transposed[j][i]; + srgb_to_xyz[3 * i + j] = sRGB_to_xyz_transposed[j][i]; + } + + // WP -> sRGB = (XYZ -> sRGB) * (WP -> XYZ) + mat3mul(ctx->wp_to_srgb, xyz_to_srgb, wp_to_xyz); + + // invert WP -> XYZ to get XYZ -> WP, then compose sRGB -> WP + float xyz_to_wp[9]; + if(mat3inv(xyz_to_wp, wp_to_xyz) != 0) + { + dt_print(DT_DEBUG_AI, + "[restore] singular WP->XYZ matrix, falling back to gamma-only"); + ctx->has_profile = FALSE; + return; + } + mat3mul(ctx->srgb_to_wp, xyz_to_wp, srgb_to_xyz); + + ctx->has_profile = TRUE; + dt_print(DT_DEBUG_AI, "[restore] working profile color matrices ready"); +} + static gboolean _model_available(dt_restore_env_t *env, const char *task) { @@ -401,14 +469,53 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, const int out_w = w * scale; const int out_h = h * scale; const size_t out_pixels = (size_t)out_w * out_h * 3; - - // apply sRGB transfer function (gamma only, no primaries change). - // values > 1.0 pass through to preserve wide-gamut colors + const size_t plane = (size_t)w * h; + + // convert to sRGB gamma-encoded. If a working profile is set, + // first convert primaries (working profile -> sRGB linear) so the + // model sees the image as if it were native sRGB. Otherwise only + // apply the gamma curve (legacy path, shifts hues for wide-gamut). + // input layout is planar NCHW: R plane, then G plane, then B plane. + // in_gamut_mask records which pixels were in sRGB gamut (scale==1 + // only) so the output pass can skip recomputing WP->sRGB float *srgb_in = g_try_malloc(in_pixels * sizeof(float)); + uint8_t *in_gamut_mask = NULL; if(!srgb_in) return 1; + if(ctx->has_profile && scale == 1) + { + in_gamut_mask = g_try_malloc(plane); + if(!in_gamut_mask) + { + g_free(srgb_in); + return 1; + } + } - for(size_t i = 0; i < in_pixels; i++) - srgb_in[i] = _linear_to_srgb(in_patch[i]); + if(ctx->has_profile) + { + const float *M = ctx->wp_to_srgb; + for(size_t p = 0; p < plane; p++) + { + const float r = in_patch[p]; + const float g = in_patch[p + plane]; + const float b = in_patch[p + 2 * plane]; + const float sr = M[0] * r + M[1] * g + M[2] * b; + const float sg = M[3] * r + M[4] * g + M[5] * b; + const float sb = M[6] * r + M[7] * g + M[8] * b; + srgb_in[p] = _linear_to_srgb(sr); + srgb_in[p + plane] = _linear_to_srgb(sg); + srgb_in[p + 2 * plane] = _linear_to_srgb(sb); + if(in_gamut_mask) + in_gamut_mask[p] = (sr >= 0.0f && sr <= 1.0f + && sg >= 0.0f && sg <= 1.0f + && sb >= 0.0f && sb <= 1.0f) ? 1 : 0; + } + } + else + { + for(size_t i = 0; i < in_pixels; i++) + srgb_in[i] = _linear_to_srgb(in_patch[i]); + } const int num_inputs = dt_ai_get_input_count(ctx->ai_ctx); if(num_inputs > MAX_MODEL_INPUTS) @@ -459,12 +566,86 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, &output, 1); g_free(srgb_in); g_free(noise_map); - if(ret != 0) return ret; + if(ret != 0) + { + g_free(in_gamut_mask); + return ret; + } - // sRGB -> linear - for(size_t i = 0; i < out_pixels; i++) - out_patch[i] = _srgb_to_linear(out_patch[i]); + // convert model output back to the working profile + // + // with profile: apply inverse sRGB gamma, then check if the ORIGINAL + // input pixel (converted to sRGB linear) is representable in sRGB + // gamut. if yes, use model output converted back to working profile. + // if no, pass through the original pixel (wide-gamut colors preserved, + // no denoising on those pixels). upscale has no pixel-to-pixel + // correspondence so pass-through is not possible — always use the + // model output + // + // without profile: fall back to per-channel pass-through in the + // original (working-profile-as-sRGB) space + if(ctx->has_profile && scale == 1) + { + const size_t out_plane = (size_t)out_w * out_h; + const float *Mi = ctx->srgb_to_wp; + for(size_t p = 0; p < out_plane; p++) + { + if(in_gamut_mask[p]) + { + const float sr = _srgb_to_linear(out_patch[p]); + const float sg = _srgb_to_linear(out_patch[p + out_plane]); + const float sb = _srgb_to_linear(out_patch[p + 2 * out_plane]); + out_patch[p] = Mi[0] * sr + Mi[1] * sg + Mi[2] * sb; + out_patch[p + out_plane] = Mi[3] * sr + Mi[4] * sg + Mi[5] * sb; + out_patch[p + 2 * out_plane] = Mi[6] * sr + Mi[7] * sg + Mi[8] * sb; + } + else + { + out_patch[p] = in_patch[p]; + out_patch[p + out_plane] = in_patch[p + plane]; + out_patch[p + 2 * out_plane] = in_patch[p + 2 * plane]; + } + } + } + else if(scale == 1) + { + // no profile set: per-channel pass-through, treats working-profile + // numbers as if they were sRGB. colors will be slightly shifted + // for wide-gamut working profiles — rely on the profile path above + // when possible + for(size_t i = 0; i < out_pixels; i++) + { + const float in = in_patch[i]; + out_patch[i] = (in >= 0.0f && in <= 1.0f) + ? _srgb_to_linear(out_patch[i]) + : in; + } + } + else + { + // upscale: no pixel-to-pixel correspondence, use model output as-is + if(ctx->has_profile) + { + const size_t out_plane = (size_t)out_w * out_h; + const float *Mi = ctx->srgb_to_wp; + for(size_t p = 0; p < out_plane; p++) + { + const float sr = _srgb_to_linear(out_patch[p]); + const float sg = _srgb_to_linear(out_patch[p + out_plane]); + const float sb = _srgb_to_linear(out_patch[p + 2 * out_plane]); + out_patch[p] = Mi[0] * sr + Mi[1] * sg + Mi[2] * sb; + out_patch[p + out_plane] = Mi[3] * sr + Mi[4] * sg + Mi[5] * sb; + out_patch[p + 2 * out_plane] = Mi[6] * sr + Mi[7] * sg + Mi[8] * sb; + } + } + else + { + for(size_t i = 0; i < out_pixels; i++) + out_patch[i] = _srgb_to_linear(out_patch[i]); + } + } + g_free(in_gamut_mask); return 0; } diff --git a/src/common/ai/restore.h b/src/common/ai/restore.h index 33ca7372ef00..077621bfc400 100644 --- a/src/common/ai/restore.h +++ b/src/common/ai/restore.h @@ -113,6 +113,29 @@ dt_restore_context_t *dt_restore_ref(dt_restore_context_t *ctx); */ void dt_restore_unref(dt_restore_context_t *ctx); +/** + * @brief Set the working color profile for the context. + * + * The AI model was trained on sRGB primaries. If the input pixels are + * in a different working profile (e.g. Rec.2020), we must convert to + * sRGB before inference and back after to avoid hue shifts. Call this + * before running inference on each image that may use a different + * working profile. + * + * If profile is NULL, the pipeline falls back to gamma-only conversion + * (treating working-profile numbers as if they were sRGB), which can + * cause color shifts for wide-gamut working profiles. + * + * Thread-safety: must not be called concurrently with + * dt_restore_run_patch() or dt_restore_process_tiled(). Set the + * profile before dispatching inference on a given image. + * + * @param ctx context handle (NULL-safe) + * @param profile lcms2 cmsHPROFILE handle cast to void*; NULL to disable + */ +void dt_restore_set_profile(dt_restore_context_t *ctx, + void *profile); + /** * @brief check if a denoise model is available * @param env environment handle diff --git a/src/libs/neural_restore.c b/src/libs/neural_restore.c index f68912cad7cd..b213d2efaae8 100644 --- a/src/libs/neural_restore.c +++ b/src/libs/neural_restore.c @@ -130,6 +130,8 @@ DT_MODULE(1) #define CONF_BIT_DEPTH "plugins/lighttable/neural_restore/bit_depth" #define CONF_ADD_CATALOG "plugins/lighttable/neural_restore/add_to_catalog" #define CONF_OUTPUT_DIR "plugins/lighttable/neural_restore/output_directory" +#define CONF_ICC_TYPE "plugins/lighttable/neural_restore/icc_type" +#define CONF_ICC_FILE "plugins/lighttable/neural_restore/icc_filename" #define CONF_EXPAND_OUTPUT "plugins/lighttable/neural_restore/expand_output" #define CONF_PREVIEW_HEIGHT "plugins/lighttable/neural_restore/preview_height" @@ -156,7 +158,7 @@ typedef struct dt_lib_neural_restore_t GtkWidget *preview_area; GtkWidget *process_button; char info_text_left[64]; - char info_text_right[64]; + char info_text_right[128]; char warning_text[128]; GtkWidget *recovery_slider; dt_neural_task_t task; @@ -197,6 +199,7 @@ typedef struct dt_lib_neural_restore_t // output settings (collapsible) dt_gui_collapsible_section_t cs_output; GtkWidget *bpp_combo; + GtkWidget *profile_combo; GtkWidget *catalog_toggle; GtkWidget *output_dir_entry; GtkWidget *output_dir_button; @@ -215,6 +218,9 @@ typedef struct dt_neural_job_t dt_neural_bpp_t bpp; gboolean add_to_catalog; char *output_dir; // NULL = same as source + // output color profile. DT_COLORSPACE_NONE means "use image's working profile" + dt_colorspaces_color_profile_type_t icc_type; + char *icc_filename; // only used when icc_type == DT_COLORSPACE_FILE } dt_neural_job_t; typedef struct dt_neural_format_params_t @@ -485,6 +491,14 @@ static int _ai_write_image(dt_imageio_module_data_t *data, if(!job->ctx) return 1; + // inform the restore pipeline of the working profile so it can + // convert to sRGB primaries before inference (the model was trained + // on sRGB data) and back after. without this the model treats the + // working-profile RGB values as sRGB and shifts hues + const dt_colorspaces_color_profile_t *work_cp + = dt_colorspaces_get_work_profile(imgid); + dt_restore_set_profile(job->ctx, work_cp ? work_cp->profile : NULL); + const int width = params->parent.width; const int height = params->parent.height; const int S = job->scale; @@ -709,6 +723,7 @@ static void _job_cleanup(void *param) dt_pthread_mutex_unlock(&d->ctx_lock); dt_restore_unref(job->ctx); g_free(job->output_dir); + g_free(job->icc_filename); g_list_free(job->images); g_free(job); } @@ -878,8 +893,10 @@ static int32_t _process_job_run(dt_job_t *job) NULL, // filter FALSE, // copy_metadata FALSE, // export_masks - dt_colorspaces_get_work_profile(imgid)->type, - NULL, + (j->icc_type == DT_COLORSPACE_NONE) + ? dt_colorspaces_get_work_profile(imgid)->type + : j->icc_type, + j->icc_filename, DT_INTENT_PERCEPTUAL, NULL, NULL, count, total, NULL, -1); @@ -943,6 +960,81 @@ static void _update_button_sensitivity(dt_lib_neural_restore_t *d) d->export_pixels != NULL); } +// TRUE if the given profile type has primaries wider than sRGB; +// exhaustive switch with no default: when dt_colorspaces_color_profile_type_t +// gains a new value, the compiler will warn and force this list to be +// updated; DT_COLORSPACE_NONE and DT_COLORSPACE_FILE require additional +// context and are handled by the caller +static gboolean _profile_type_is_wide_gamut(dt_colorspaces_color_profile_type_t t) +{ + switch(t) + { + // wider than sRGB + case DT_COLORSPACE_ADOBERGB: + case DT_COLORSPACE_PROPHOTO_RGB: + case DT_COLORSPACE_LIN_REC2020: + case DT_COLORSPACE_PQ_REC2020: + case DT_COLORSPACE_HLG_REC2020: + case DT_COLORSPACE_PQ_P3: + case DT_COLORSPACE_HLG_P3: + case DT_COLORSPACE_DISPLAY_P3: + return TRUE; + + // sRGB primaries (gamma may differ but gamut is the same) + case DT_COLORSPACE_SRGB: + case DT_COLORSPACE_REC709: + case DT_COLORSPACE_LIN_REC709: + return FALSE; + + // non-RGB / internal / pseudo profiles — treat as not wide-gamut + case DT_COLORSPACE_NONE: + case DT_COLORSPACE_FILE: + case DT_COLORSPACE_XYZ: + case DT_COLORSPACE_LAB: + case DT_COLORSPACE_INFRARED: + case DT_COLORSPACE_DISPLAY: + case DT_COLORSPACE_DISPLAY2: + case DT_COLORSPACE_EMBEDDED_ICC: + case DT_COLORSPACE_EMBEDDED_MATRIX: + case DT_COLORSPACE_STANDARD_MATRIX: + case DT_COLORSPACE_ENHANCED_MATRIX: + case DT_COLORSPACE_VENDOR_MATRIX: + case DT_COLORSPACE_ALTERNATE_MATRIX: + case DT_COLORSPACE_BRG: + case DT_COLORSPACE_EXPORT: + case DT_COLORSPACE_SOFTPROOF: + case DT_COLORSPACE_WORK: + case DT_COLORSPACE_LAST: + return FALSE; + } + return FALSE; +} + +// TRUE if the configured output profile is wider than sRGB (colors +// outside sRGB gamut will be clipped by the AI model which operates +// in sRGB internally); for "image settings" (NONE), resolves to the +// image's actual working profile when imgid is valid +static gboolean _output_profile_is_wide_gamut(dt_imgid_t imgid) +{ + const int icc_type = dt_conf_key_exists(CONF_ICC_TYPE) + ? dt_conf_get_int(CONF_ICC_TYPE) + : DT_COLORSPACE_NONE; + + if(icc_type == DT_COLORSPACE_NONE) + { + // fall back to image's working profile + if(!dt_is_valid_imgid(imgid)) return FALSE; + const dt_colorspaces_color_profile_t *work + = dt_colorspaces_get_work_profile(imgid); + return work ? _profile_type_is_wide_gamut(work->type) : FALSE; + } + + if(icc_type == DT_COLORSPACE_FILE) + return TRUE; // unknown custom — be conservative + + return _profile_type_is_wide_gamut(icc_type); +} + static void _update_info_label(dt_lib_neural_restore_t *d) { d->info_text_left[0] = '\0'; @@ -953,15 +1045,16 @@ static void _update_info_label(dt_lib_neural_restore_t *d) return; const int scale = _task_scale(d->task); - if(scale == 1) - return; - // show output dimensions for current image using final - // developed size (respects crop, rotation, lens correction) + // pick the first selected/active image to resolve "image settings" + // profile and to compute upscale output dimensions GList *imgs = dt_act_on_get_images(TRUE, FALSE, FALSE); - if(imgs) + const dt_imgid_t imgid = imgs ? GPOINTER_TO_INT(imgs->data) : NO_IMGID; + + // show output dimensions for upscale using final developed size + // (respects crop, rotation, lens correction) + if(scale > 1 && dt_is_valid_imgid(imgid)) { - dt_imgid_t imgid = GPOINTER_TO_INT(imgs->data); int fw = 0, fh = 0; dt_image_get_final_size(imgid, &fw, &fh); if(fw > 0 && fh > 0) @@ -970,20 +1063,39 @@ static void _update_info_label(dt_lib_neural_restore_t *d) const int out_h = fh * scale; const double in_mp = (double)fw * fh / 1e6; const double out_mp = (double)out_w * out_h / 1e6; - const size_t est_mb = (size_t)out_w * out_h * 3 * 4 / (1024 * 1024); snprintf(d->info_text_left, sizeof(d->info_text_left), "%.0fMP", in_mp); snprintf(d->info_text_right, sizeof(d->info_text_right), - "%.0fMP (~%zuMB)", out_mp, est_mb); + "%.0fMP", out_mp); if(out_mp >= LARGE_OUTPUT_MP) snprintf(d->warning_text, sizeof(d->warning_text), "%s", _("large output - processing will be slow")); } - g_list_free(imgs); } + // gamut note (informational, not a warning): reuse the same info + // line as the upscale size display. for denoise, shows standalone + // in info_text_left; for upscale, appended to the size info + if(_output_profile_is_wide_gamut(imgid)) + { + const char *msg = (scale == 1) + ? _("wide-gamut preserved, not denoised") + : _("wide-gamut clipped"); + if(d->info_text_right[0]) + { + const size_t used = strlen(d->info_text_right); + snprintf(d->info_text_right + used, sizeof(d->info_text_right) - used, + " · %s", msg); + } + else + { + snprintf(d->info_text_left, sizeof(d->info_text_left), "%s", msg); + } + } + + g_list_free(imgs); gtk_widget_queue_draw(d->preview_area); } @@ -1145,6 +1257,13 @@ static gpointer _preview_thread(gpointer data) .bpp = _ai_check_bpp, .write_image = _preview_capture_write_image}; + const dt_colorspaces_color_profile_type_t cfg_type + = dt_conf_key_exists(CONF_ICC_TYPE) + ? dt_conf_get_int(CONF_ICC_TYPE) + : DT_COLORSPACE_NONE; + gchar *cfg_file = (cfg_type == DT_COLORSPACE_FILE) + ? dt_conf_get_string(CONF_ICC_FILE) + : NULL; dt_imageio_export_with_flags(pd->imgid, "unused", &fmt, @@ -1159,10 +1278,13 @@ static gpointer _preview_thread(gpointer data) NULL, // filter FALSE, // copy_metadata FALSE, // export_masks - dt_colorspaces_get_work_profile(pd->imgid)->type, - NULL, + (cfg_type == DT_COLORSPACE_NONE) + ? dt_colorspaces_get_work_profile(pd->imgid)->type + : cfg_type, + cfg_file, DT_INTENT_PERCEPTUAL, NULL, NULL, 1, 1, NULL, -1); + g_free(cfg_file); pixels = cap.pixels; pixels_w = cap.cap_w; @@ -1295,6 +1417,11 @@ static gpointer _preview_thread(gpointer data) "[neural_restore] preview: tiled inference %dx%d", crop_w, crop_h); + // set working profile on context so the model sees sRGB primaries + const dt_colorspaces_color_profile_t *work_cp + = dt_colorspaces_get_work_profile(pd->imgid); + dt_restore_set_profile(ctx, work_cp ? work_cp->profile : NULL); + _buf_writer_data_t bwd = { .out_buf = out_4ch, .out_w = pw }; const int ret = dt_restore_process_tiled( ctx, crop_4ch, crop_w, crop_h, pd->scale, @@ -1528,6 +1655,12 @@ static void _process_clicked(GtkWidget *widget, gpointer user_data) job_data->output_dir = (out_dir && out_dir[0]) ? out_dir : NULL; if(!job_data->output_dir) g_free(out_dir); + job_data->icc_type = dt_conf_key_exists(CONF_ICC_TYPE) + ? dt_conf_get_int(CONF_ICC_TYPE) + : DT_COLORSPACE_NONE; + job_data->icc_filename = (job_data->icc_type == DT_COLORSPACE_FILE) + ? dt_conf_get_string(CONF_ICC_FILE) + : NULL; job_data->self = self; // mark selected images as processing @@ -1809,11 +1942,15 @@ static gboolean _preview_draw(GtkWidget *widget, cairo_t *cr, dt_lib_module_t *s { cairo_text_extents_t ext_l, ext_r; cairo_text_extents(cr, d->info_text_left, &ext_l); - cairo_text_extents(cr, d->info_text_right, &ext_r); + const gboolean with_right = (d->info_text_right[0] != '\0'); + if(with_right) + cairo_text_extents(cr, d->info_text_right, &ext_r); const double pad = 4.0; const double arrow_w = ext_l.height * 1.2; const double gap = 6.0; - const double total_w = ext_l.width + gap + arrow_w + gap + ext_r.width; + const double total_w = with_right + ? ext_l.width + gap + arrow_w + gap + ext_r.width + : ext_l.width; const double bh = ext_l.height + pad * 2; const double by = h - bh; cairo_set_source_rgba(cr, 0.0, 0.0, 0.0, 0.3); @@ -1826,20 +1963,23 @@ static gboolean _preview_draw(GtkWidget *widget, cairo_t *cr, dt_lib_module_t *s cairo_move_to(cr, tx, ty); cairo_show_text(cr, d->info_text_left); - // draw arrow - const double ah = ext_l.height * 0.5; - const double ax = tx + ext_l.width + gap; - const double ay = ty - ext_l.height * 0.5; - cairo_set_line_width(cr, 1.5); - cairo_move_to(cr, ax, ay); - cairo_line_to(cr, ax + arrow_w, ay); - cairo_line_to(cr, ax + arrow_w - ah * 0.5, ay - ah * 0.5); - cairo_move_to(cr, ax + arrow_w, ay); - cairo_line_to(cr, ax + arrow_w - ah * 0.5, ay + ah * 0.5); - cairo_stroke(cr); - - cairo_move_to(cr, ax + arrow_w + gap, ty); - cairo_show_text(cr, d->info_text_right); + if(with_right) + { + // draw arrow between the size texts + const double ah = ext_l.height * 0.5; + const double ax = tx + ext_l.width + gap; + const double ay = ty - ext_l.height * 0.5; + cairo_set_line_width(cr, 1.5); + cairo_move_to(cr, ax, ay); + cairo_line_to(cr, ax + arrow_w, ay); + cairo_line_to(cr, ax + arrow_w - ah * 0.5, ay - ah * 0.5); + cairo_move_to(cr, ax + arrow_w, ay); + cairo_line_to(cr, ax + arrow_w - ah * 0.5, ay + ah * 0.5); + cairo_stroke(cr); + + cairo_move_to(cr, ax + arrow_w + gap, ty); + cairo_show_text(cr, d->info_text_right); + } } return FALSE; @@ -2051,6 +2191,37 @@ static void _bpp_combo_changed(GtkWidget *w, dt_conf_set_int(CONF_BIT_DEPTH, idx); } +// mirror of export.c: combo index 0 = "image settings", 1..N = profiles +// with out_pos >= 0 ordered by out_pos +static void _profile_combo_changed(GtkWidget *w, + dt_lib_module_t *self) +{ + const int pos = dt_bauhaus_combobox_get(w); + gboolean done = FALSE; + if(pos > 0) + { + const int out_pos = pos - 1; + for(GList *l = darktable.color_profiles->profiles; l; l = g_list_next(l)) + { + const dt_colorspaces_color_profile_t *pp = l->data; + if(pp->out_pos == out_pos) + { + dt_conf_set_int(CONF_ICC_TYPE, pp->type); + dt_conf_set_string(CONF_ICC_FILE, + (pp->type == DT_COLORSPACE_FILE) ? pp->filename : ""); + done = TRUE; + break; + } + } + } + if(!done) + { + dt_conf_set_int(CONF_ICC_TYPE, DT_COLORSPACE_NONE); + dt_conf_set_string(CONF_ICC_FILE, ""); + } + _update_info_label((dt_lib_neural_restore_t *)self->data); +} + static void _catalog_toggle_changed(GtkWidget *w, dt_lib_module_t *self) { @@ -2211,6 +2382,48 @@ void gui_init(dt_lib_module_t *self) N_("8 bit"), N_("16 bit"), N_("32 bit (float)")); dt_gui_box_add(cs_box, d->bpp_combo); + // output color profile: 0 = image settings (working profile), then the + // same list the standard export dialog uses; out-of-gamut colors are + // still clamped by the model, so this only controls the wrapper + // embedded in the output TIFF + d->profile_combo = dt_bauhaus_combobox_new_action(DT_ACTION(self)); + dt_bauhaus_widget_set_label(d->profile_combo, NULL, N_("profile")); + dt_bauhaus_combobox_add(d->profile_combo, _("image settings")); + for(GList *l = darktable.color_profiles->profiles; l; l = g_list_next(l)) + { + const dt_colorspaces_color_profile_t *pp = l->data; + if(pp->out_pos > -1) + dt_bauhaus_combobox_add(d->profile_combo, pp->name); + } + // restore saved selection + int saved_pos = 0; + if(dt_conf_key_exists(CONF_ICC_TYPE)) + { + const int saved_type = dt_conf_get_int(CONF_ICC_TYPE); + if(saved_type != DT_COLORSPACE_NONE) + { + gchar *saved_file = dt_conf_get_string(CONF_ICC_FILE); + for(GList *l = darktable.color_profiles->profiles; l; l = g_list_next(l)) + { + const dt_colorspaces_color_profile_t *pp = l->data; + if(pp->out_pos > -1 && pp->type == saved_type + && (pp->type != DT_COLORSPACE_FILE + || g_strcmp0(pp->filename, saved_file) == 0)) + { + saved_pos = pp->out_pos + 1; + break; + } + } + g_free(saved_file); + } + } + dt_bauhaus_combobox_set(d->profile_combo, saved_pos); + gtk_widget_set_tooltip_text(d->profile_combo, + _("color profile embedded in the output TIFF")); + g_signal_connect(d->profile_combo, "value-changed", + G_CALLBACK(_profile_combo_changed), self); + dt_gui_box_add(cs_box, d->profile_combo); + // add to catalog GtkWidget *catalog_box = dt_gui_hbox(); d->catalog_toggle = gtk_check_button_new_with_label(_("add to catalog")); From 3823094f0e4541bc5231f79c24cbe763821622b6 Mon Sep 17 00:00:00 2001 From: Andrii Ryzhkov Date: Tue, 14 Apr 2026 19:08:08 +0200 Subject: [PATCH 2/6] Move wide-gamut profile helpers to common/colorspaces and common/image --- src/common/colorspaces.c | 49 +++++++++++++++++++++++ src/common/colorspaces.h | 3 ++ src/common/image.c | 19 +++++++++ src/common/image.h | 10 +++++ src/libs/neural_restore.c | 81 +++------------------------------------ 5 files changed, 86 insertions(+), 76 deletions(-) diff --git a/src/common/colorspaces.c b/src/common/colorspaces.c index e4fd464b61af..f442031cf3ac 100644 --- a/src/common/colorspaces.c +++ b/src/common/colorspaces.c @@ -2561,6 +2561,55 @@ void dt_make_transposed_matrices_from_primaries_and_whitepoint(const float prima for(size_t j = 0; j < 3; j++) RGB_to_XYZ_transposed[i][j] = scale[i] * primaries_matrix[i][j]; } +// exhaustive switch with no default: when dt_colorspaces_color_profile_type_t +// gains a new value, the compiler warns and forces this list to be updated; +// DT_COLORSPACE_NONE and DT_COLORSPACE_FILE require additional context and are +// handled by callers +gboolean dt_colorspaces_profile_is_wide_gamut(const dt_colorspaces_color_profile_type_t type) +{ + switch(type) + { + // wider than sRGB + case DT_COLORSPACE_ADOBERGB: + case DT_COLORSPACE_PROPHOTO_RGB: + case DT_COLORSPACE_LIN_REC2020: + case DT_COLORSPACE_PQ_REC2020: + case DT_COLORSPACE_HLG_REC2020: + case DT_COLORSPACE_PQ_P3: + case DT_COLORSPACE_HLG_P3: + case DT_COLORSPACE_DISPLAY_P3: + return TRUE; + + // sRGB primaries (gamma may differ but gamut is the same) + case DT_COLORSPACE_SRGB: + case DT_COLORSPACE_REC709: + case DT_COLORSPACE_LIN_REC709: + return FALSE; + + // non-RGB / internal / pseudo profiles — not wide-gamut + case DT_COLORSPACE_NONE: + case DT_COLORSPACE_FILE: + case DT_COLORSPACE_XYZ: + case DT_COLORSPACE_LAB: + case DT_COLORSPACE_INFRARED: + case DT_COLORSPACE_DISPLAY: + case DT_COLORSPACE_DISPLAY2: + case DT_COLORSPACE_EMBEDDED_ICC: + case DT_COLORSPACE_EMBEDDED_MATRIX: + case DT_COLORSPACE_STANDARD_MATRIX: + case DT_COLORSPACE_ENHANCED_MATRIX: + case DT_COLORSPACE_VENDOR_MATRIX: + case DT_COLORSPACE_ALTERNATE_MATRIX: + case DT_COLORSPACE_BRG: + case DT_COLORSPACE_EXPORT: + case DT_COLORSPACE_SOFTPROOF: + case DT_COLORSPACE_WORK: + case DT_COLORSPACE_LAST: + return FALSE; + } + return FALSE; +} + // clang-format off // modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py // vim: shiftwidth=2 expandtab tabstop=2 cindent diff --git a/src/common/colorspaces.h b/src/common/colorspaces.h index f4c716665b0e..9033b733b67f 100644 --- a/src/common/colorspaces.h +++ b/src/common/colorspaces.h @@ -422,6 +422,9 @@ void dt_make_transposed_matrices_from_primaries_and_whitepoint(const float prima const float whitepoint[2], dt_colormatrix_t RGB_to_XYZ_transposed); +/** TRUE if the profile type has primaries wider than sRGB */ +gboolean dt_colorspaces_profile_is_wide_gamut(const dt_colorspaces_color_profile_type_t type); + G_END_DECLS // clang-format off diff --git a/src/common/image.c b/src/common/image.c index c36b92b93939..05605921dc2f 100644 --- a/src/common/image.c +++ b/src/common/image.c @@ -18,6 +18,7 @@ #include "common/image.h" #include "common/collection.h" +#include "common/colorspaces.h" #include "common/darktable.h" #include "common/debug.h" #include "common/exif.h" @@ -437,6 +438,24 @@ void dt_image_full_path(const dt_imgid_t imgid, } } +gboolean dt_image_has_wide_gamut_output_profile( + const dt_imgid_t imgid, + const dt_colorspaces_color_profile_type_t icc_type) +{ + // "image settings": fall back to the image's working profile + if(icc_type == DT_COLORSPACE_NONE) + { + if(!dt_is_valid_imgid(imgid)) return FALSE; + const dt_colorspaces_color_profile_t *work + = dt_colorspaces_get_work_profile(imgid); + return work && dt_colorspaces_profile_is_wide_gamut(work->type); + } + // custom ICC file: gamut unknown, be conservative + if(icc_type == DT_COLORSPACE_FILE) + return TRUE; + return dt_colorspaces_profile_is_wide_gamut(icc_type); +} + gboolean dt_image_exists(const dt_imgid_t imgid) { sqlite3_stmt *stmt; diff --git a/src/common/image.h b/src/common/image.h index 97631e9e6070..5909db68b038 100644 --- a/src/common/image.h +++ b/src/common/image.h @@ -401,6 +401,16 @@ gboolean dt_image_use_monochrome_workflow(const dt_image_t *img); char *dt_image_get_filename(const dt_imgid_t imgid); /** returns true if the image exists on the database */ gboolean dt_image_exists(const dt_imgid_t imgid); +/** TRUE if the given output profile type has primaries wider than sRGB for + * this image. Handles the three UI cases used by export-style controls: + * DT_COLORSPACE_NONE — "image settings", falls back to the image's + * working profile (wide-gamut if that is). + * DT_COLORSPACE_FILE — custom ICC file, unknown → conservative TRUE. + * any other type — direct classification via + * dt_colorspaces_profile_is_wide_gamut(). + */ +gboolean dt_image_has_wide_gamut_output_profile(const dt_imgid_t imgid, + const dt_colorspaces_color_profile_type_t icc_type); /** returns the full path name where the image was imported * from. from_cache=TRUE check and return local cached filename if * any. */ diff --git a/src/libs/neural_restore.c b/src/libs/neural_restore.c index b213d2efaae8..374c44d58a6d 100644 --- a/src/libs/neural_restore.c +++ b/src/libs/neural_restore.c @@ -960,81 +960,6 @@ static void _update_button_sensitivity(dt_lib_neural_restore_t *d) d->export_pixels != NULL); } -// TRUE if the given profile type has primaries wider than sRGB; -// exhaustive switch with no default: when dt_colorspaces_color_profile_type_t -// gains a new value, the compiler will warn and force this list to be -// updated; DT_COLORSPACE_NONE and DT_COLORSPACE_FILE require additional -// context and are handled by the caller -static gboolean _profile_type_is_wide_gamut(dt_colorspaces_color_profile_type_t t) -{ - switch(t) - { - // wider than sRGB - case DT_COLORSPACE_ADOBERGB: - case DT_COLORSPACE_PROPHOTO_RGB: - case DT_COLORSPACE_LIN_REC2020: - case DT_COLORSPACE_PQ_REC2020: - case DT_COLORSPACE_HLG_REC2020: - case DT_COLORSPACE_PQ_P3: - case DT_COLORSPACE_HLG_P3: - case DT_COLORSPACE_DISPLAY_P3: - return TRUE; - - // sRGB primaries (gamma may differ but gamut is the same) - case DT_COLORSPACE_SRGB: - case DT_COLORSPACE_REC709: - case DT_COLORSPACE_LIN_REC709: - return FALSE; - - // non-RGB / internal / pseudo profiles — treat as not wide-gamut - case DT_COLORSPACE_NONE: - case DT_COLORSPACE_FILE: - case DT_COLORSPACE_XYZ: - case DT_COLORSPACE_LAB: - case DT_COLORSPACE_INFRARED: - case DT_COLORSPACE_DISPLAY: - case DT_COLORSPACE_DISPLAY2: - case DT_COLORSPACE_EMBEDDED_ICC: - case DT_COLORSPACE_EMBEDDED_MATRIX: - case DT_COLORSPACE_STANDARD_MATRIX: - case DT_COLORSPACE_ENHANCED_MATRIX: - case DT_COLORSPACE_VENDOR_MATRIX: - case DT_COLORSPACE_ALTERNATE_MATRIX: - case DT_COLORSPACE_BRG: - case DT_COLORSPACE_EXPORT: - case DT_COLORSPACE_SOFTPROOF: - case DT_COLORSPACE_WORK: - case DT_COLORSPACE_LAST: - return FALSE; - } - return FALSE; -} - -// TRUE if the configured output profile is wider than sRGB (colors -// outside sRGB gamut will be clipped by the AI model which operates -// in sRGB internally); for "image settings" (NONE), resolves to the -// image's actual working profile when imgid is valid -static gboolean _output_profile_is_wide_gamut(dt_imgid_t imgid) -{ - const int icc_type = dt_conf_key_exists(CONF_ICC_TYPE) - ? dt_conf_get_int(CONF_ICC_TYPE) - : DT_COLORSPACE_NONE; - - if(icc_type == DT_COLORSPACE_NONE) - { - // fall back to image's working profile - if(!dt_is_valid_imgid(imgid)) return FALSE; - const dt_colorspaces_color_profile_t *work - = dt_colorspaces_get_work_profile(imgid); - return work ? _profile_type_is_wide_gamut(work->type) : FALSE; - } - - if(icc_type == DT_COLORSPACE_FILE) - return TRUE; // unknown custom — be conservative - - return _profile_type_is_wide_gamut(icc_type); -} - static void _update_info_label(dt_lib_neural_restore_t *d) { d->info_text_left[0] = '\0'; @@ -1078,7 +1003,11 @@ static void _update_info_label(dt_lib_neural_restore_t *d) // gamut note (informational, not a warning): reuse the same info // line as the upscale size display. for denoise, shows standalone // in info_text_left; for upscale, appended to the size info - if(_output_profile_is_wide_gamut(imgid)) + const dt_colorspaces_color_profile_type_t icc_type + = dt_conf_key_exists(CONF_ICC_TYPE) + ? dt_conf_get_int(CONF_ICC_TYPE) + : DT_COLORSPACE_NONE; + if(dt_image_has_wide_gamut_output_profile(imgid, icc_type)) { const char *msg = (scale == 1) ? _("wide-gamut preserved, not denoised") From 4b6503ea47726f48c00ba58d05775d183ba6ab46 Mon Sep 17 00:00:00 2001 From: Andrii Ryzhkov Date: Wed, 15 Apr 2026 10:49:35 +0200 Subject: [PATCH 3/6] Add preserve wide-gamut checkbox to neural_restore output parameters --- src/common/ai/restore.c | 42 ++++++++++++++++++++++++++++++++------ src/common/ai/restore.h | 18 ++++++++++++++++ src/libs/neural_restore.c | 43 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/common/ai/restore.c b/src/common/ai/restore.c index 26d9b3ea62e4..d7aeaf54d818 100644 --- a/src/common/ai/restore.c +++ b/src/common/ai/restore.c @@ -71,6 +71,10 @@ struct dt_restore_context_t gboolean has_profile; float wp_to_srgb[9]; // working profile RGB -> sRGB linear (row-major) float srgb_to_wp[9]; // sRGB linear -> working profile RGB (row-major) + // when TRUE (default), out-of-sRGB-gamut pixels pass through unchanged + // during denoise. when FALSE, every pixel uses the model output and + // wide-gamut colors get clipped to sRGB but everything is denoised + gboolean preserve_wide_gamut; gint ref_count; }; @@ -239,6 +243,7 @@ static dt_restore_context_t *_load(dt_restore_env_t *env, ctx->tile_size = tile_size; ctx->dim_h = g_strdup(dim_h); ctx->dim_w = g_strdup(dim_w); + ctx->preserve_wide_gamut = TRUE; return ctx; } @@ -350,6 +355,11 @@ void dt_restore_set_profile(dt_restore_context_t *ctx, void *profile) dt_print(DT_DEBUG_AI, "[restore] working profile color matrices ready"); } +void dt_restore_set_preserve_wide_gamut(dt_restore_context_t *ctx, gboolean preserve) +{ + if(ctx) ctx->preserve_wide_gamut = preserve; +} + static gboolean _model_available(dt_restore_env_t *env, const char *task) { @@ -481,7 +491,10 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, float *srgb_in = g_try_malloc(in_pixels * sizeof(float)); uint8_t *in_gamut_mask = NULL; if(!srgb_in) return 1; - if(ctx->has_profile && scale == 1) + // only allocate the gamut mask when denoise pass-through is requested + const gboolean need_gamut_mask + = ctx->has_profile && scale == 1 && ctx->preserve_wide_gamut; + if(need_gamut_mask) { in_gamut_mask = g_try_malloc(plane); if(!in_gamut_mask) @@ -584,7 +597,7 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, // // without profile: fall back to per-channel pass-through in the // original (working-profile-as-sRGB) space - if(ctx->has_profile && scale == 1) + if(ctx->has_profile && scale == 1 && ctx->preserve_wide_gamut) { const size_t out_plane = (size_t)out_w * out_h; const float *Mi = ctx->srgb_to_wp; @@ -607,18 +620,35 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, } } } + else if(ctx->has_profile && scale == 1) + { + // denoise with profile but NO pass-through: apply the inverse + // matrix to every pixel. wide-gamut inputs will have been clipped + // by the model, but we get denoising everywhere + const size_t out_plane = (size_t)out_w * out_h; + const float *Mi = ctx->srgb_to_wp; + for(size_t p = 0; p < out_plane; p++) + { + const float sr = _srgb_to_linear(out_patch[p]); + const float sg = _srgb_to_linear(out_patch[p + out_plane]); + const float sb = _srgb_to_linear(out_patch[p + 2 * out_plane]); + out_patch[p] = Mi[0] * sr + Mi[1] * sg + Mi[2] * sb; + out_patch[p + out_plane] = Mi[3] * sr + Mi[4] * sg + Mi[5] * sb; + out_patch[p + 2 * out_plane] = Mi[6] * sr + Mi[7] * sg + Mi[8] * sb; + } + } else if(scale == 1) { // no profile set: per-channel pass-through, treats working-profile // numbers as if they were sRGB. colors will be slightly shifted // for wide-gamut working profiles — rely on the profile path above - // when possible + // when possible. pass-through still honored via preserve_wide_gamut for(size_t i = 0; i < out_pixels; i++) { const float in = in_patch[i]; - out_patch[i] = (in >= 0.0f && in <= 1.0f) - ? _srgb_to_linear(out_patch[i]) - : in; + out_patch[i] = (ctx->preserve_wide_gamut && (in < 0.0f || in > 1.0f)) + ? in + : _srgb_to_linear(out_patch[i]); } } else diff --git a/src/common/ai/restore.h b/src/common/ai/restore.h index 077621bfc400..7d1587846ac4 100644 --- a/src/common/ai/restore.h +++ b/src/common/ai/restore.h @@ -136,6 +136,24 @@ void dt_restore_unref(dt_restore_context_t *ctx); void dt_restore_set_profile(dt_restore_context_t *ctx, void *profile); +/** + * @brief Enable/disable wide-gamut pass-through for denoise. + * + * When TRUE (default): pixels that would be out of sRGB gamut pass + * through unchanged, preserving color but not denoising them. When + * FALSE: all pixels use the model output, wide-gamut colors are + * clipped to sRGB but everything gets denoised. + * + * Affects denoise only (scale == 1). Upscale always uses the model + * output because there is no pixel-to-pixel correspondence to + * pass through. + * + * @param ctx context handle (NULL-safe) + * @param preserve TRUE to enable pass-through, FALSE to denoise everything + */ +void dt_restore_set_preserve_wide_gamut(dt_restore_context_t *ctx, + gboolean preserve); + /** * @brief check if a denoise model is available * @param env environment handle diff --git a/src/libs/neural_restore.c b/src/libs/neural_restore.c index 374c44d58a6d..1a7c9cfba97e 100644 --- a/src/libs/neural_restore.c +++ b/src/libs/neural_restore.c @@ -132,6 +132,7 @@ DT_MODULE(1) #define CONF_OUTPUT_DIR "plugins/lighttable/neural_restore/output_directory" #define CONF_ICC_TYPE "plugins/lighttable/neural_restore/icc_type" #define CONF_ICC_FILE "plugins/lighttable/neural_restore/icc_filename" +#define CONF_PRESERVE_WIDE_GAMUT "plugins/lighttable/neural_restore/preserve_wide_gamut" #define CONF_EXPAND_OUTPUT "plugins/lighttable/neural_restore/expand_output" #define CONF_PREVIEW_HEIGHT "plugins/lighttable/neural_restore/preview_height" @@ -200,6 +201,7 @@ typedef struct dt_lib_neural_restore_t dt_gui_collapsible_section_t cs_output; GtkWidget *bpp_combo; GtkWidget *profile_combo; + GtkWidget *preserve_wide_gamut_toggle; GtkWidget *catalog_toggle; GtkWidget *output_dir_entry; GtkWidget *output_dir_button; @@ -221,6 +223,8 @@ typedef struct dt_neural_job_t // output color profile. DT_COLORSPACE_NONE means "use image's working profile" dt_colorspaces_color_profile_type_t icc_type; char *icc_filename; // only used when icc_type == DT_COLORSPACE_FILE + // when TRUE, wide-gamut pixels pass through unchanged on denoise + gboolean preserve_wide_gamut; } dt_neural_job_t; typedef struct dt_neural_format_params_t @@ -498,6 +502,7 @@ static int _ai_write_image(dt_imageio_module_data_t *data, const dt_colorspaces_color_profile_t *work_cp = dt_colorspaces_get_work_profile(imgid); dt_restore_set_profile(job->ctx, work_cp ? work_cp->profile : NULL); + dt_restore_set_preserve_wide_gamut(job->ctx, job->preserve_wide_gamut); const int width = params->parent.width; const int height = params->parent.height; @@ -1009,7 +1014,9 @@ static void _update_info_label(dt_lib_neural_restore_t *d) : DT_COLORSPACE_NONE; if(dt_image_has_wide_gamut_output_profile(imgid, icc_type)) { - const char *msg = (scale == 1) + const gboolean preserve = dt_conf_key_exists(CONF_PRESERVE_WIDE_GAMUT) + ? dt_conf_get_bool(CONF_PRESERVE_WIDE_GAMUT) : TRUE; + const char *msg = (scale == 1 && preserve) ? _("wide-gamut preserved, not denoised") : _("wide-gamut clipped"); if(d->info_text_right[0]) @@ -1350,6 +1357,9 @@ static gpointer _preview_thread(gpointer data) const dt_colorspaces_color_profile_t *work_cp = dt_colorspaces_get_work_profile(pd->imgid); dt_restore_set_profile(ctx, work_cp ? work_cp->profile : NULL); + const gboolean pres = dt_conf_key_exists(CONF_PRESERVE_WIDE_GAMUT) + ? dt_conf_get_bool(CONF_PRESERVE_WIDE_GAMUT) : TRUE; + dt_restore_set_preserve_wide_gamut(ctx, pres); _buf_writer_data_t bwd = { .out_buf = out_4ch, .out_w = pw }; const int ret = dt_restore_process_tiled( @@ -1590,6 +1600,9 @@ static void _process_clicked(GtkWidget *widget, gpointer user_data) job_data->icc_filename = (job_data->icc_type == DT_COLORSPACE_FILE) ? dt_conf_get_string(CONF_ICC_FILE) : NULL; + job_data->preserve_wide_gamut = dt_conf_key_exists(CONF_PRESERVE_WIDE_GAMUT) + ? dt_conf_get_bool(CONF_PRESERVE_WIDE_GAMUT) + : TRUE; job_data->self = self; // mark selected images as processing @@ -2159,6 +2172,15 @@ static void _catalog_toggle_changed(GtkWidget *w, dt_conf_set_bool(CONF_ADD_CATALOG, active); } +static void _preserve_wide_gamut_toggled(GtkWidget *w, + dt_lib_module_t *self) +{ + const gboolean active + = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(w)); + dt_conf_set_bool(CONF_PRESERVE_WIDE_GAMUT, active); + _update_info_label((dt_lib_neural_restore_t *)self->data); +} + static void _output_dir_changed(GtkEditable *editable, dt_lib_module_t *self) { @@ -2353,6 +2375,25 @@ void gui_init(dt_lib_module_t *self) G_CALLBACK(_profile_combo_changed), self); dt_gui_box_add(cs_box, d->profile_combo); + // preserve wide-gamut toggle: when on, out-of-sRGB pixels pass + // through the model unchanged (preserved but not denoised). when + // off, every pixel is denoised but wide-gamut colors may be clipped. + // only affects denoise; upscale always uses the model output. + d->preserve_wide_gamut_toggle + = gtk_check_button_new_with_label(_("preserve wide-gamut colors")); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(d->preserve_wide_gamut_toggle), + dt_conf_key_exists(CONF_PRESERVE_WIDE_GAMUT) + ? dt_conf_get_bool(CONF_PRESERVE_WIDE_GAMUT) + : TRUE); + gtk_widget_set_tooltip_text(d->preserve_wide_gamut_toggle, + _("when on, pixels outside sRGB gamut pass through without being" + " denoised; when off, all pixels are denoised but wide-gamut" + " colors may be clipped; only affects denoise")); + g_signal_connect(d->preserve_wide_gamut_toggle, "toggled", + G_CALLBACK(_preserve_wide_gamut_toggled), self); + dt_gui_box_add(cs_box, d->preserve_wide_gamut_toggle); + // add to catalog GtkWidget *catalog_box = dt_gui_hbox(); d->catalog_toggle = gtk_check_button_new_with_label(_("add to catalog")); From ce53f271c888f9c016c3503f9014a52a9a05a7dd Mon Sep 17 00:00:00 2001 From: Andrii Ryzhkov Date: Wed, 15 Apr 2026 12:05:28 +0200 Subject: [PATCH 4/6] Add per-model attributes facility and shadow boost for denoise --- data/ai_models.json | 8 +++ src/ai/backend.h | 32 +++++++++ src/ai/backend_common.c | 107 +++++++++++++++++++++++++++++ src/common/ai/restore.c | 148 ++++++++++++++++++++++++++++++++-------- 4 files changed, 267 insertions(+), 28 deletions(-) diff --git a/data/ai_models.json b/data/ai_models.json index 63e2cd6d1b95..12d46c1cb66d 100644 --- a/data/ai_models.json +++ b/data/ai_models.json @@ -27,6 +27,14 @@ "github_asset": "denoise-nind.dtmodel", "default": true }, + { + "id": "denoise-nafnet", + "name": "denoise nafnet small", + "description": "NAFNet denoiser trained on SIDD dataset", + "task": "denoise", + "github_asset": "denoise-nafnet.dtmodel", + "default": false + }, { "id": "upscale-bsrgan", "name": "upscale bsrgan", diff --git a/src/ai/backend.h b/src/ai/backend.h index b83cbb3d2297..776bf8a4c523 100644 --- a/src/ai/backend.h +++ b/src/ai/backend.h @@ -132,8 +132,40 @@ typedef struct dt_ai_model_info_t { const char *arch; ///< e.g. "sam2", "segnext" const char *backend; ///< Backend type (e.g. "onnx") int num_inputs; ///< Number of model inputs (default 1) + const char *attributes; ///< Optional attributes } dt_ai_model_info_t; +/* --- Model "attributes" lookup --- + * + * Models declare optional behavior hints under an "attributes" object + * in their config.json, e.g.: + * "attributes": { + * "shadow_boost": true, + * "tile_factor": 1.5, + * "color_space": "sRGB" + * } + * + * The accessors parse the stored JSON on demand. A missing key (or + * one of a different type) returns the supplied default — or FALSE / + * NULL for the bool / string variants. + */ + +gboolean dt_ai_model_attribute_bool(const dt_ai_model_info_t *info, + const char *key); + +int dt_ai_model_attribute_int(const dt_ai_model_info_t *info, + const char *key, + int default_value); + +double dt_ai_model_attribute_double(const dt_ai_model_info_t *info, + const char *key, + double default_value); + +/** Returned string is newly allocated and must be freed with g_free(). + * Returns NULL if the key is absent or not a string. */ +char *dt_ai_model_attribute_string(const dt_ai_model_info_t *info, + const char *key); + /* --- Discovery --- */ /** diff --git a/src/ai/backend_common.c b/src/ai/backend_common.c index 117c11873319..1e9ae773e924 100644 --- a/src/ai/backend_common.c +++ b/src/ai/backend_common.c @@ -153,6 +153,26 @@ static void _scan_directory(dt_ai_environment_t *env, const char *root_path) ? (int)json_object_get_int_member(obj, "num_inputs") : 1; + // capture optional "attributes" object as a JSON string; + // accessors (e.g. dt_ai_model_attribute_bool) parse on demand + info->attributes = NULL; + if(json_object_has_member(obj, "attributes")) + { + JsonNode *attr_node = json_object_get_member(obj, "attributes"); + if(attr_node && JSON_NODE_HOLDS_OBJECT(attr_node)) + { + JsonGenerator *gen = json_generator_new(); + json_generator_set_root(gen, attr_node); + gchar *s = json_generator_to_data(gen, NULL); + if(s) + { + _store_string(env, s, &info->attributes); + g_free(s); + } + g_object_unref(gen); + } + } + env->models = g_list_prepend(env->models, info); g_hash_table_insert( env->model_paths, @@ -422,6 +442,93 @@ dt_ai_context_t *dt_ai_load_model_ext(dt_ai_environment_t *env, return ctx; } +// model attribute lookup — parses the JSON-encoded attributes string +// on demand; callers pass info from dt_ai_get_model_info_by_id() +// +// _attribute_node returns the parsed JsonParser plus a borrowed JsonNode* +// for the named key; caller must g_object_unref the returned parser; +// returns NULL parser if the attribute set is absent or the key is missing +static JsonParser *_attribute_node(const dt_ai_model_info_t *info, + const char *key, + JsonNode **out_node) +{ + *out_node = NULL; + if(!info || !info->attributes || !key) return NULL; + JsonParser *parser = json_parser_new(); + if(!json_parser_load_from_data(parser, info->attributes, -1, NULL)) + { + g_object_unref(parser); + return NULL; + } + JsonNode *root = json_parser_get_root(parser); + if(!root || !JSON_NODE_HOLDS_OBJECT(root)) + { + g_object_unref(parser); + return NULL; + } + JsonObject *obj = json_node_get_object(root); + if(!json_object_has_member(obj, key)) + { + g_object_unref(parser); + return NULL; + } + *out_node = json_object_get_member(obj, key); + return parser; +} + +gboolean dt_ai_model_attribute_bool(const dt_ai_model_info_t *info, + const char *key) +{ + JsonNode *v = NULL; + JsonParser *p = _attribute_node(info, key, &v); + gboolean result = FALSE; + if(v && JSON_NODE_HOLDS_VALUE(v)) + result = json_node_get_boolean(v); + if(p) g_object_unref(p); + return result; +} + +int dt_ai_model_attribute_int(const dt_ai_model_info_t *info, + const char *key, + int default_value) +{ + JsonNode *v = NULL; + JsonParser *p = _attribute_node(info, key, &v); + int result = default_value; + if(v && JSON_NODE_HOLDS_VALUE(v)) + result = (int)json_node_get_int(v); + if(p) g_object_unref(p); + return result; +} + +double dt_ai_model_attribute_double(const dt_ai_model_info_t *info, + const char *key, + double default_value) +{ + JsonNode *v = NULL; + JsonParser *p = _attribute_node(info, key, &v); + double result = default_value; + if(v && JSON_NODE_HOLDS_VALUE(v)) + result = json_node_get_double(v); + if(p) g_object_unref(p); + return result; +} + +char *dt_ai_model_attribute_string(const dt_ai_model_info_t *info, + const char *key) +{ + JsonNode *v = NULL; + JsonParser *p = _attribute_node(info, key, &v); + char *result = NULL; + if(v && JSON_NODE_HOLDS_VALUE(v)) + { + const char *s = json_node_get_string(v); + if(s) result = g_strdup(s); + } + if(p) g_object_unref(p); + return result; +} + // provider string conversion const char *dt_ai_provider_to_string(dt_ai_provider_t provider) diff --git a/src/common/ai/restore.c b/src/common/ai/restore.c index d7aeaf54d818..7fa145be0e6d 100644 --- a/src/common/ai/restore.c +++ b/src/common/ai/restore.c @@ -75,6 +75,13 @@ struct dt_restore_context_t // during denoise. when FALSE, every pixel uses the model output and // wide-gamut colors get clipped to sRGB but everything is denoised gboolean preserve_wide_gamut; + // shadow_boost_capable: TRUE when the model declares the + // "shadow_boost" attribute in its config.json; set once at load + gboolean shadow_boost_capable; + // shadow_boost: the effective flag used at inference; recomputed + // per-image inside dt_restore_process_tiled() when capable, based + // on a luminance check (bright images skip the curve) + gboolean shadow_boost; gint ref_count; }; @@ -234,16 +241,29 @@ static dt_restore_context_t *_load(dt_restore_env_t *env, } dt_restore_context_t *ctx = g_new0(dt_restore_context_t, 1); - ctx->ref_count = 1; - ctx->ai_ctx = ai_ctx; - ctx->env = env; - ctx->task = g_strdup(task); - ctx->model_id = model_id; - ctx->model_file = g_strdup(model_file); - ctx->tile_size = tile_size; - ctx->dim_h = g_strdup(dim_h); - ctx->dim_w = g_strdup(dim_w); + ctx->ref_count = 1; + ctx->ai_ctx = ai_ctx; + ctx->env = env; + ctx->task = g_strdup(task); + ctx->model_id = model_id; + ctx->model_file = g_strdup(model_file); + ctx->tile_size = tile_size; + ctx->dim_h = g_strdup(dim_h); + ctx->dim_w = g_strdup(dim_w); ctx->preserve_wide_gamut = TRUE; + // shadow boost capability is declared per-model via the + // "attributes": { "shadow_boost": true } object in config.json; + // models that hallucinate in dark patches opt in this way; + // other models run as-is + const dt_ai_model_info_t *info + = dt_ai_get_model_info_by_id(env->ai_env, model_id); + ctx->shadow_boost_capable + = dt_ai_model_attribute_bool(info, "shadow_boost"); + ctx->shadow_boost = ctx->shadow_boost_capable; + if(ctx->shadow_boost_capable) + dt_print(DT_DEBUG_AI, + "[restore] model %s declares shadow_boost attribute", + model_id); return ctx; } @@ -507,21 +527,41 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, if(ctx->has_profile) { const float *M = ctx->wp_to_srgb; + const gboolean boost = ctx->shadow_boost; for(size_t p = 0; p < plane; p++) { const float r = in_patch[p]; const float g = in_patch[p + plane]; const float b = in_patch[p + 2 * plane]; - const float sr = M[0] * r + M[1] * g + M[2] * b; - const float sg = M[3] * r + M[4] * g + M[5] * b; - const float sb = M[6] * r + M[7] * g + M[8] * b; - srgb_in[p] = _linear_to_srgb(sr); - srgb_in[p + plane] = _linear_to_srgb(sg); - srgb_in[p + 2 * plane] = _linear_to_srgb(sb); + float sr = M[0] * r + M[1] * g + M[2] * b; + float sg = M[3] * r + M[4] * g + M[5] * b; + float sb = M[6] * r + M[7] * g + M[8] * b; + // gamut check uses pre-boost values so pass-through decisions + // reflect the original color if(in_gamut_mask) in_gamut_mask[p] = (sr >= 0.0f && sr <= 1.0f && sg >= 0.0f && sg <= 1.0f && sb >= 0.0f && sb <= 1.0f) ? 1 : 0; + if(boost) + { + sr = sr > 0.0f ? sqrtf(sr) : 0.0f; + sg = sg > 0.0f ? sqrtf(sg) : 0.0f; + sb = sb > 0.0f ? sqrtf(sb) : 0.0f; + } + srgb_in[p] = _linear_to_srgb(sr); + srgb_in[p + plane] = _linear_to_srgb(sg); + srgb_in[p + 2 * plane] = _linear_to_srgb(sb); + } + } + else if(ctx->shadow_boost) + { + // no profile: still boost shadows so the model stays within its + // comfort zone, even though we treat WP values as sRGB + for(size_t i = 0; i < in_pixels; i++) + { + const float v = in_patch[i]; + const float boosted = v > 0.0f ? sqrtf(v) : 0.0f; + srgb_in[i] = _linear_to_srgb(boosted); } } else @@ -597,6 +637,7 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, // // without profile: fall back to per-channel pass-through in the // original (working-profile-as-sRGB) space + const gboolean boost = ctx->shadow_boost; if(ctx->has_profile && scale == 1 && ctx->preserve_wide_gamut) { const size_t out_plane = (size_t)out_w * out_h; @@ -605,9 +646,10 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, { if(in_gamut_mask[p]) { - const float sr = _srgb_to_linear(out_patch[p]); - const float sg = _srgb_to_linear(out_patch[p + out_plane]); - const float sb = _srgb_to_linear(out_patch[p + 2 * out_plane]); + float sr = _srgb_to_linear(out_patch[p]); + float sg = _srgb_to_linear(out_patch[p + out_plane]); + float sb = _srgb_to_linear(out_patch[p + 2 * out_plane]); + if(boost) { sr *= sr; sg *= sg; sb *= sb; } out_patch[p] = Mi[0] * sr + Mi[1] * sg + Mi[2] * sb; out_patch[p + out_plane] = Mi[3] * sr + Mi[4] * sg + Mi[5] * sb; out_patch[p + 2 * out_plane] = Mi[6] * sr + Mi[7] * sg + Mi[8] * sb; @@ -629,9 +671,10 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, const float *Mi = ctx->srgb_to_wp; for(size_t p = 0; p < out_plane; p++) { - const float sr = _srgb_to_linear(out_patch[p]); - const float sg = _srgb_to_linear(out_patch[p + out_plane]); - const float sb = _srgb_to_linear(out_patch[p + 2 * out_plane]); + float sr = _srgb_to_linear(out_patch[p]); + float sg = _srgb_to_linear(out_patch[p + out_plane]); + float sb = _srgb_to_linear(out_patch[p + 2 * out_plane]); + if(boost) { sr *= sr; sg *= sg; sb *= sb; } out_patch[p] = Mi[0] * sr + Mi[1] * sg + Mi[2] * sb; out_patch[p + out_plane] = Mi[3] * sr + Mi[4] * sg + Mi[5] * sb; out_patch[p + 2 * out_plane] = Mi[6] * sr + Mi[7] * sg + Mi[8] * sb; @@ -646,9 +689,16 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, for(size_t i = 0; i < out_pixels; i++) { const float in = in_patch[i]; - out_patch[i] = (ctx->preserve_wide_gamut && (in < 0.0f || in > 1.0f)) - ? in - : _srgb_to_linear(out_patch[i]); + if(ctx->preserve_wide_gamut && (in < 0.0f || in > 1.0f)) + { + out_patch[i] = in; + } + else + { + float v = _srgb_to_linear(out_patch[i]); + if(boost) v *= v; + out_patch[i] = v; + } } } else @@ -660,9 +710,10 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, const float *Mi = ctx->srgb_to_wp; for(size_t p = 0; p < out_plane; p++) { - const float sr = _srgb_to_linear(out_patch[p]); - const float sg = _srgb_to_linear(out_patch[p + out_plane]); - const float sb = _srgb_to_linear(out_patch[p + 2 * out_plane]); + float sr = _srgb_to_linear(out_patch[p]); + float sg = _srgb_to_linear(out_patch[p + out_plane]); + float sb = _srgb_to_linear(out_patch[p + 2 * out_plane]); + if(boost) { sr *= sr; sg *= sg; sb *= sb; } out_patch[p] = Mi[0] * sr + Mi[1] * sg + Mi[2] * sb; out_patch[p + out_plane] = Mi[3] * sr + Mi[4] * sg + Mi[5] * sb; out_patch[p + 2 * out_plane] = Mi[6] * sr + Mi[7] * sg + Mi[8] * sb; @@ -671,7 +722,11 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, else { for(size_t i = 0; i < out_pixels; i++) - out_patch[i] = _srgb_to_linear(out_patch[i]); + { + float v = _srgb_to_linear(out_patch[i]); + if(boost) v *= v; + out_patch[i] = v; + } } } @@ -679,6 +734,32 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, return 0; } +// per-image gate for the shadow-boost curve; enable only when the image +// has substantial near-black area to protect — bright images would only +// pay the curve cost (minor highlight compression) for no gain; +// thresholds tuned so localized very-dark features (a tree hollow, a +// silhouette) do NOT trigger; only broad noisy shadow regions do +// +// in_data is interleaved float4 RGBA +#define _SHADOW_BOOST_THRESHOLD 0.005f // 0.5% linear luminance +#define _SHADOW_BOOST_FRACTION 0.10f // 10% of sampled pixels +static gboolean _image_has_deep_shadows(const float *in_data, int w, int h) +{ + const size_t stride = 16; // sample 1/256 of pixels for speed + size_t dark = 0, total = 0; + for(size_t y = 0; y < (size_t)h; y += stride) + for(size_t x = 0; x < (size_t)w; x += stride) + { + const size_t p = ((size_t)y * w + x) * 4; + const float luma = 0.2126f * in_data[p] + + 0.7152f * in_data[p + 1] + + 0.0722f * in_data[p + 2]; + if(luma < _SHADOW_BOOST_THRESHOLD) dark++; + total++; + } + return total > 0 && (float)dark / total >= _SHADOW_BOOST_FRACTION; +} + int dt_restore_process_tiled(dt_restore_context_t *ctx, const float *in_data, int width, int height, @@ -690,6 +771,17 @@ int dt_restore_process_tiled(dt_restore_context_t *ctx, if(!ctx || !in_data || !row_writer) return 1; + // for shadow-boost-capable models, decide per-image whether the + // curve is worth applying; one analysis per call, before tiling, + // so all tiles see the same flag (avoids per-tile seams) + if(ctx->shadow_boost_capable) + { + const gboolean dark = _image_has_deep_shadows(in_data, width, height); + ctx->shadow_boost = dark; + dt_print(DT_DEBUG_AI, "[restore] shadow boost %s", + dark ? "enabled" : "disabled"); + } + const int O = (scale > 1) ? OVERLAP_UPSCALE : OVERLAP_DENOISE; const int S = scale; const int out_w = width * S; From 04faead4ce22c1c3b87e317965a77f3c1e801322 Mon Sep 17 00:00:00 2001 From: Andrii Ryzhkov Date: Wed, 15 Apr 2026 12:40:19 +0200 Subject: [PATCH 5/6] Tighten gamut margin to reduce noise specks in pass-through regions --- src/common/ai/restore.c | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/common/ai/restore.c b/src/common/ai/restore.c index 7fa145be0e6d..6a013b2e1476 100644 --- a/src/common/ai/restore.c +++ b/src/common/ai/restore.c @@ -539,9 +539,12 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, // gamut check uses pre-boost values so pass-through decisions // reflect the original color if(in_gamut_mask) - in_gamut_mask[p] = (sr >= 0.0f && sr <= 1.0f - && sg >= 0.0f && sg <= 1.0f - && sb >= 0.0f && sb <= 1.0f) ? 1 : 0; + { + const float m = 0.01f; // ~1% margin beyond [0, 1] + in_gamut_mask[p] = (sr >= -m && sr <= 1.0f + m + && sg >= -m && sg <= 1.0f + m + && sb >= -m && sb <= 1.0f + m) ? 1 : 0; + } if(boost) { sr = sr > 0.0f ? sqrtf(sr) : 0.0f; From 6dd154cb21b7ccecfb93f943904b3e63a327cfb7 Mon Sep 17 00:00:00 2001 From: Andrii Ryzhkov Date: Wed, 15 Apr 2026 13:13:00 +0200 Subject: [PATCH 6/6] Smooth pass-through speckles via luminance-only replacement --- src/common/ai/restore.c | 57 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/common/ai/restore.c b/src/common/ai/restore.c index 6a013b2e1476..53ceab0b4863 100644 --- a/src/common/ai/restore.c +++ b/src/common/ai/restore.c @@ -488,6 +488,15 @@ static int _select_tile_size(int scale) return candidates[n_candidates - 1]; } +// Rec.709 / sRGB luminance weights (Y row of sRGB->XYZ D65); +// applied to working-profile-linear pixels in the pass-through +// blending below; exact only when the working profile is +// sRGB/Rec.709, but correct enough for luminance deltas +static inline float _luma_rec709(float r, float g, float b) +{ + return 0.2126f * r + 0.7152f * g + 0.0722f * b; +} + int dt_restore_run_patch(dt_restore_context_t *ctx, const float *in_patch, int w, int h, @@ -645,6 +654,9 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, { const size_t out_plane = (size_t)out_w * out_h; const float *Mi = ctx->srgb_to_wp; + // pass 1: write denoised values for in-gamut pixels; out-of-gamut + // pixels get plain pass-through as a fallback (used only when no + // in-gamut neighbors are found in pass 2) for(size_t p = 0; p < out_plane; p++) { if(in_gamut_mask[p]) @@ -664,6 +676,51 @@ int dt_restore_run_patch(dt_restore_context_t *ctx, out_patch[p + 2 * out_plane] = in_patch[p + 2 * plane]; } } + // pass 2: luminance-only smoothing for out-of-gamut pixels. the + // original pixel keeps its chroma (wide-gamut color preserved + // exactly) but its brightness is shifted to match the local + // average luminance of denoised in-gamut neighbors; this kills + // the single-pixel speckles that pass-through would otherwise + // leave visible against the denoised background + const int radius = 2; // 5x5 window + for(int y = 0; y < out_h; y++) + { + for(int x = 0; x < out_w; x++) + { + const size_t p = (size_t)y * out_w + x; + if(in_gamut_mask[p]) continue; + const float r0 = in_patch[p]; + const float g0 = in_patch[p + plane]; + const float b0 = in_patch[p + 2 * plane]; + const float Y_orig = _luma_rec709(r0, g0, b0); + float sumY = 0.0f; + int count = 0; + const int y0 = y - radius < 0 ? 0 : y - radius; + const int y1 = y + radius >= out_h ? out_h - 1 : y + radius; + const int x0 = x - radius < 0 ? 0 : x - radius; + const int x1 = x + radius >= out_w ? out_w - 1 : x + radius; + for(int yy = y0; yy <= y1; yy++) + { + for(int xx = x0; xx <= x1; xx++) + { + const size_t q = (size_t)yy * out_w + xx; + if(!in_gamut_mask[q]) continue; + const float rq = out_patch[q]; + const float gq = out_patch[q + out_plane]; + const float bq = out_patch[q + 2 * out_plane]; + sumY += _luma_rec709(rq, gq, bq); + count++; + } + } + if(count > 0) + { + const float dY = sumY / (float)count - Y_orig; + out_patch[p] = r0 + dY; + out_patch[p + out_plane] = g0 + dY; + out_patch[p + 2 * out_plane] = b0 + dY; + } + } + } } else if(ctx->has_profile && scale == 1) {