/* This file is part of darktable, Copyright (C) 2026 darktable developers. darktable is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. darktable is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with darktable. If not, see . */ // neural restore — lighttable module for AI-based image restoration // // overview // -------- // provides three operations via a tabbed notebook UI: // - raw denoise: run a RawNIND UtNet2 denoiser pre-demosaic (Bayer // CFA) or post-demosaic (X-Trans / Foveon, via lin_rec2020). // output is a DNG (CFA Bayer or LinearRaw) that re-imports into // the user's full pipeline. // - denoise: run an ONNX denoiser (e.g. NIND UNet) on the user's // processed/exported image. output is a TIFF. // - upscale: run an ONNX super-resolution model (e.g. BSRGAN) at // 2x or 4x. output is a TIFF. // // the module lives in the right panel (DT_UI_CONTAINER_PANEL_RIGHT_CENTER) // and is available in both lighttable and darkroom views. it is only built // when cmake option USE_AI=ON. // // architecture // ------------ // the core AI inference, tiling, color management and DWT detail // recovery logic lives in the darktable_ai library, split across: // - src/common/ai/restore.{c,h} env/ctx lifecycle, model // loaders, raw patch runners, // shared user-pipe ROI bridge // - src/common/ai/restore_rgb.{c,h} RGB denoise + upscale tiled // driver, sRGB wrapper, shadow // boost, DWT detail recovery // - src/common/ai/restore_raw_bayer. RawNIND Bayer end-to-end // {c,h} (batch + piped preview) // - src/common/ai/restore_raw_linear. RawNIND linear / X-Trans // {c,h} end-to-end // // this module handles UI, threading, output writing, and the per-task // preview cache. // // 1. preview (interactive, single-image) // triggered by clicking the picker thumbnail (which sets a // "preview requested" flag) or by tab switching afterwards. // two worker functions, dispatched via _preview_thread_dispatch: // - _preview_thread: RGB denoise + upscale. exports the // image at reduced resolution and runs // dt_restore_run_patch() on a crop. // - _preview_thread_raw: raw denoise. reads the mipmap CFA // (Bayer) or runs darktable's demosaic // pipe (X-Trans → lin_rec2020), feeds // one inference tile, then runs the // user's full pipe twice (with // rawdenoise disabled) on the patched // vs original CFA so before/after match // a re-imported DNG. // both deliver result buffers to the main thread via g_idle_add. // the preview widget draws a split before/after view with a // draggable divider. for RGB denoise, DWT-filtered detail is // pre-computed once per inference so the strength slider can // re-blend interactively without re-running the model. // // responsiveness: // - tab switch routes through a 150 ms debounce // (_schedule_preview_refresh) so rapid cycling collapses to // one preview run // - per-task preview cache keyed by (imgid, patch_center): on // tab switch back to a previously-seen state we install the // cached buffers and skip inference entirely // - new triggers do NOT join the previous worker (would freeze // the UI for the duration of an in-flight pipe call). the // previous thread is detached; preview_inference_lock // serialises inference so the new worker queues without // fighting for the GPU; preview_sequence is bumped so any // in-flight result is discarded by its idle callback when it // eventually arrives. // // 2. batch processing (multi-image) // runs as a dt_control_job on the user background queue. // for each selected image, dispatches by task: // - raw denoise (Bayer): pre-process the sensor CFA (black/WB/ // pack), run tiled inference via dt_restore_raw_bayer(), un- // process and re-mosaic, write a CFA DNG via // dt_imageio_dng_write_cfa_bayer(). // - raw denoise (X-Trans / linear): demosaic via the darktable // pipe (rawprepare + highlights + demosaic only), run // dt_restore_raw_linear(), write a LinearRaw DNG via // dt_imageio_dng_write_linear(). // - denoise / upscale (RGB): export via the darktable pipeline // with a custom format module that intercepts the pixel // buffer in _ai_write_image(). when strength < 100 (so the // DWT detail recovery is active), buffer the full denoised // output, apply dt_restore_apply_detail_recovery(), then // write TIFF. otherwise stream tiles directly to TIFF via // _process_tiled_tiff() to avoid buffering the full output. // output TIFF embeds the selected output ICC profile and // source EXIF. // in all cases, the output is imported into the darktable library // and grouped with the source image (when add-to-catalog is on). // // when the batch finishes, a single completion toast is shown via // dt_control_log (e.g. "neural denoise: 3 images processed"). the // module deliberately does NOT raise DT_SIGNAL_VIEWMANAGER_THUMBTABLE_ACTIVATE: // lighttable ignores that signal while darkroom / map / culling / // tethering / print_settings would swap the user's current view to // the freshly-imported image and clobber any in-progress edit. // // 3. output parameters (collapsible section) // common (all three tabs): // - add to catalog: auto-import output into darktable library // - output directory: supports darktable variables (e.g. $(FILE_FOLDER)) // RGB tabs only (denoise / upscale — raw denoise writes DNG): // - bit depth: 8/16/32-bit TIFF (default 16-bit) // - output ICC profile: pick any installed profile, or keep // image settings // - preserve wide-gamut: when on, out-of-sRGB-gamut pixels pass // through the denoise model unchanged (wide-gamut colors // preserved exactly); when off, those pixels are clipped to // sRGB and denoised like the rest // // threading // --------- // - preview: background GThread spawned per refresh. previous worker // is detached (not joined) so the UI thread never blocks on tab // switch. preview_inference_lock (GMutex) serialises the actual // inference / pipe work so two workers don't fight for the GPU. // stale workers are discarded via the atomic preview_sequence // counter, checked at the dispatcher entry and at key points // inside the worker; idle callbacks re-check before installing. // gui_cleanup joins the latest worker and drains the main context // to flush any pending idle callbacks before freeing module state. // - batch: dt_control_job on DT_JOB_QUEUE_USER_BG. supports // cancellation via dt_control_job_get_state(). // - ai_registry->lock: held briefly to read provider setting. // - all GTK updates go through g_idle_add to stay on the main thread. // // key structs // ----------- // dt_lib_neural_restore_t — module GUI state, preview cache, // inference lock, debounce timer // dt_neural_job_t — batch processing job parameters // dt_neural_format_params_t — custom format module for export // interception (RGB tabs) // dt_neural_preview_data_t — preview thread input parameters // (shared by both workers) // dt_neural_preview_result_t — RGB-tab preview thread output // dt_neural_preview_result_raw_t — raw-tab preview thread output // (also carries the cached // full-image buffers for re-pick) // dt_neural_preview_capture_t — captures export pixels for the // RGB-tab preview // // preferences // ----------- // CONF_STRENGTH — RGB denoise strength slider (0 = source, // 100 = full denoise; internally mapped to // a DWT-filtered residual recovery amount // so lowering strength brings back texture // without reintroducing noise-frequency // content) // CONF_RAW_STRENGTH — raw denoise strength slider (0 = source // CFA, 100 = full denoised CFA; uniform // blend at the re-mosaic sample level) // CONF_ACTIVE_PAGE — last active notebook tab // CONF_BIT_DEPTH — output TIFF bit depth (0=8, 1=16, 2=32) // CONF_COMPRESSION — output TIFF compression (0=none, 1=deflate, 2=deflate+predictor) // CONF_ADD_CATALOG — auto-import output into library // CONF_OUTPUT_DIR - the old output directory, kept only to migrate it // CONF_PATTERN_* - per-task output path, without an extension // CONF_ICC_TYPE — output ICC profile type (image settings by default) // CONF_ICC_FILE — filename for file-type ICC profiles // CONF_PRESERVE_WIDE_GAMUT — pass-through out-of-sRGB-gamut pixels during denoise // CONF_PREVIEW_EXPORT_SIZE — preview export longest-edge in pixels // CONF_PREVIEW_HEIGHT — preview widget height in pixels // CONF_EXPAND_OUTPUT — output section collapsed/expanded state #include "common/ai/restore.h" #include "common/ai/restore_rgb.h" #include "common/ai/restore_raw_bayer.h" #include "common/ai/restore_raw_linear.h" #include "control/conf.h" #include "control/control.h" #include "bauhaus/bauhaus.h" #include "common/act_on.h" #include "common/collection.h" #include "common/variables.h" #include "common/colorspaces.h" #include "imageio/imageio_dng.h" #include "imageio/imageio_jpeg.h" #include "common/exif.h" #include "common/film.h" #include "common/grouping.h" #include "common/history.h" #include "common/image_cache.h" #include "common/colorlabels.h" #include "common/metadata.h" #include "common/mipmap_cache.h" #include "common/tags.h" #include "control/jobs/control_jobs.h" #include "control/signal.h" #include "develop/develop.h" #include "develop/format.h" #include "dtgtk/button.h" #include "dtgtk/paint.h" #include "gui/accelerators.h" #include "imageio/imageio_common.h" #include "imageio/imageio_module.h" #include #include #include #include DT_MODULE(1) #define CONF_PREVIEW_EXPORT_SIZE "plugins/lighttable/neural_restore/preview_export_size" // warn the user when upscaled output exceeds this many megapixels #define LARGE_OUTPUT_MP 60.0 #define CONF_STRENGTH "plugins/lighttable/neural_restore/strength" #define CONF_RAW_STRENGTH "plugins/lighttable/neural_restore/raw_strength" #define CONF_ACTIVE_PAGE "plugins/lighttable/neural_restore/active_page" #define CONF_BIT_DEPTH "plugins/lighttable/neural_restore/bit_depth" #define CONF_COMPRESSION "plugins/lighttable/neural_restore/compression" #define CONF_ADD_CATALOG "plugins/lighttable/neural_restore/add_to_catalog" #define CONF_MARK_OUTPUT "plugins/lighttable/neural_restore/mark_output" #define CONF_OUTPUT_DIR "plugins/lighttable/neural_restore/output_directory" #define CONF_PATTERN_RAW_DENOISE \ "plugins/lighttable/neural_restore/output_pattern/raw_denoise" #define CONF_PATTERN_DENOISE "plugins/lighttable/neural_restore/output_pattern/denoise" #define CONF_PATTERN_UPSCALE "plugins/lighttable/neural_restore/output_pattern/upscale" #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" #define CONF_PREVIEW_TOOLTIP_ZOOM \ "plugins/lighttable/neural_restore/preview_tooltip_zoom" typedef enum dt_neural_task_t { NEURAL_TASK_DENOISE = 0, NEURAL_TASK_UPSCALE_2X, NEURAL_TASK_UPSCALE_4X, NEURAL_TASK_RAW_DENOISE, NEURAL_TASK_COUNT, // sentinel; used to size per-task arrays } dt_neural_task_t; typedef enum dt_neural_bpp_t { NEURAL_BPP_8 = 0, NEURAL_BPP_16 = 1, NEURAL_BPP_32 = 2, } dt_neural_bpp_t; typedef enum dt_neural_compress_t { NEURAL_COMPRESS_NONE = 0, NEURAL_COMPRESS_DEFLATE = 1, NEURAL_COMPRESS_DEFLATE_PREDICTOR = 2, } dt_neural_compress_t; // preview-area placeholder state when no rendered preview exists typedef enum dt_nr_preview_err_t { DT_NR_PREVIEW_ERR_NONE = 0, DT_NR_PREVIEW_ERR_UNSUPPORTED, // sensor class not handled by task DT_NR_PREVIEW_ERR_INIT_FAILED, // mipmap / model / cache load bailed } dt_nr_preview_err_t; typedef struct dt_lib_neural_restore_t { GtkNotebook *notebook; GtkWidget *raw_denoise_page; GtkWidget *denoise_page; GtkWidget *upscale_page; GtkWidget *scale_combo; GtkWidget *preview_area; GtkWidget *process_button; char info_text_left[64]; char info_text_right[128]; char warning_text[128]; GtkWidget *recovery_slider; GtkWidget *raw_strength_slider; dt_neural_task_t task; dt_restore_env_t *env; dt_restore_context_t *cached_ctx; dt_neural_task_t cached_task; dt_pthread_mutex_t ctx_lock; gboolean model_available; GHashTable *processing_images; float *preview_before; float *preview_after; float *preview_detail; int preview_w; int preview_h; float split_pos; gboolean preview_ready; gboolean preview_requested; gboolean dragging_split; gboolean preview_generating; dt_nr_preview_err_t preview_error; gboolean recovery_changing; GThread *preview_thread; gint preview_sequence; unsigned char *cairo_before; unsigned char *cairo_after; int cairo_stride; // preview area selector float patch_center[2]; gboolean picking_thumbnail; GtkWidget *pick_button; // cached export image for re-picking without re-exporting float *export_pixels; int export_w; int export_h; unsigned char *export_cairo; int export_cairo_stride; // fingerprint of the history the cached preview + export were built // from, so an edit between two previews invalidates both guint8 *preview_hash; int preview_hash_len; dt_imgid_t preview_hash_imgid; // raw denoise preview state — disjoint from the export-based preview // above. cached per-image (CFA for Bayer, demosaicked lin_rec2020 for // X-Trans / linear) so re-picking a new crop on the same image skips // the slow load + demosaic; freed on imgid or sensor-type change. dt_imgid_t preview_raw_imgid; // imgid we last showed the "already demosaiced" warning for, so the // toast doesn't fire every patch-click move during preview dt_imgid_t preview_linear_warned_imgid; dt_restore_sensor_class_t preview_raw_sensor_class; float *preview_full_cfa; // Bayer: full sensor (w*h floats) int preview_full_w; int preview_full_h; float *preview_full_lin; // linear: 3ch interleaved (w*h*3 floats) int preview_lin_w; int preview_lin_h; // per-refresh inference output (3ch interleaved at the displayed crop // dims, both in lin_rec2020). cached so the strength slider can blend // these without re-running inference. float *preview_raw_src_rgb; float *preview_raw_denoised_rgb; int preview_raw_crop_w; int preview_raw_crop_h; // strength slider debounce: re-blend on UI thread 50 ms after the // last value-changed emit. set/replaced via g_timeout_add. guint preview_strength_timer; // debounce timer for `_trigger_preview`. tab switches and rapid // re-triggers schedule via this so a quick burst of switches // doesn't pile up worker threads. set/replaced via g_timeout_add; // 0 means no pending trigger guint preview_trigger_timer; // serializes the expensive inference / pipe work in worker threads. // a stale worker (sequence bumped while it was in flight) holds this // until it finishes, so a freshly-spawned worker waits its turn // rather than competing for the same GPU/CPU GMutex preview_inference_lock; // per-task cache of the last successful preview, keyed by // (imgid, patch_center). on tab switch we look up the new task's // slot; if it matches the current image+patch we install the // cached buffers and skip inference entirely. invalidated on // image change or patch move struct { gboolean valid; dt_imgid_t imgid; float patch_center[2]; int crop_w, crop_h; float *before_rgb; // 3ch interleaved, crop_w*crop_h*3 floats float *after_rgb; // same float *detail; // denoise: DWT luminance detail; NULL otherwise } preview_cache[NEURAL_TASK_COUNT]; // output settings (collapsible) dt_gui_collapsible_section_t cs_output; GtkWidget *bpp_combo; GtkWidget *compress_combo; GtkWidget *profile_combo; GtkWidget *preserve_wide_gamut_toggle; GtkWidget *catalog_toggle; GtkWidget *output_pattern_entry; GtkWidget *output_pattern_button; } dt_lib_neural_restore_t; typedef struct dt_neural_job_t { dt_neural_task_t task; dt_restore_env_t *env; GList *images; dt_job_t *control_job; dt_restore_context_t *ctx; int scale; float strength; float raw_strength; // 0..1 blend for raw denoise // raw denoise only: sensor class of the currently-loaded rawdenoise // ctx. lets us reuse ctx across images of the same class in a batch // and avoid reloading the ORT session for every image dt_restore_sensor_class_t raw_ctx_sensor_class; dt_lib_module_t *self; dt_neural_bpp_t bpp; dt_neural_compress_t compression; gboolean add_to_catalog; char *output_pattern; // whole output path, variables unexpanded, no extension // 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; // raw denoise only: TRUE after we've toasted the "already demosaiced" // caveat for any LINEAR image in this batch; avoids one warning // per file when the user kicks off a multi-image job gboolean linear_warned; } dt_neural_job_t; typedef struct dt_neural_format_params_t { dt_imageio_module_data_t parent; dt_neural_job_t *job; } dt_neural_format_params_t; typedef struct dt_neural_preview_capture_t { dt_imageio_module_data_t parent; float *pixels; int cap_w; int cap_h; } dt_neural_preview_capture_t; typedef struct dt_neural_preview_data_t { dt_lib_module_t *self; dt_imgid_t imgid; dt_neural_task_t task; int scale; dt_restore_env_t *env; int sequence; int preview_w; int preview_h; float patch_center[2]; // when re-picking, a private copy of the cached export saves a // re-export. owned by the worker, which frees it on every exit float *reuse_pixels; int reuse_w; int reuse_h; // whether a picker thumbnail existed when this worker was launched gboolean have_export; } dt_neural_preview_data_t; typedef struct dt_neural_preview_result_t { dt_lib_module_t *self; float *before; float *after; float *export_pixels; int export_w; int export_h; int sequence; int width; int height; // cache key components copied from the originating preview request: // the worker may run after the user has switched tabs/images, so // the idle callback uses these (not d->* live values) to decide // whether the result is still applicable to the current state and // to populate the per-task preview cache slot dt_neural_task_t task; dt_imgid_t imgid; float patch_center[2]; } dt_neural_preview_result_t; const char *name(dt_lib_module_t *self) { return _("neural restore"); } const char *description(dt_lib_module_t *self) { return _("AI-based image restoration: denoise and upscale"); } dt_view_type_flags_t views(dt_lib_module_t *self) { return DT_VIEW_LIGHTTABLE | DT_VIEW_DARKROOM; } uint32_t container(dt_lib_module_t *self) { return DT_UI_CONTAINER_PANEL_RIGHT_CENTER; } int position(const dt_lib_module_t *self) { return 400; } static int _ai_check_bpp(dt_imageio_module_data_t *data) { return 32; } static int _ai_check_levels(dt_imageio_module_data_t *data) { return IMAGEIO_RGB | IMAGEIO_FLOAT; } static const char *_ai_get_mime(dt_imageio_module_data_t *data) { return "memory"; } static int _preview_capture_write_image(dt_imageio_module_data_t *data, const char *filename, const void *in_void, dt_colorspaces_color_profile_type_t over_type, const char *over_filename, void *exif, int exif_len, dt_imgid_t imgid, const int num, const int total, dt_dev_pixelpipe_t *pipe, const gboolean export_masks) { dt_neural_preview_capture_t *cap = (dt_neural_preview_capture_t *)data; const int w = data->width; const int h = data->height; const size_t buf_size = (size_t)w * h * 4 * sizeof(float); cap->pixels = g_try_malloc(buf_size); if(cap->pixels) { memcpy(cap->pixels, in_void, buf_size); cap->cap_w = w; cap->cap_h = h; } return 0; } static inline float _linear_to_srgb(const float v) { if(v <= 0.0f) return 0.0f; if(v >= 1.0f) return 1.0f; return (v <= 0.0031308f) ? 12.92f * v : 1.055f * powf(v, 1.0f / 2.4f) - 0.055f; } // stamp the finished DNG as already denoised. after the writer, which // merges the source exif blob in and would overwrite an earlier stamp static void _mark_neural_restore_output(const char *path) { if(!dt_conf_get_bool(CONF_MARK_OUTPUT)) return; if(!dt_exif_xmp_write_neural_restore(path, "raw-denoise")) dt_print(DT_DEBUG_AI, "[neural_restore] could not mark %s as denoised; an " "auto-applied denoise preset may run on it", path); } // pull the camera's embedded JPEG preview from the source raw, to embed // as the DNG thumbnail; output buffer is g_malloc'd (caller frees) static int _extract_source_jpeg_preview(const char *src_path, uint8_t **out_jpeg, int *out_jpeg_len, int *out_w, int *out_h) { *out_jpeg = NULL; *out_jpeg_len = 0; *out_w = 0; *out_h = 0; uint8_t *raw_buf = NULL; size_t raw_size = 0; char *mime = NULL; // dt_exif_get_thumbnail returns FALSE on success, allocates via malloc/strdup if(dt_exif_get_thumbnail(src_path, &raw_buf, &raw_size, &mime) || !raw_buf) { free(raw_buf); free(mime); return 1; } const gboolean is_jpeg = mime && (strcmp(mime, "image/jpeg") == 0); free(mime); if(!is_jpeg || raw_size == 0 || raw_size > (size_t)INT_MAX) { free(raw_buf); return 1; } dt_imageio_jpeg_t jpg; if(dt_imageio_jpeg_decompress_header(raw_buf, raw_size, &jpg) != 0 || jpg.width <= 0 || jpg.height <= 0) { free(raw_buf); return 1; } // re-allocate via glib so caller can g_free uniformly uint8_t *jpeg = g_try_malloc(raw_size); if(!jpeg) { free(raw_buf); return 1; } memcpy(jpeg, raw_buf, raw_size); free(raw_buf); *out_jpeg = jpeg; *out_jpeg_len = (int)raw_size; *out_w = jpg.width; *out_h = jpg.height; return 0; } // convert float RGB (3ch interleaved, linear) to cairo RGB24 surface data static void _float_rgb_to_cairo(const float *const restrict src, unsigned char *const restrict dst, const int width, const int height, const int stride) { for(int y = 0; y < height; y++) { uint32_t *row = (uint32_t *)(dst + y * stride); for(int x = 0; x < width; x++) { const int si = (y * width + x) * 3; const uint8_t r = (uint8_t)(_linear_to_srgb(src[si + 0]) * 255.0f + 0.5f); const uint8_t g = (uint8_t)(_linear_to_srgb(src[si + 1]) * 255.0f + 0.5f); const uint8_t b = (uint8_t)(_linear_to_srgb(src[si + 2]) * 255.0f + 0.5f); row[x] = ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b; } } } // nearest-neighbor upscale for before preview in upscale mode static void _nn_upscale(const float *const restrict src, const int src_w, const int src_h, float *const restrict dst, const int dst_w, const int dst_h) { for(int y = 0; y < dst_h; y++) { const int sy = y * src_h / dst_h; for(int x = 0; x < dst_w; x++) { const int sx = x * src_w / dst_w; const size_t di = ((size_t)y * dst_w + x) * 3; const size_t si = ((size_t)sy * src_w + sx) * 3; dst[di + 0] = src[si + 0]; dst[di + 1] = src[si + 1]; dst[di + 2] = src[si + 2]; } } } // write a float 3ch scanline to TIFF, converting to the // target bit depth. scratch must be at least width*3*4 bytes static int _write_tiff_scanline(TIFF *tif, const float *src, const int width, const int bpp, const int row, void *scratch) { if(bpp == 32) return TIFFWriteScanline(tif, (void *)src, row, 0); if(bpp == 16) { uint16_t *dst = (uint16_t *)scratch; for(int i = 0; i < width * 3; i++) { const float v = CLAMPF(src[i], 0.0f, 1.0f); dst[i] = (uint16_t)(v * 65535.0f + 0.5f); } return TIFFWriteScanline(tif, dst, row, 0); } // 8 bit uint8_t *dst = (uint8_t *)scratch; for(int i = 0; i < width * 3; i++) { const float v = CLAMPF(src[i], 0.0f, 1.0f); dst[i] = (uint8_t)(v * 255.0f + 0.5f); } return TIFFWriteScanline(tif, dst, row, 0); } // load the right model for a task static dt_restore_context_t *_load_for_task(dt_restore_env_t *env, dt_neural_task_t task) { switch(task) { case NEURAL_TASK_DENOISE: return dt_restore_load_denoise(env); case NEURAL_TASK_RAW_DENOISE: // focus on bayer for now; auto-pick bayer vs linear per image // sensor is a follow-up (see dt_restore_load_rawdenoise_linear) return dt_restore_load_rawdenoise_bayer(env); case NEURAL_TASK_UPSCALE_2X: return dt_restore_load_upscale_x2(env); case NEURAL_TASK_UPSCALE_4X: return dt_restore_load_upscale_x4(env); default: return NULL; } } // short, untranslated task names for debug logs (use the localised // labels at line ~1022 for user-visible strings) static const char *_task_log_name(const dt_neural_task_t task) { switch(task) { case NEURAL_TASK_DENOISE: return "denoise"; case NEURAL_TASK_RAW_DENOISE: return "raw denoise"; case NEURAL_TASK_UPSCALE_2X: return "upscale 2x"; case NEURAL_TASK_UPSCALE_4X: return "upscale 4x"; default: return "?"; } } // check if a model is available for a task static gboolean _task_model_available(dt_restore_env_t *env, const dt_neural_task_t task) { switch(task) { case NEURAL_TASK_DENOISE: return dt_restore_denoise_available(env); case NEURAL_TASK_RAW_DENOISE: return dt_restore_rawdenoise_available(env); default: return dt_restore_upscale_available(env); } } // configure the restore context to receive LIN_REC2020 linear input, // matching the profile the export pipe is forced to produce static void _setup_restore_for_linear_input(dt_restore_context_t *ctx, const gboolean preserve_wide_gamut) { const dt_colorspaces_color_profile_t *lin_cp = dt_colorspaces_get_profile(DT_COLORSPACE_LIN_REC2020, "", DT_PROFILE_DIRECTION_OUT); dt_restore_set_profile(ctx, lin_cp ? lin_cp->profile : NULL); dt_restore_set_preserve_wide_gamut(ctx, preserve_wide_gamut); } // build an LCMS transform from LIN_REC2020 to dst, NULL when src == dst; // caller frees with cmsDeleteTransform static cmsHTRANSFORM _build_output_color_transform (dt_colorspaces_color_profile_type_t dst_type, const char *dst_filename) { const dt_colorspaces_color_profile_t *lin_cp = dt_colorspaces_get_profile(DT_COLORSPACE_LIN_REC2020, "", DT_PROFILE_DIRECTION_OUT); const dt_colorspaces_color_profile_t *dst_cp = dt_colorspaces_get_profile(dst_type, dst_filename, DT_PROFILE_DIRECTION_OUT); if(!lin_cp || !dst_cp || lin_cp->profile == dst_cp->profile) return NULL; return cmsCreateTransform(lin_cp->profile, TYPE_RGB_FLT, dst_cp->profile, TYPE_RGB_FLT, INTENT_PERCEPTUAL, 0); } // row writer: copy 3ch float scanline to float4 RGBA buffer typedef struct _buf_writer_data_t { float *out_buf; int out_w; } _buf_writer_data_t; static int _buf_row_writer(const float *scanline, const int out_w, const int y, void *user_data) { _buf_writer_data_t *wd = (_buf_writer_data_t *)user_data; float *dst = wd->out_buf + (size_t)y * wd->out_w * 4; for(int x = 0; x < out_w; x++) { dst[x * 4 + 0] = scanline[x * 3 + 0]; dst[x * 4 + 1] = scanline[x * 3 + 1]; dst[x * 4 + 2] = scanline[x * 3 + 2]; dst[x * 4 + 3] = 0.0f; } return 0; } // row writer: convert and write 3ch float scanline to TIFF typedef struct _tiff_writer_data_t { TIFF *tif; int bpp; void *scratch; // bpp conversion buffer cmsHTRANSFORM xform; // LIN_REC2020 → dst, or NULL float *xform_scratch; // [out_w * 3] floats, only if xform != NULL } _tiff_writer_data_t; static int _tiff_row_writer(const float *scanline, const int out_w, const int y, void *user_data) { _tiff_writer_data_t *wd = (_tiff_writer_data_t *)user_data; const float *src = scanline; if(wd->xform) { cmsDoTransform(wd->xform, scanline, wd->xform_scratch, out_w); src = wd->xform_scratch; } return (_write_tiff_scanline(wd->tif, src, out_w, wd->bpp, y, wd->scratch) < 0) ? 1 : 0; } static int _ai_write_image(dt_imageio_module_data_t *data, const char *filename, const void *in_void, dt_colorspaces_color_profile_type_t over_type, const char *over_filename, void *exif, const int exif_len, const dt_imgid_t imgid, const int num, const int total, dt_dev_pixelpipe_t *pipe, const gboolean export_masks) { dt_neural_format_params_t *params = (dt_neural_format_params_t *)data; dt_neural_job_t *job = params->job; if(!job->ctx) return 1; _setup_restore_for_linear_input(job->ctx, job->preserve_wide_gamut); // resolve destination profile for TIFF ICC embed (NONE = working) const dt_colorspaces_color_profile_t *work_cp = dt_colorspaces_get_work_profile(imgid); const dt_colorspaces_color_profile_type_t dst_type = (job->icc_type == DT_COLORSPACE_NONE) ? (work_cp ? work_cp->type : DT_COLORSPACE_LIN_REC2020) : job->icc_type; const char *dst_filename = (job->icc_type == DT_COLORSPACE_NONE) ? (work_cp ? work_cp->filename : "") : (job->icc_filename ? job->icc_filename : ""); const dt_colorspaces_color_profile_t *dst_cp = dt_colorspaces_get_profile(dst_type, dst_filename, DT_PROFILE_DIRECTION_OUT); cmsHTRANSFORM xform = _build_output_color_transform(dst_type, dst_filename); const int width = params->parent.width; const int height = params->parent.height; const int S = job->scale; const int out_w = width * S; const int out_h = height * S; const float *in_data = (const float *)in_void; dt_print(DT_DEBUG_AI, "[neural_restore] processing %dx%d -> %dx%d (scale=%d)", width, height, out_w, out_h, S); #ifdef _WIN32 wchar_t *wfilename = g_utf8_to_utf16(filename, -1, NULL, NULL, NULL); TIFF *tif = TIFFOpenW(wfilename, "w"); g_free(wfilename); #else TIFF *tif = TIFFOpen(filename, "w"); #endif if(!tif) { dt_control_log(_("failed to open TIFF for writing: %s"), filename); return 1; } const int bpp = (job->bpp == NEURAL_BPP_8) ? 8 : (job->bpp == NEURAL_BPP_16) ? 16 : 32; const int sample_fmt = (bpp == 32) ? SAMPLEFORMAT_IEEEFP : SAMPLEFORMAT_UINT; TIFFSetField(tif, TIFFTAG_IMAGEWIDTH, out_w); TIFFSetField(tif, TIFFTAG_IMAGELENGTH, out_h); TIFFSetField(tif, TIFFTAG_SAMPLESPERPIXEL, 3); TIFFSetField(tif, TIFFTAG_BITSPERSAMPLE, bpp); TIFFSetField(tif, TIFFTAG_PLANARCONFIG, PLANARCONFIG_CONTIG); TIFFSetField(tif, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_RGB); TIFFSetField(tif, TIFFTAG_SAMPLEFORMAT, sample_fmt); TIFFSetField(tif, TIFFTAG_ROWSPERSTRIP, TIFFDefaultStripSize(tif, 0)); // compression — mirrors src/imageio/format/tiff.c const int zlevel = dt_conf_key_exists("plugins/imageio/format/tiff/compresslevel") ? dt_conf_get_int("plugins/imageio/format/tiff/compresslevel") : 6; if(job->compression == NEURAL_COMPRESS_DEFLATE) { TIFFSetField(tif, TIFFTAG_COMPRESSION, COMPRESSION_ADOBE_DEFLATE); TIFFSetField(tif, TIFFTAG_PREDICTOR, PREDICTOR_NONE); TIFFSetField(tif, TIFFTAG_ZIPQUALITY, (uint16_t)zlevel); } else if(job->compression == NEURAL_COMPRESS_DEFLATE_PREDICTOR) { TIFFSetField(tif, TIFFTAG_COMPRESSION, COMPRESSION_ADOBE_DEFLATE); if(sample_fmt == SAMPLEFORMAT_IEEEFP) TIFFSetField(tif, TIFFTAG_PREDICTOR, PREDICTOR_FLOATINGPOINT); else TIFFSetField(tif, TIFFTAG_PREDICTOR, PREDICTOR_HORIZONTAL); TIFFSetField(tif, TIFFTAG_ZIPQUALITY, (uint16_t)zlevel); } // embed destination profile so re-import / external viewers // interpret the saved pixels correctly if(dst_cp && dst_cp->profile) { uint32_t icc_len = 0; cmsSaveProfileToMem(dst_cp->profile, NULL, &icc_len); if(icc_len > 0) { uint8_t *icc_buf = g_try_malloc(icc_len); if(icc_buf) { cmsSaveProfileToMem(dst_cp->profile, icc_buf, &icc_len); TIFFSetField(tif, TIFFTAG_ICCPROFILE, icc_len, icc_buf); g_free(icc_buf); } } } // strength: 100 = full denoise (no recovery), 0 = source-like. // DWT detail recovery runs whenever strength < 100, mixing // (1 - strength/100) of the filtered residual back into the output. const float recovery_alpha = 1.0f - job->strength / 100.0f; const gboolean need_buffer = (recovery_alpha > 0.0f && S == 1); int res; if(need_buffer) { // buffer full denoised output for detail recovery float *out_4ch = g_try_malloc((size_t)out_w * out_h * 4 * sizeof(float)); if(!out_4ch) { TIFFClose(tif); dt_control_log(_("out of memory for detail recovery buffer")); return 1; } _buf_writer_data_t bwd = { .out_buf = out_4ch, .out_w = out_w }; res = dt_restore_process_tiled(job->ctx, in_data, width, height, S, _buf_row_writer, &bwd, job->control_job); if(res == 0) { dt_restore_apply_detail_recovery(in_data, out_4ch, width, height, recovery_alpha); // write buffered LIN_REC2020 result, converting to dst per row const size_t row_bytes = (size_t)out_w * 3 * sizeof(float); float *scan = g_malloc(row_bytes); void *cvt = (bpp < 32) ? g_malloc((size_t)out_w * 3 * sizeof(uint16_t)) : NULL; for(int y = 0; y < out_h && res == 0; y++) { const float *row = out_4ch + (size_t)y * out_w * 4; for(int x = 0; x < out_w; x++) { scan[x * 3 + 0] = row[x * 4 + 0]; scan[x * 3 + 1] = row[x * 4 + 1]; scan[x * 3 + 2] = row[x * 4 + 2]; } if(xform) cmsDoTransform(xform, scan, scan, out_w); if(_write_tiff_scanline(tif, scan, out_w, bpp, y, cvt) < 0) { dt_print(DT_DEBUG_AI, "[neural_restore] TIFF write error at scanline %d", y); res = 1; } } g_free(cvt); g_free(scan); } g_free(out_4ch); } else { void *scratch = (bpp < 32) ? g_try_malloc((size_t)out_w * 3 * sizeof(uint16_t)) : NULL; float *xform_scratch = xform ? g_try_malloc((size_t)out_w * 3 * sizeof(float)) : NULL; // scratch OOM: drop xform so the writer skips the transform branch if(xform && !xform_scratch) { cmsDeleteTransform(xform); xform = NULL; } _tiff_writer_data_t twd = { .tif = tif, .bpp = bpp, .scratch = scratch, .xform = xform, .xform_scratch = xform_scratch }; res = dt_restore_process_tiled(job->ctx, in_data, width, height, S, _tiff_row_writer, &twd, job->control_job); g_free(scratch); g_free(xform_scratch); } TIFFClose(tif); if(xform) cmsDeleteTransform(xform); // write EXIF metadata from source image if(res == 0 && exif && exif_len > 0) dt_exif_write_blob(exif, exif_len, filename, 0); if(res != 0) g_unlink(filename); return res; } static void _import_image(const char *filename, const dt_imgid_t source_imgid) { dt_film_t film; dt_film_init(&film); char *dir = g_path_get_dirname(filename); const dt_filmid_t filmid = dt_film_new(&film, dir); g_free(dir); const dt_imgid_t newid = dt_image_import(filmid, filename, TRUE, FALSE); dt_film_cleanup(&film); if(dt_is_valid_imgid(newid)) { dt_print(DT_DEBUG_AI, "[neural_restore] imported imgid=%d: %s", newid, filename); if(dt_is_valid_imgid(source_imgid)) { // retrieve and copy image rating from the source image const dt_image_t *src_rate = dt_image_cache_get(source_imgid, 'r'); const int rating = dt_image_get_xmp_rating(src_rate); dt_image_cache_read_release(src_rate); dt_image_t *img = dt_image_cache_get(newid, 'w'); dt_image_set_xmp_rating(img, rating); dt_image_cache_write_release(img, DT_IMAGE_CACHE_SAFE); // retrieve and copy color labels from the source image const int color_labels = dt_colorlabels_get_labels(source_imgid); for(int color = 0; color < DT_COLORLABELS_LAST; color++) { if(color_labels & (1 << color)) { dt_colorlabels_set_label(newid, color); } } // retrieve and copy manual geolocation metadata dt_image_geoloc_t geoloc; geoloc.longitude = NAN; geoloc.latitude = NAN; geoloc.elevation = NAN; dt_image_get_location(source_imgid, &geoloc); if(!isnan(geoloc.longitude) && !isnan(geoloc.latitude)) { dt_image_set_location(newid, &geoloc, FALSE, FALSE); } // retrieve and copy database-only metadata like title, description, etc. GList *meta = dt_metadata_get_list_id(source_imgid); if(meta) { GList *imgs = g_list_prepend(NULL, GINT_TO_POINTER(newid)); dt_metadata_set_list_id(imgs, meta, FALSE, FALSE); g_list_free(imgs); g_list_free_full(meta, g_free); } const dt_image_t *src = dt_image_cache_get(source_imgid, 'r'); // get source image group ID; fallback to source_imgid if cache miss to // avoid invalid 0 (NO_IMGID) group ID const dt_imgid_t grpid = (src && dt_is_valid_imgid(src->group_id)) ? src->group_id : source_imgid; dt_image_cache_read_release(src); dt_grouping_add_to_group(grpid, newid); // promote the output as group leader, but only when the source // was the current leader — preserves any manually-set leader the // user deliberately chose const gboolean source_is_leader = (grpid == source_imgid); if(source_is_leader) dt_grouping_change_representative(newid); // propagate the source's user tags so the output stays visible // in tag-based collections (TRUE = skip darktable|* auto-tags) GList *src_tags = NULL; if(dt_tag_get_attached(source_imgid, &src_tags, TRUE)) { GList *targets = g_list_prepend(NULL, GINT_TO_POINTER(newid)); for(GList *t = src_tags; t; t = t->next) dt_tag_attach_images(((dt_tag_t *)t->data)->id, targets, FALSE); g_list_free(targets); } dt_tag_free_result(&src_tags); // sync metadata changes to sidecar files and trigger UI updates dt_image_synch_xmp(newid); } // refresh the collection so the new image appears in the thumb grid dt_collection_update_query(darktable.collection, DT_COLLECTION_CHANGE_RELOAD, DT_COLLECTION_PROP_UNDEF, NULL); } } // one per task, because each wants its own suffix. all upscale factors share // one: the page offers them through a single combo static const char *_task_pattern_key(const dt_neural_task_t task) { switch(task) { case NEURAL_TASK_RAW_DENOISE: return CONF_PATTERN_RAW_DENOISE; case NEURAL_TASK_DENOISE: return CONF_PATTERN_DENOISE; default: return CONF_PATTERN_UPSCALE; } } // an emptied entry means "give the default back", not a path of nothing static char *_task_output_pattern(const dt_neural_task_t task) { const char *key = _task_pattern_key(task); char *pattern = dt_conf_get_string(key); if(pattern && pattern[0]) return pattern; g_free(pattern); // dt_confgen_get answers "" for a key the installed darktableconfig.xml does // not declare (src/control/conf.c:681), and an empty path writes ./.tif // the literal also keeps this from ever returning "", which _output_pattern_browse // relies on to avoid naming the file "." const char *def = dt_confgen_get(key, DT_DEFAULT); return g_strdup(def && def[0] ? def : "$(FILE_FOLDER)/$(FILE.NAME)_restore"); } // a one-shot config upgrade, not a second way to name a file: 5.6.x stored a // directory, so fold an existing one in and clear it. naming reads the patterns static void _migrate_output_dir(void) { char *legacy = dt_conf_get_string(CONF_OUTPUT_DIR); if(legacy && legacy[0]) { const dt_neural_task_t tasks[] = { NEURAL_TASK_RAW_DENOISE, NEURAL_TASK_DENOISE, NEURAL_TASK_UPSCALE_2X }; for(size_t i = 0; i < sizeof(tasks) / sizeof(*tasks); i++) { char *current = _task_output_pattern(tasks[i]); char *name = g_path_get_basename(current); char *pattern = g_build_filename(legacy, name, NULL); dt_conf_set_string(_task_pattern_key(tasks[i]), pattern); g_free(pattern); g_free(name); g_free(current); } dt_conf_set_string(CONF_OUTPUT_DIR, ""); } g_free(legacy); } // the entry shows one task at a time, so it follows the active one static void _output_pattern_refresh(dt_lib_neural_restore_t *d) { if(!d->output_pattern_entry) return; char *pattern = _task_output_pattern(d->task); GtkEntry *entry = GTK_ENTRY(d->output_pattern_entry); // setting the text re-enters the changed handler, so only touch what differs if(g_strcmp0(pattern, gtk_entry_get_text(entry))) { gtk_entry_set_text(entry, pattern); gtk_editable_set_position(GTK_EDITABLE(entry), -1); } g_free(pattern); } static int _task_scale(const dt_neural_task_t task) { switch(task) { case NEURAL_TASK_UPSCALE_2X: return 2; case NEURAL_TASK_UPSCALE_4X: return 4; default: return 1; } } static void _update_button_sensitivity(dt_lib_neural_restore_t *d); typedef struct _job_finished_data_t { dt_lib_module_t *self; GList *images; // image IDs to remove from processing set } _job_finished_data_t; static gboolean _job_finished_idle(gpointer data) { _job_finished_data_t *fd = (_job_finished_data_t *)data; dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)fd->self->data; if(d) { for(GList *l = fd->images; l; l = g_list_next(l)) g_hash_table_remove(d->processing_images, l->data); _update_button_sensitivity(d); if (fd->images) DT_CONTROL_SIGNAL_RAISE(DT_SIGNAL_METADATA_CHANGED, DT_METADATA_SIGNAL_NEW_VALUE); } g_list_free(fd->images); g_free(fd); return G_SOURCE_REMOVE; } static void _job_cleanup(void *param) { dt_neural_job_t *job = (dt_neural_job_t *)param; // return the model to the preview cache if it's empty // (avoids reloading the model for the next preview) dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)job->self->data; dt_pthread_mutex_lock(&d->ctx_lock); if(!d->cached_ctx && job->ctx) { d->cached_ctx = dt_restore_ref(job->ctx); d->cached_task = job->task; } dt_pthread_mutex_unlock(&d->ctx_lock); dt_restore_unref(job->ctx); g_free(job->output_pattern); g_free(job->icc_filename); g_list_free(job->images); g_free(job); } // raw-denoise batch path: bypasses the darktable export pipeline and // goes directly from the source CFA mosaic to a denoised DNG. unlike // RGB denoise/upscale, there is no demosaic / WB / tonemap / export // involved — the raw pixels leave and re-enter the pipeline at the // same stage, so the darktable re-import runs its normal pipeline on // cleaner data. intentionally self-contained and free of interactions // with the RGB denoise path // ensure j->ctx is loaded with the rawdenoise variant matching the // image's sensor class. reloads if needed; tracks the currently-loaded // variant in j->raw_ctx_sensor_class so consecutive images of the same // class don't pay the reload cost. returns 0 on success static int _ensure_raw_ctx(dt_neural_job_t *j, const dt_restore_sensor_class_t cls) { if(j->ctx && j->raw_ctx_sensor_class == cls) return 0; if(j->ctx) { dt_restore_unref(j->ctx); j->ctx = NULL; } const char *message = NULL; switch(cls) { case DT_RESTORE_SENSOR_CLASS_BAYER: j->ctx = dt_restore_load_rawdenoise_bayer(j->env); message = _("failed to load AI model for Bayer raw denoising"); break; case DT_RESTORE_SENSOR_CLASS_XTRANS: j->ctx = dt_restore_load_rawdenoise_xtrans(j->env); message = _("failed to load AI model for X-Trans raw denoising"); break; case DT_RESTORE_SENSOR_CLASS_LINEAR: j->ctx = dt_restore_load_rawdenoise_linear(j->env); message = _("failed to load AI model for linear raw denoising"); break; default: return 1; } if(!j->ctx) { dt_control_log(message, (char* )NULL); return 1; } j->raw_ctx_sensor_class = cls; return 0; } // bayer variant: source CFA (single-channel) -> denoise -> CFA DNG static int _process_raw_denoise_bayer(dt_neural_job_t *j, const dt_imgid_t imgid, const char *out_filename, const char *src_path, const dt_image_t *img_meta) { dt_mipmap_buffer_t mbuf; dt_mipmap_cache_get(&mbuf, imgid, DT_MIPMAP_FULL, DT_MIPMAP_BLOCKING, 'r'); if(!mbuf.buf) { dt_print(DT_DEBUG_AI, "[neural_restore] failed to load raw mosaic for imgid %d", imgid); dt_mipmap_cache_release(&mbuf); return 1; } const int width = img_meta->width; const int height = img_meta->height; const size_t npix = (size_t)width * height; float *cfa_in = g_try_malloc(npix * sizeof(float)); if(!cfa_in) { dt_mipmap_cache_release(&mbuf); return 1; } if(img_meta->buf_dsc.datatype == TYPE_UINT16) { const uint16_t *const src = (const uint16_t *)mbuf.buf; for(size_t i = 0; i < npix; i++) cfa_in[i] = (float)src[i]; } else if(img_meta->buf_dsc.datatype == TYPE_FLOAT) { memcpy(cfa_in, mbuf.buf, npix * sizeof(float)); } else { dt_control_log(_("raw denoise: unsupported raw datatype")); g_free(cfa_in); dt_mipmap_cache_release(&mbuf); return 1; } dt_mipmap_cache_release(&mbuf); uint16_t *cfa_out = g_try_malloc(npix * sizeof(uint16_t)); if(!cfa_out) { g_free(cfa_in); return 1; } int res = dt_restore_raw_bayer(j->ctx, img_meta, cfa_in, width, height, cfa_out, j->raw_strength, j->control_job); g_free(cfa_in); if(res != 0) { g_free(cfa_out); return res; } uint8_t *exif_blob = NULL; const int exif_len = dt_exif_read_blob(&exif_blob, src_path, imgid, FALSE, width, height, TRUE); uint8_t *jpeg_buf = NULL; int jpeg_len = 0, jpeg_w = 0, jpeg_h = 0; dt_imageio_dng_preview_t preview = {0}; const int prev_rc = _extract_source_jpeg_preview(src_path, &jpeg_buf, &jpeg_len, &jpeg_w, &jpeg_h); if(prev_rc == 0) { preview.data = jpeg_buf; preview.len = jpeg_len; preview.width = jpeg_w; preview.height = jpeg_h; dt_print(DT_DEBUG_AI, "[neural_restore] embedded JPEG preview from source %dx%d (%d bytes)", jpeg_w, jpeg_h, jpeg_len); } else { dt_print(DT_DEBUG_AI, "[neural_restore] no embedded preview in source (rc=%d) — " "writing DNG without thumbnail", prev_rc); } res = dt_imageio_dng_write_cfa_bayer(out_filename, cfa_out, width, height, img_meta, exif_blob, exif_len, jpeg_buf ? &preview : NULL); g_free(jpeg_buf); g_free(exif_blob); g_free(cfa_out); if(res != 0) g_unlink(out_filename); if(res == 0) _mark_neural_restore_output(out_filename); return res; } // linear variant: darktable's demosaic runs inside raw_restore_linear, // so there's no CFA buffer to hand in. output is a 3ch linear DNG static int _process_raw_denoise_linear(dt_neural_job_t *j, const dt_imgid_t imgid, const char *out_filename, const char *src_path, const dt_image_t *img_meta) { float *rgb = NULL; int w = 0, h = 0; int res = dt_restore_raw_linear(j->ctx, imgid, &rgb, &w, &h, j->raw_strength, j->control_job); if(res != 0 || !rgb) { g_free(rgb); return res ? res : 1; } uint8_t *exif_blob = NULL; const int exif_len = dt_exif_read_blob(&exif_blob, src_path, imgid, FALSE, w, h, TRUE); uint8_t *jpeg_buf = NULL; int jpeg_len = 0, jpeg_w = 0, jpeg_h = 0; dt_imageio_dng_preview_t preview = {0}; const int prev_rc = _extract_source_jpeg_preview(src_path, &jpeg_buf, &jpeg_len, &jpeg_w, &jpeg_h); if(prev_rc == 0) { preview.data = jpeg_buf; preview.len = jpeg_len; preview.width = jpeg_w; preview.height = jpeg_h; dt_print(DT_DEBUG_AI, "[neural_restore] embedded JPEG preview from source %dx%d (%d bytes)", jpeg_w, jpeg_h, jpeg_len); } else { dt_print(DT_DEBUG_AI, "[neural_restore] no embedded preview in source (rc=%d) — " "writing DNG without thumbnail", prev_rc); } res = dt_imageio_dng_write_linear(out_filename, rgb, w, h, img_meta, exif_blob, exif_len, jpeg_buf ? &preview : NULL); g_free(jpeg_buf); g_free(exif_blob); dt_free_align(rgb); if(res != 0) g_unlink(out_filename); if(res == 0) _mark_neural_restore_output(out_filename); return res; } static int _process_raw_denoise_one(dt_neural_job_t *j, const dt_imgid_t imgid, const char *out_filename, const char *src_path) { // force the raw to be loaded so buf_dsc.{filters,channels,datatype} // are populated. for a fresh session, dt_image_cache_get alone may // return a dt_image_t whose buf_dsc is zeroed because rawspeed has // not been invoked on this id yet dt_mipmap_buffer_t warmup; dt_mipmap_cache_get(&warmup, imgid, DT_MIPMAP_FULL, DT_MIPMAP_BLOCKING, 'r'); const gboolean loaded = (warmup.buf != NULL); dt_mipmap_cache_release(&warmup); if(!loaded) { dt_print(DT_DEBUG_AI, "[neural_restore] mipmap warmup failed for imgid %d", imgid); dt_control_log(_("raw denoise: cannot load source image")); return 1; } const dt_image_t *cached = dt_image_cache_get(imgid, 'r'); if(!cached) return 1; dt_image_t img_meta = *cached; dt_image_cache_read_release(cached); const uint32_t filters = img_meta.buf_dsc.filters; const uint32_t channels = img_meta.buf_dsc.channels; const uint32_t flags = img_meta.flags; const dt_restore_sensor_class_t cls = dt_restore_classify_sensor(&img_meta); const char *cls_name = (cls == DT_RESTORE_SENSOR_CLASS_BAYER) ? "bayer" : (cls == DT_RESTORE_SENSOR_CLASS_XTRANS) ? "x-trans" : (cls == DT_RESTORE_SENSOR_CLASS_LINEAR) ? "linear" : "unsupported"; dt_print(DT_DEBUG_AI, "[neural_restore] imgid %d: flags=0x%x channels=%u " "filters=0x%x (%s)", imgid, flags, channels, filters, cls_name); if(cls == DT_RESTORE_SENSOR_CLASS_UNSUPPORTED) { dt_control_log(_("raw denoise: image is not in a supported raw sensor format")); return 1; } if(_ensure_raw_ctx(j, cls)) return 1; switch(cls) { case DT_RESTORE_SENSOR_CLASS_BAYER: return _process_raw_denoise_bayer(j, imgid, out_filename, src_path, &img_meta); case DT_RESTORE_SENSOR_CLASS_XTRANS: // today: X-Trans runs through the linear pipeline. a future // dedicated xtrans_v1 model with a different input format would // get its own _process_raw_denoise_xtrans() here; the dispatch // structure stays, just the target function swaps return _process_raw_denoise_linear(j, imgid, out_filename, src_path, &img_meta); case DT_RESTORE_SENSOR_CLASS_LINEAR: // most LINEAR-class inputs are computational raws (Apple // ProRAW, Google HDR+, etc.) that have already been denoised // on-device; the model will run but adds little; Canon/Nikon // sRAW is the exception; warn once per job and proceed. // 4BAYER (CYGM/RGBE) piggybacks on LINEAR class but isn't a // computational raw — skip the misleading toast if(!j->linear_warned && !(flags & DT_IMAGE_4BAYER)) { j->linear_warned = TRUE; dt_control_log(_("raw denoise: this image is already demosaiced " "(sRaw / computational raw); results may be limited")); } return _process_raw_denoise_linear(j, imgid, out_filename, src_path, &img_meta); default: return 1; } } static int32_t _process_job_run(dt_job_t *job) { dt_neural_job_t *j = dt_control_job_get_params(job); const char *task_name = (j->task == NEURAL_TASK_DENOISE) ? _("denoise") : (j->task == NEURAL_TASK_RAW_DENOISE) ? _("raw denoise") : _("upscale"); char msg[256]; snprintf(msg, sizeof(msg), _("loading %s model..."), task_name); dt_control_job_set_progress_message(job, msg); j->control_job = job; // steal cached model if available - take a ref for the batch // and release the cache's ref. this prevents the preview thread // from using it concurrently (ORT sessions are not thread-safe // for simultaneous inference). if the user triggers a preview // while batch is running, the preview will load its own session // since cached_ctx is NULL dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)j->self->data; dt_pthread_mutex_lock(&d->ctx_lock); if(d->cached_ctx && d->cached_task == j->task) { j->ctx = dt_restore_ref(d->cached_ctx); dt_restore_unref(d->cached_ctx); d->cached_ctx = NULL; } else { dt_restore_unref(d->cached_ctx); d->cached_ctx = NULL; } dt_pthread_mutex_unlock(&d->ctx_lock); if(!j->ctx) j->ctx = _load_for_task(j->env, j->task); if(!j->ctx) { dt_control_log(_("failed to load AI %s model"), task_name); return 1; } dt_print(DT_DEBUG_AI, "[neural_restore] job started: task=%s, scale=%d, images=%d", task_name, j->scale, g_list_length(j->images)); dt_imageio_module_format_t fmt = { .mime = _ai_get_mime, .levels = _ai_check_levels, .bpp = _ai_check_bpp, .write_image = _ai_write_image}; dt_neural_format_params_t fmt_params = {.job = j}; const int total = g_list_length(j->images); int count = 0; int successes = 0; // images that made it through export (for the completion toast) // one set of variables for the whole batch: $(SEQUENCE) only advances if the // same params survive from image to image dt_variables_params_t *vp = NULL; dt_variables_params_init(&vp); for(GList *iter = j->images; iter; iter = g_list_next(iter)) { if(dt_control_job_get_state(job) == DT_JOB_STATE_CANCELLED) break; dt_imgid_t imgid = GPOINTER_TO_INT(iter->data); char srcpath[PATH_MAX]; dt_image_full_path(imgid, srcpath, sizeof(srcpath), NULL); // the pattern is the whole path bar the extension: nothing is appended to // what it expands to, and nothing stripped vp->filename = srcpath; vp->imgid = imgid; char *expanded = dt_variables_expand_path(vp, (gchar *)j->output_pattern, TRUE); char base[PATH_MAX]; g_strlcpy(base, expanded ? expanded : "", sizeof(base)); g_free(expanded); // a pattern of variables alone can expand to nothing, which would write a // bare extension into the working directory if(!base[0]) { dt_print(DT_DEBUG_AI, "[neural_restore] output pattern expands to nothing for imgid %d", imgid); dt_control_log(_("neural restore: the output pattern expands to nothing")); dt_control_job_set_progress(job, (double)++count / total); continue; } // ensure output directory exists char *out_dir_resolved = g_path_get_dirname(base); if(g_mkdir_with_parents(out_dir_resolved, 0750) != 0) { dt_print(DT_DEBUG_AI, "[neural_restore] failed to create output directory: %s", out_dir_resolved); dt_control_log(_("neural restore: cannot create output directory")); g_free(out_dir_resolved); dt_control_job_set_progress(job, (double)++count / total); continue; } g_free(out_dir_resolved); // raw denoise writes DNG; RGB denoise/upscale writes TIFF const char *ext = (j->task == NEURAL_TASK_RAW_DENOISE) ? "dng" : "tif"; // find unique filename: base., base_1., ... char filename[PATH_MAX]; snprintf(filename, sizeof(filename), "%s.%s", base, ext); // a pattern with no suffix resolves onto the source, and the source is on // disk, so this steps past it like any other collision if(g_file_test(filename, G_FILE_TEST_EXISTS)) { gboolean found = FALSE; for(int s = 1; s < 10000; s++) { snprintf(filename, sizeof(filename), "%s_%d.%s", base, s, ext); if(!g_file_test(filename, G_FILE_TEST_EXISTS)) { found = TRUE; break; } } if(!found) { dt_print(DT_DEBUG_AI, "[neural_restore] could not find unique filename for imgid %d", imgid); dt_control_log(_("neural restore: too many output files")); dt_control_job_set_progress(job, (double)++count / total); continue; } } dt_print(DT_DEBUG_AI, "[neural_restore] processing imgid %d -> %s", imgid, filename); snprintf(msg, sizeof(msg), (j->task == NEURAL_TASK_DENOISE) ? _("denoising image %d/%d...") : (j->task == NEURAL_TASK_RAW_DENOISE) ? _("raw denoising image %d/%d...") : (j->task == NEURAL_TASK_UPSCALE_2X) ? _("upscaling 2x image %d/%d...") : _("upscaling 4x image %d/%d..."), count + 1, total); dt_control_job_set_progress_message(job, msg); int step_err = 0; if(j->task == NEURAL_TASK_RAW_DENOISE) { step_err = _process_raw_denoise_one(j, imgid, filename, srcpath); if(step_err) dt_control_log(_("raw denoise failed for image %d"), imgid); } else { // force LIN_REC2020 – the AI denoise needs linear input; user's // combo choice is applied at TIFF write via LCMS step_err = dt_imageio_export_with_flags( imgid, filename, &fmt, (dt_imageio_module_data_t *)&fmt_params, FALSE, // ignore_exif — pass EXIF to write_image FALSE, // display_byteorder TRUE, // high_quality TRUE, // upscale FALSE, // is_scaling 1.0, // scale_factor FALSE, // thumbnail_export NULL, // filter FALSE, // copy_metadata FALSE, // export_masks DT_COLORSPACE_LIN_REC2020, NULL, DT_INTENT_PERCEPTUAL, NULL, NULL, count, total, NULL, -1); if(step_err) { dt_print(DT_DEBUG_AI, "[neural_restore] export failed for imgid %d", imgid); dt_control_log(_("neural restore: export failed")); } } if(step_err) { dt_control_job_set_progress(job, (double)++count / total); continue; } if(j->add_to_catalog) _import_image(filename, imgid); successes++; dt_control_job_set_progress(job, (double)++count / total); } // srcpath went out of scope with the loop, but destroy never reads vp->filename dt_variables_params_destroy(vp); dt_restore_unref(j->ctx); j->ctx = NULL; // single completion toast, shown in whichever view the user is in; // covers the batch cleanly and avoids the per-image navigation that // DT_SIGNAL_VIEWMANAGER_THUMBTABLE_ACTIVATE would trigger if(successes > 0) dt_control_log(ngettext("neural %s: %d image processed", "neural %s: %d images processed", successes), task_name, successes); _job_finished_data_t *fd = g_new(_job_finished_data_t, 1); fd->self = j->self; fd->images = g_list_copy(j->images); g_idle_add(_job_finished_idle, fd); return 0; } static gboolean _check_model_available(dt_lib_neural_restore_t *d, const dt_neural_task_t task) { return _task_model_available(d->env, task); } // check if any of the currently selected images are already being processed static gboolean _any_selected_processing(dt_lib_neural_restore_t *d) { GList *images = dt_act_on_get_images(TRUE, TRUE, FALSE); gboolean found = FALSE; for(GList *l = images; l; l = g_list_next(l)) { if(g_hash_table_contains(d->processing_images, l->data)) { found = TRUE; break; } } g_list_free(images); return found; } static void _update_button_sensitivity(dt_lib_neural_restore_t *d) { const gboolean has_images = (dt_act_on_get_images_nb(TRUE, FALSE) > 0); const gboolean sensitive = d->model_available && has_images && !_any_selected_processing(d); gtk_widget_set_sensitive(d->process_button, sensitive); // picker only available after preview has been generated gtk_widget_set_sensitive(d->pick_button, d->export_pixels != NULL); } static void _update_info_label(dt_lib_neural_restore_t *d) { d->info_text_left[0] = '\0'; d->info_text_right[0] = '\0'; d->warning_text[0] = '\0'; if(!d->model_available) return; const int scale = _task_scale(d->task); // 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); 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)) { int fw = 0, fh = 0; dt_image_get_final_size(imgid, &fw, &fh); if(fw > 0 && fh > 0) { const int out_w = fw * scale; const int out_h = fh * scale; const double in_mp = (double)fw * fh / 1e6; const double out_mp = (double)out_w * out_h / 1e6; snprintf(d->info_text_left, sizeof(d->info_text_left), "%.0fMP", in_mp); snprintf(d->info_text_right, sizeof(d->info_text_right), "%.0fMP", out_mp); if(out_mp >= LARGE_OUTPUT_MP) snprintf(d->warning_text, sizeof(d->warning_text), "%s", _("large output - processing will be slow")); } } // raw denoise: DNG variant batch will produce. Source of truth is // preview_raw_sensor_class — buf_dsc.filters is zeroed until rawspeed // decodes the image, so probing it here would misclassify an // unloaded X-Trans RAF. both X-Trans and the linear-fallback class // currently write LinearRaw DNG; only Bayer writes CFA DNG if(d->task == NEURAL_TASK_RAW_DENOISE && dt_is_valid_imgid(imgid) && d->preview_raw_imgid == imgid) { const gboolean is_bayer_out = (d->preview_raw_sensor_class == DT_RESTORE_SENSOR_CLASS_BAYER); snprintf(d->info_text_left, sizeof(d->info_text_left), "%s", is_bayer_out ? _("output: Bayer CFA DNG") : _("output: LinearRaw DNG")); } // 4BAYER preview shows no denoise effect (no RGB→CFA mapping) if(d->task == NEURAL_TASK_RAW_DENOISE && dt_is_valid_imgid(imgid)) { const dt_image_t *cached = dt_image_cache_get(imgid, 'r'); if(cached) { if(cached->flags & DT_IMAGE_4BAYER) snprintf(d->warning_text, sizeof(d->warning_text), "%s", _("preview unavailable – process to save result")); dt_image_cache_read_release(cached); } } // 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. not // applicable to raw denoise — that path writes camRGB DNG without // any sRGB wrapper, so there's no gamut clipping to warn about if(d->task != NEURAL_TASK_RAW_DENOISE) { 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 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]) { 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); } static void _trigger_preview(dt_lib_module_t *self); static void _cancel_preview(dt_lib_module_t *self); static void _schedule_preview_failed(dt_lib_module_t *self, dt_nr_preview_err_t err); static void _task_changed(dt_lib_neural_restore_t *d) { _output_pattern_refresh(d); d->model_available = _check_model_available(d, d->task); if(!d->model_available) { d->preview_ready = FALSE; d->preview_generating = FALSE; gtk_widget_queue_draw(d->preview_area); } // restore strength slider from conf when switching to a scale==1 // task (denoise / raw denoise — both benefit from the strength // knob), snap to 100 when switching to upscale (upscale has no // strength semantics — see note below). use _strength_changing // flag to avoid redundant conf writes from the slider's // value-changed callback. // raw denoise has no DWT analysis of exported preview pixels, so // the slider is hidden there — but for DENOISE and UPSCALE we // preserve the master behaviour (slider visible, value restored / // reset). d->recovery_changing = TRUE; if(d->task == NEURAL_TASK_DENOISE || d->task == NEURAL_TASK_RAW_DENOISE) { const float saved = dt_conf_key_exists(CONF_STRENGTH) ? dt_conf_get_float(CONF_STRENGTH) : 100.0f; dt_bauhaus_slider_set(d->recovery_slider, saved); } else { dt_bauhaus_slider_set(d->recovery_slider, 100.0f); } gtk_widget_set_visible(d->recovery_slider, d->task != NEURAL_TASK_RAW_DENOISE); d->recovery_changing = FALSE; // output settings that only apply to the RGB-export-based tasks: // bit depth selects TIFF bits-per-pixel (raw denoise writes DNG), // output ICC profile applies to the exported TIFF, and // preserve-wide-gamut is part of the sRGB-gamma wrapper around the // denoise-nind model. bpp + ICC apply to any TIFF-emitting task // (denoise + upscale); preserve-wide-gamut only matters for denoise // (upscale has no pixel-to-pixel correspondence to pass through). // raw denoise writes a DNG directly from the sensor-space inference // result, so none of these knobs apply to it const gboolean tiff_knobs_visible = (d->task != NEURAL_TASK_RAW_DENOISE); const gboolean wide_gamut_visible = (d->task == NEURAL_TASK_DENOISE); if(d->bpp_combo) gtk_widget_set_visible(d->bpp_combo, tiff_knobs_visible); if(d->compress_combo) gtk_widget_set_visible(d->compress_combo, tiff_knobs_visible); if(d->profile_combo) gtk_widget_set_visible(d->profile_combo, tiff_knobs_visible); if(d->preserve_wide_gamut_toggle) gtk_widget_set_visible(d->preserve_wide_gamut_toggle, wide_gamut_visible); _update_info_label(d); _update_button_sensitivity(d); } // per-task preview cache helpers // // the cache holds, per task, the buffers needed to redisplay the most // recent successful preview without re-running inference. on tab switch // we look up the new task's slot keyed by (imgid, patch_center). on // hit we install the cached buffers and skip the worker; on miss we // schedule one. invalidated wholesale on image / patch change // // for raw denoise, before_rgb / after_rgb hold the unblended source vs // fully-denoised lin_rec2020 crops (= preview_raw_src_rgb / // preview_raw_denoised_rgb). detail is always NULL there // // for RGB denoise + upscale, before_rgb / after_rgb hold the displayed // preview_before / preview_after, and detail (denoise only) holds the // DWT luminance residual used by the strength slider static void _preview_cache_free_slot(dt_lib_neural_restore_t *d, const dt_neural_task_t task) { g_free(d->preview_cache[task].before_rgb); d->preview_cache[task].before_rgb = NULL; g_free(d->preview_cache[task].after_rgb); d->preview_cache[task].after_rgb = NULL; dt_free_align(d->preview_cache[task].detail); d->preview_cache[task].detail = NULL; d->preview_cache[task].valid = FALSE; } static void _preview_cache_invalidate_all(dt_lib_neural_restore_t *d) { for(int t = 0; t < NEURAL_TASK_COUNT; t++) _preview_cache_free_slot(d, t); } static gboolean _preview_cache_hit(dt_lib_neural_restore_t *d, const dt_neural_task_t task, const dt_imgid_t imgid) { if(task >= NEURAL_TASK_COUNT) return FALSE; const __typeof__(d->preview_cache[0]) *e = &d->preview_cache[task]; // exact match on patch_center (no fp tolerance: we store the exact // value the worker received, so equality is reliable) return e->valid && e->imgid == imgid && e->patch_center[0] == d->patch_center[0] && e->patch_center[1] == d->patch_center[1]; } // memcpy buffers into the cache slot for `task`. caller retains // ownership of the source pointers (we duplicate) static void _preview_cache_store(dt_lib_neural_restore_t *d, const dt_neural_task_t task, const dt_imgid_t imgid, const float patch_center[2], const int crop_w, const int crop_h, const float *before, const float *after, const float *detail) { // task is an unsigned enum, no need for < 0 check if(task >= NEURAL_TASK_COUNT) return; if(crop_w <= 0 || crop_h <= 0 || !before || !after) return; _preview_cache_free_slot(d, task); const size_t n3 = (size_t)crop_w * crop_h * 3; d->preview_cache[task].before_rgb = g_try_malloc(n3 * sizeof(float)); d->preview_cache[task].after_rgb = g_try_malloc(n3 * sizeof(float)); if(!d->preview_cache[task].before_rgb || !d->preview_cache[task].after_rgb) { _preview_cache_free_slot(d, task); return; } memcpy(d->preview_cache[task].before_rgb, before, n3 * sizeof(float)); memcpy(d->preview_cache[task].after_rgb, after, n3 * sizeof(float)); if(detail) { const size_t n1 = (size_t)crop_w * crop_h; d->preview_cache[task].detail = dt_alloc_align_float(n1); if(d->preview_cache[task].detail) memcpy(d->preview_cache[task].detail, detail, n1 * sizeof(float)); } d->preview_cache[task].imgid = imgid; d->preview_cache[task].patch_center[0] = patch_center[0]; d->preview_cache[task].patch_center[1] = patch_center[1]; d->preview_cache[task].crop_w = crop_w; d->preview_cache[task].crop_h = crop_h; d->preview_cache[task].valid = TRUE; } // rebuild the "after" cairo surface from cached float buffers, applying // DWT-filtered detail recovery so that slider changes don't re-run inference static void _rebuild_cairo_after(dt_lib_neural_restore_t *d) { if(!d->preview_after || !d->cairo_after) return; const int w = d->preview_w; const int h = d->preview_h; const int stride = d->cairo_stride; // strength = 100 → no recovery (max denoise visible); strength = 0 // → full filtered detail back. preview mirrors batch semantics. const float strength = dt_conf_key_exists(CONF_STRENGTH) ? dt_conf_get_float(CONF_STRENGTH) : 100.0f; const float alpha = 1.0f - strength / 100.0f; const gboolean recover = (alpha > 0.0f && d->preview_detail); for(int y = 0; y < h; y++) { uint32_t *row = (uint32_t *)(d->cairo_after + y * stride); for(int x = 0; x < w; x++) { const int si = (y * w + x) * 3; const int pi = y * w + x; float r = d->preview_after[si + 0]; float g = d->preview_after[si + 1]; float b = d->preview_after[si + 2]; if(recover) { const float detail = alpha * d->preview_detail[pi]; r += detail; g += detail; b += detail; } const uint8_t cr = (uint8_t)(_linear_to_srgb(r) * 255.0f + 0.5f); const uint8_t cg = (uint8_t)(_linear_to_srgb(g) * 255.0f + 0.5f); const uint8_t cb = (uint8_t)(_linear_to_srgb(b) * 255.0f + 0.5f); row[x] = ((uint32_t)cr << 16) | ((uint32_t)cg << 8) | (uint32_t)cb; } } } static gboolean _preview_result_idle(gpointer data) { dt_neural_preview_result_t *res = (dt_neural_preview_result_t *)data; // skip if gui_cleanup ran between schedule and dispatch dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)res->self->data; if(!d) { g_free(res->before); g_free(res->after); g_free(res->export_pixels); g_free(res); return G_SOURCE_REMOVE; } // discard stale results if(res->sequence != g_atomic_int_get(&d->preview_sequence)) { g_free(res->before); g_free(res->after); g_free(res->export_pixels); g_free(res); return G_SOURCE_REMOVE; } g_free(d->preview_before); g_free(d->preview_after); dt_free_align(d->preview_detail); d->preview_before = res->before; d->preview_after = res->after; d->preview_w = res->width; d->preview_h = res->height; // cache the export image for picker re-cropping if(res->export_pixels) { g_free(d->export_pixels); g_free(d->export_cairo); d->export_pixels = res->export_pixels; d->export_w = res->export_w; d->export_h = res->export_h; d->export_cairo = NULL; } // pre-compute DWT-filtered luminance detail for instant slider response d->preview_detail = dt_restore_compute_dwt_detail(res->before, res->after, res->width, res->height); // rebuild cached cairo surface data g_free(d->cairo_before); g_free(d->cairo_after); const int stride = cairo_format_stride_for_width(CAIRO_FORMAT_RGB24, res->width); d->cairo_before = g_malloc(stride * res->height); d->cairo_after = g_malloc(stride * res->height); d->cairo_stride = stride; _float_rgb_to_cairo(d->preview_before, d->cairo_before, res->width, res->height, stride); _rebuild_cairo_after(d); // store this result in the per-task cache so a later tab switch // back to the same task / image / patch skips inference _preview_cache_store(d, res->task, res->imgid, res->patch_center, res->width, res->height, d->preview_before, d->preview_after, d->preview_detail); d->preview_ready = TRUE; d->preview_generating = FALSE; _update_button_sensitivity(d); gtk_widget_queue_draw(d->preview_area); g_free(res); return G_SOURCE_REMOVE; } static gpointer _preview_thread(gpointer data) { dt_neural_preview_data_t *pd = (dt_neural_preview_data_t *)data; dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)pd->self->data; // bail reason if we hit cleanup; stale-sequence bails are dropped dt_nr_preview_err_t err = DT_NR_PREVIEW_ERR_INIT_FAILED; // reuse borrowed export if available (re-pick), otherwise export. // pixels points to either the borrowed buffer (not owned) or // cap.pixels (owned, must be freed on error or passed to result) dt_neural_preview_capture_t cap = {0}; const float *pixels = NULL; int pixels_w = 0, pixels_h = 0; gboolean owns_pixels = FALSE; if(pd->reuse_pixels) { pixels = pd->reuse_pixels; pixels_w = pd->reuse_w; pixels_h = pd->reuse_h; } else { const int export_size = dt_conf_get_int(CONF_PREVIEW_EXPORT_SIZE); cap.parent.max_width = export_size; cap.parent.max_height = export_size; dt_imageio_module_format_t fmt = { .mime = _ai_get_mime, .levels = _ai_check_levels, .bpp = _ai_check_bpp, .write_image = _preview_capture_write_image}; // force LIN_REC2020 – AI denoise needs linear input; converted to // lin_rec709 for cairo display below dt_imageio_export_with_flags(pd->imgid, "unused", &fmt, (dt_imageio_module_data_t *)&cap, TRUE, // ignore_exif FALSE, // display_byteorder TRUE, // high_quality FALSE, // upscale FALSE, // is_scaling 1.0, // scale_factor FALSE, // thumbnail_export NULL, // filter FALSE, // copy_metadata FALSE, // export_masks DT_COLORSPACE_LIN_REC2020, NULL, DT_INTENT_PERCEPTUAL, NULL, NULL, 1, 1, NULL, -1); pixels = cap.pixels; pixels_w = cap.cap_w; pixels_h = cap.cap_h; owns_pixels = TRUE; } if(!pixels || pd->sequence != g_atomic_int_get(&d->preview_sequence)) { if(owns_pixels) g_free(cap.pixels); goto cleanup; } dt_print(DT_DEBUG_AI, "[neural_restore] preview: %s %dx%d, scale=%d, " "export_size=%d", owns_pixels ? "exported" : "reusing", pixels_w, pixels_h, pd->scale, owns_pixels ? cap.parent.max_width : 0); // crop region matching widget aspect ratio // clamp crop to export dimensions and max preview size to keep // inference responsive. the result is scaled to fill the widget const int max_crop = 512; // pw/ph are output dimensions (divisible by scale), // crop_w/crop_h are input dimensions to the model int pw = MIN(MIN(pd->preview_w, pixels_w * pd->scale), max_crop * pd->scale); int ph = MIN(MIN(pd->preview_h, pixels_h * pd->scale), max_crop * pd->scale); pw = (pw / pd->scale) * pd->scale; ph = (ph / pd->scale) * pd->scale; const int crop_w = pw / pd->scale; const int crop_h = ph / pd->scale; if(crop_w <= 0 || crop_h <= 0) { dt_print(DT_DEBUG_AI, "[neural_restore] preview: export too small (%dx%d)", pixels_w, pixels_h); if(owns_pixels) g_free(cap.pixels); goto cleanup; } // compute crop position in export image int crop_x = (int)(pd->patch_center[0] * pixels_w) - crop_w / 2; int crop_y = (int)(pd->patch_center[1] * pixels_h) - crop_h / 2; crop_x = CLAMP(crop_x, 0, pixels_w - crop_w); crop_y = CLAMP(crop_y, 0, pixels_h - crop_h); // extract crop as interleaved RGBx (4ch) for dt_restore_process_tiled float *crop_4ch = g_try_malloc((size_t)crop_w * crop_h * 4 * sizeof(float)); if(!crop_4ch) { if(owns_pixels) g_free(cap.pixels); goto cleanup; } for(int y = 0; y < crop_h; y++) { memcpy(crop_4ch + (size_t)y * crop_w * 4, pixels + ((size_t)(crop_y + y) * pixels_w + crop_x) * 4, (size_t)crop_w * 4 * sizeof(float)); } // extract "before" as interleaved RGB (3ch) for display float *crop_rgb = g_try_malloc((size_t)crop_w * crop_h * 3 * sizeof(float)); if(!crop_rgb) { g_free(crop_4ch); if(owns_pixels) g_free(cap.pixels); goto cleanup; } for(int y = 0; y < crop_h; y++) { for(int x = 0; x < crop_w; x++) { const size_t si = ((size_t)(crop_y + y) * pixels_w + (crop_x + x)) * 4; const size_t di = ((size_t)y * crop_w + x) * 3; crop_rgb[di + 0] = pixels[si + 0]; crop_rgb[di + 1] = pixels[si + 1]; crop_rgb[di + 2] = pixels[si + 2]; } } // check for cancellation before expensive inference if(pd->sequence != g_atomic_int_get(&d->preview_sequence)) { g_free(crop_4ch); g_free(crop_rgb); if(owns_pixels) g_free(cap.pixels); goto cleanup; } // take a ref on the cached model (or load a new one). // hold the lock only for the ref/swap, not during inference dt_restore_context_t *ctx = NULL; dt_pthread_mutex_lock(&d->ctx_lock); if(!d->cached_ctx || d->cached_task != pd->task) { dt_restore_unref(d->cached_ctx); d->cached_ctx = _load_for_task(pd->env, pd->task); d->cached_task = pd->task; } if(d->cached_ctx) ctx = dt_restore_ref(d->cached_ctx); dt_pthread_mutex_unlock(&d->ctx_lock); if(!ctx) { dt_print(DT_DEBUG_AI, "[neural_restore] preview: failed to load model"); g_free(crop_4ch); g_free(crop_rgb); if(owns_pixels) g_free(cap.pixels); goto cleanup; } // use the same tiled processing as batch — this handles mirror // padding, overlap blending, and planar conversion identically. // tile size is fixed at model load time (ctx->tile_size) and shared // across preview and batch so JIT-compiling EPs (MIGraphX, CoreML, // TensorRT) compile the kernel only once per session. float *out_4ch = g_try_malloc((size_t)pw * ph * 4 * sizeof(float)); if(!out_4ch) { g_free(crop_4ch); g_free(crop_rgb); goto cleanup; } dt_print(DT_DEBUG_AI, "[neural_restore] preview: tiled inference %dx%d", crop_w, crop_h); const gboolean pres = dt_conf_key_exists(CONF_PRESERVE_WIDE_GAMUT) ? dt_conf_get_bool(CONF_PRESERVE_WIDE_GAMUT) : TRUE; _setup_restore_for_linear_input(ctx, pres); _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, _buf_row_writer, &bwd, NULL); g_free(crop_4ch); dt_restore_unref(ctx); if(ret != 0) { dt_print(DT_DEBUG_AI, "[neural_restore] preview: inference failed"); g_free(out_4ch); g_free(crop_rgb); goto cleanup; } // build "before" buffer: pw × ph interleaved RGB float *before_buf = NULL; if(pd->scale > 1) { before_buf = g_malloc((size_t)pw * ph * 3 * sizeof(float)); _nn_upscale(crop_rgb, crop_w, crop_h, before_buf, pw, ph); g_free(crop_rgb); } else { before_buf = crop_rgb; } // build "after" buffer: extract RGB from RGBx output float *after_buf = g_malloc((size_t)pw * ph * 3 * sizeof(float)); for(int y = 0; y < ph; y++) { for(int x = 0; x < pw; x++) { const size_t si = ((size_t)y * pw + x) * 4; const size_t di = ((size_t)y * pw + x) * 3; after_buf[di + 0] = out_4ch[si + 0]; after_buf[di + 1] = out_4ch[si + 1]; after_buf[di + 2] = out_4ch[si + 2]; } } g_free(out_4ch); // convert LIN_REC2020 → linear sRGB for cairo // (_float_rgb_to_cairo applies sRGB gamma, assumes sRGB primaries) cmsHTRANSFORM xform_disp = _build_output_color_transform(DT_COLORSPACE_LIN_REC709, ""); if(xform_disp) { cmsDoTransform(xform_disp, before_buf, before_buf, (size_t)pw * ph); cmsDoTransform(xform_disp, after_buf, after_buf, (size_t)pw * ph); cmsDeleteTransform(xform_disp); } // deliver result to main thread dt_neural_preview_result_t *result = g_new(dt_neural_preview_result_t, 1); result->self = pd->self; result->before = before_buf; result->after = after_buf; // only pass export pixels on fresh export (owned by cap). // on reuse, the main thread already has them cached result->export_pixels = owns_pixels ? cap.pixels : NULL; result->export_w = pixels_w; result->export_h = pixels_h; result->sequence = pd->sequence; result->width = pw; result->height = ph; result->task = pd->task; result->imgid = pd->imgid; result->patch_center[0] = pd->patch_center[0]; result->patch_center[1] = pd->patch_center[1]; g_idle_add(_preview_result_idle, result); g_free(pd->reuse_pixels); g_free(pd); return NULL; cleanup: // bail: clear preview_generating on UI thread (stale-sequence bails dropped) if(pd->sequence == g_atomic_int_get(&d->preview_sequence)) _schedule_preview_failed(pd->self, err); g_free(pd->reuse_pixels); g_free(pd); return NULL; } static void _cancel_preview(dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; d->preview_ready = FALSE; d->preview_generating = FALSE; // bump sequence so any in-flight worker (and its idle callback) // discards its result. we DO NOT join here — that would block the // UI for the full duration of a running inference. the worker // keeps running in the background and exits silently. the barrier // below (take + release the inference lock, which the worker holds // for its whole run) is kept as a conservative serialisation point // on image change; its original reason — waiting before freeing // export_pixels — no longer applies now that workers copy those g_atomic_int_inc(&d->preview_sequence); if(d->preview_trigger_timer) { g_source_remove(d->preview_trigger_timer); d->preview_trigger_timer = 0; } if(d->preview_thread) { g_mutex_lock(&d->preview_inference_lock); g_mutex_unlock(&d->preview_inference_lock); g_thread_unref(d->preview_thread); d->preview_thread = NULL; } // invalidate cached export (image changed) g_free(d->export_pixels); d->export_pixels = NULL; g_free(d->export_cairo); d->export_cairo = NULL; // raw-denoise crops — strength slider would otherwise reblend stale g_free(d->preview_raw_src_rgb); d->preview_raw_src_rgb = NULL; g_free(d->preview_raw_denoised_rgb); d->preview_raw_denoised_rgb = NULL; d->picking_thumbnail = FALSE; gtk_widget_queue_draw(d->preview_area); } // ============================================================================ // raw denoise preview path. parallel to the export-based preview above: // * pixel source: full-resolution CFA (Bayer) or demosaicked lin_rec2020 // (X-Trans / linear), cached per image so re-picks reuse it // * inference: dt_restore_raw_{bayer,linear}_preview returns 3ch // source + 3ch denoised crops (both lin_rec2020) for the displayed // region, using one fixed-size tile that matches the JIT-compiled // batch session // * strength slider: re-blends the cached src/denoised on the UI thread // (microseconds) without touching the model — debounced to 50 ms so // fast drags don't queue up redraws // ============================================================================ #define RAW_PREVIEW_STRENGTH_DEBOUNCE_MS 50 typedef struct dt_neural_preview_result_raw_t { dt_lib_module_t *self; float *src_rgb; // crop_w * crop_h * 3, lin_rec2020 float *denoised_rgb; // same dims, lin_rec2020 (gain-matched) int width; int height; int sequence; // optional: full-image buffers to install into the cache (NULL when // the worker reused an already-cached buffer for this image). // ownership transfers to d on idle install float *take_full_cfa; // Bayer; allocated with g_malloc float *take_full_lin; // X-Trans/linear; allocated with dt_alloc_align int full_w; int full_h; dt_imgid_t full_imgid; dt_restore_sensor_class_t full_sensor_class; // optional: picker thumbnail (4ch interleaved float) produced via // dt_imageio_export_with_flags. matches whatever the user sees in // darkroom — identical colours to the preview's before/after. NULL // when we reused an already-cached export_pixels on d. float *take_export_pixels; int export_thumb_w; int export_thumb_h; // cache key (see comment on dt_neural_preview_result_t) float patch_center[2]; } dt_neural_preview_result_raw_t; // blend cached src + denoised crops at the given strength, write into // preview_before/after (allocating fresh buffers), rebuild cairo, and // queue a redraw. UI thread only. static void _blend_raw_into_preview(dt_lib_neural_restore_t *d, float strength) { if(!d->preview_raw_src_rgb || !d->preview_raw_denoised_rgb) return; const int w = d->preview_raw_crop_w; const int h = d->preview_raw_crop_h; if(w <= 0 || h <= 0) return; if(strength < 0.0f) strength = 0.0f; if(strength > 1.0f) strength = 1.0f; const float a = strength; const float ia = 1.0f - strength; const size_t n3 = (size_t)w * h * 3; // preview_before stays at the source (split widget shows pre-denoise // on one side, blended-strength on the other) g_free(d->preview_before); d->preview_before = g_malloc(n3 * sizeof(float)); memcpy(d->preview_before, d->preview_raw_src_rgb, n3 * sizeof(float)); // preview_after = α · denoised + (1-α) · source, per channel g_free(d->preview_after); d->preview_after = g_malloc(n3 * sizeof(float)); for(size_t i = 0; i < n3; i++) d->preview_after[i] = a * d->preview_raw_denoised_rgb[i] + ia * d->preview_raw_src_rgb[i]; // detail-recovery DWT does not apply to raw (different pipeline // position; would need its own analysis pass). leave NULL so // _rebuild_cairo_after takes the no-recovery branch. dt_free_align(d->preview_detail); d->preview_detail = NULL; d->preview_w = w; d->preview_h = h; // rebuild cached cairo surfaces g_free(d->cairo_before); g_free(d->cairo_after); const int stride = cairo_format_stride_for_width(CAIRO_FORMAT_RGB24, w); d->cairo_before = g_malloc(stride * h); d->cairo_after = g_malloc(stride * h); d->cairo_stride = stride; _float_rgb_to_cairo(d->preview_before, d->cairo_before, w, h, stride); _rebuild_cairo_after(d); d->preview_ready = TRUE; gtk_widget_queue_draw(d->preview_area); } // install a cached preview slot into the active preview buffers and // rebuild cairo so the widget displays it. dispatches by task: raw // denoise needs to repopulate preview_raw_src/denoised_rgb and re-blend // at the current strength; RGB denoise / upscale just install // preview_before/after/detail and rebuild the after surface static void _install_cache_slot_raw(dt_lib_module_t *self, const dt_neural_task_t task) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; const __typeof__(d->preview_cache[0]) *e = &d->preview_cache[task]; if(!e->valid) return; const size_t n3 = (size_t)e->crop_w * e->crop_h * 3; g_free(d->preview_raw_src_rgb); g_free(d->preview_raw_denoised_rgb); d->preview_raw_src_rgb = g_malloc(n3 * sizeof(float)); d->preview_raw_denoised_rgb = g_malloc(n3 * sizeof(float)); memcpy(d->preview_raw_src_rgb, e->before_rgb, n3 * sizeof(float)); memcpy(d->preview_raw_denoised_rgb, e->after_rgb, n3 * sizeof(float)); d->preview_raw_crop_w = e->crop_w; d->preview_raw_crop_h = e->crop_h; const float strength = dt_bauhaus_slider_get(d->raw_strength_slider) / 100.0f; _blend_raw_into_preview(d, strength); } static void _install_cache_slot_rgb(dt_lib_module_t *self, const dt_neural_task_t task) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; const __typeof__(d->preview_cache[0]) *e = &d->preview_cache[task]; if(!e->valid) return; const size_t n3 = (size_t)e->crop_w * e->crop_h * 3; g_free(d->preview_before); g_free(d->preview_after); dt_free_align(d->preview_detail); d->preview_before = g_malloc(n3 * sizeof(float)); d->preview_after = g_malloc(n3 * sizeof(float)); memcpy(d->preview_before, e->before_rgb, n3 * sizeof(float)); memcpy(d->preview_after, e->after_rgb, n3 * sizeof(float)); d->preview_detail = NULL; if(e->detail) { const size_t n1 = (size_t)e->crop_w * e->crop_h; d->preview_detail = dt_alloc_align_float(n1); if(d->preview_detail) memcpy(d->preview_detail, e->detail, n1 * sizeof(float)); } d->preview_w = e->crop_w; d->preview_h = e->crop_h; // rebuild cairo surfaces g_free(d->cairo_before); g_free(d->cairo_after); const int stride = cairo_format_stride_for_width(CAIRO_FORMAT_RGB24, e->crop_w); d->cairo_before = g_malloc(stride * e->crop_h); d->cairo_after = g_malloc(stride * e->crop_h); d->cairo_stride = stride; _float_rgb_to_cairo(d->preview_before, d->cairo_before, e->crop_w, e->crop_h, stride); _rebuild_cairo_after(d); d->preview_ready = TRUE; gtk_widget_queue_draw(d->preview_area); } static void _install_cache_slot(dt_lib_module_t *self, const dt_neural_task_t task) { if(task == NEURAL_TASK_RAW_DENOISE) _install_cache_slot_raw(self, task); else _install_cache_slot_rgb(self, task); } // debounced strength-slider re-blend. returns G_SOURCE_REMOVE so the // timer fires once. static gboolean _strength_blend_timer_cb(gpointer data) { dt_lib_module_t *self = (dt_lib_module_t *)data; dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; d->preview_strength_timer = 0; if(d->task != NEURAL_TASK_RAW_DENOISE) return G_SOURCE_REMOVE; if(!d->preview_raw_src_rgb || !d->preview_raw_denoised_rgb) return G_SOURCE_REMOVE; const float strength = dt_bauhaus_slider_get(d->raw_strength_slider) / 100.0f; _blend_raw_into_preview(d, strength); return G_SOURCE_REMOVE; } static void _schedule_raw_strength_reblend(dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(d->preview_strength_timer) g_source_remove(d->preview_strength_timer); d->preview_strength_timer = g_timeout_add(RAW_PREVIEW_STRENGTH_DEBOUNCE_MS, _strength_blend_timer_cb, self); } // fired when a preview worker bails: clears preview_generating and // records the bail reason so the placeholder shows it typedef struct { dt_lib_module_t *self; dt_nr_preview_err_t err; } _preview_failed_data_t; static gboolean _preview_failed_idle(gpointer data) { _preview_failed_data_t *fd = (_preview_failed_data_t *)data; // skip if gui_cleanup ran between schedule and dispatch dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)fd->self->data; if(!d) { g_free(fd); return G_SOURCE_REMOVE; } d->preview_generating = FALSE; d->preview_error = fd->err; _update_button_sensitivity(d); gtk_widget_queue_draw(d->preview_area); g_free(fd); return G_SOURCE_REMOVE; } static void _schedule_preview_failed(dt_lib_module_t *self, const dt_nr_preview_err_t err) { _preview_failed_data_t *fd = g_new0(_preview_failed_data_t, 1); fd->self = self; fd->err = err; g_idle_add(_preview_failed_idle, fd); } static gboolean _preview_raw_result_idle(gpointer data) { dt_neural_preview_result_raw_t *res = (dt_neural_preview_result_raw_t *)data; // skip if gui_cleanup ran between schedule and dispatch dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)res->self->data; if(!d) { g_free(res->src_rgb); g_free(res->denoised_rgb); g_free(res->take_full_cfa); dt_free_align(res->take_full_lin); g_free(res->take_export_pixels); g_free(res); return G_SOURCE_REMOVE; } // discard stale results if(res->sequence != g_atomic_int_get(&d->preview_sequence)) { g_free(res->src_rgb); g_free(res->denoised_rgb); g_free(res->take_full_cfa); dt_free_align(res->take_full_lin); g_free(res->take_export_pixels); g_free(res); return G_SOURCE_REMOVE; } // install the per-image full buffer if the worker freshly loaded one. // also (re)build the patch-picker thumbnail (export_pixels) from it — // the raw path doesn't run a pipeline export so the picker needs us // to synthesise a whole-image 4ch RGBA buffer. gboolean refresh_thumbnail = FALSE; if(res->take_full_cfa) { g_free(d->preview_full_cfa); d->preview_full_cfa = res->take_full_cfa; d->preview_full_w = res->full_w; d->preview_full_h = res->full_h; d->preview_raw_imgid = res->full_imgid; d->preview_raw_sensor_class = res->full_sensor_class; // free the other variant's cache (we switched sensor type) dt_free_align(d->preview_full_lin); d->preview_full_lin = NULL; refresh_thumbnail = TRUE; } if(res->take_full_lin) { dt_free_align(d->preview_full_lin); d->preview_full_lin = res->take_full_lin; d->preview_lin_w = res->full_w; d->preview_lin_h = res->full_h; d->preview_raw_imgid = res->full_imgid; d->preview_raw_sensor_class = res->full_sensor_class; g_free(d->preview_full_cfa); d->preview_full_cfa = NULL; refresh_thumbnail = TRUE; } // install the picker thumbnail when the worker produced a fresh // export (triggered by new imgid / sensor-type change). matches // exactly what the user sees in darkroom — same pipeline the RGB // denoise preview uses for its picker thumbnail. if(res->take_export_pixels) { g_free(d->export_pixels); g_free(d->export_cairo); d->export_pixels = res->take_export_pixels; d->export_w = res->export_thumb_w; d->export_h = res->export_thumb_h; d->export_cairo = NULL; // rebuilt on demand by picker res->take_export_pixels = NULL; } (void)refresh_thumbnail; // legacy flag; export is handled above // install per-refresh inference output g_free(d->preview_raw_src_rgb); g_free(d->preview_raw_denoised_rgb); d->preview_raw_src_rgb = res->src_rgb; d->preview_raw_denoised_rgb = res->denoised_rgb; d->preview_raw_crop_w = res->width; d->preview_raw_crop_h = res->height; const float strength = dt_bauhaus_slider_get(d->raw_strength_slider) / 100.0f; _blend_raw_into_preview(d, strength); // preview_raw_sensor_class is now authoritative for this imgid; // refresh the overlay so it shows the correct DNG output format _update_info_label(d); // store unblended source / denoised in the cache. raw never has DWT // detail; the strength slider blends src ↔ denoised on the fly via // _blend_raw_into_preview, so the cache only needs the two anchors _preview_cache_store(d, NEURAL_TASK_RAW_DENOISE, res->full_imgid, res->patch_center, res->width, res->height, d->preview_raw_src_rgb, d->preview_raw_denoised_rgb, NULL); d->preview_generating = FALSE; _update_button_sensitivity(d); g_free(res); return G_SOURCE_REMOVE; } static gpointer _preview_thread_raw(gpointer data) { dt_neural_preview_data_t *pd = (dt_neural_preview_data_t *)data; dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)pd->self->data; // bail reason for cleanup path; unsupported-sensor branch overrides dt_nr_preview_err_t bail_err = DT_NR_PREVIEW_ERR_INIT_FAILED; // declared up here so every `goto cleanup` below can free it: the // export is expensive to redo and large enough to matter float *take_export_pixels = NULL; // 1. load source image metadata to determine sensor type. // on a fresh session dt_image_cache_get returns img_meta with a // zeroed buf_dsc until rawspeed has been invoked on this id. the // batch path (_process_raw_denoise_one) does this same warmup // BEFORE reading metadata for the same reason. if(pd->sequence != g_atomic_int_get(&d->preview_sequence)) goto cleanup; dt_mipmap_buffer_t warmup; dt_mipmap_cache_get(&warmup, pd->imgid, DT_MIPMAP_FULL, DT_MIPMAP_BLOCKING, 'r'); const gboolean warm_loaded = (warmup.buf != NULL); dt_mipmap_cache_release(&warmup); if(!warm_loaded) { dt_print(DT_DEBUG_AI, "[neural_restore] raw preview: mipmap warmup failed for imgid %d", pd->imgid); goto cleanup; } const dt_image_t *cached = dt_image_cache_get(pd->imgid, 'r'); if(!cached) goto cleanup; dt_image_t img_meta = *cached; dt_image_cache_read_release(cached); const uint32_t filters = img_meta.buf_dsc.filters; const dt_restore_sensor_class_t cls = dt_restore_classify_sensor(&img_meta); // today x-trans piggybacks on the linear pipeline because we don't // ship a dedicated x-trans denoise model yet. when one lands, drop // XTRANS from use_linear and add an x-trans branch alongside the // use_linear and bayer paths (buffer fetch, preview pipe, max_disp) const gboolean use_linear = (cls == DT_RESTORE_SENSOR_CLASS_XTRANS || cls == DT_RESTORE_SENSOR_CLASS_LINEAR); if(cls != DT_RESTORE_SENSOR_CLASS_BAYER && !use_linear) { dt_print(DT_DEBUG_AI, "[neural_restore] raw preview: imgid %d unsupported " "(filters=0x%x class=%d)", pd->imgid, filters, cls); bail_err = DT_NR_PREVIEW_ERR_UNSUPPORTED; goto cleanup; } const char *cls_name = (cls == DT_RESTORE_SENSOR_CLASS_XTRANS) ? "x-trans" : use_linear ? "linear" : "bayer"; dt_print(DT_DEBUG_AI, "[neural_restore] raw preview: imgid=%d %s patch=(%.3f,%.3f) " "widget=%dx%d filters=0x%x", pd->imgid, cls_name, pd->patch_center[0], pd->patch_center[1], pd->preview_w, pd->preview_h, filters); // same caveat as the batch path: LINEAR is usually a computational // raw that's already been denoised on-device. warn once per imgid // so picking a different patch doesn't re-toast. 4BAYER piggybacks // on LINEAR class but isn't computational — skip the toast for it if(cls == DT_RESTORE_SENSOR_CLASS_LINEAR && !(img_meta.flags & DT_IMAGE_4BAYER) && d->preview_linear_warned_imgid != pd->imgid) { d->preview_linear_warned_imgid = pd->imgid; dt_control_log(_("raw denoise: this image is already demosaiced " "(sRaw / computational raw); results may be limited")); } // 2. ensure the right ctx is loaded (matches batch logic in // _ensure_raw_ctx). reload if cached_task is wrong or if the // cached sensor class doesn't match this image. dt_restore_context_t *ctx = NULL; dt_pthread_mutex_lock(&d->ctx_lock); { const gboolean cached_is_raw_correct = d->cached_ctx && d->cached_task == NEURAL_TASK_RAW_DENOISE && (cls == d->preview_raw_sensor_class); if(!cached_is_raw_correct) { dt_restore_unref(d->cached_ctx); switch(cls) { case DT_RESTORE_SENSOR_CLASS_BAYER: d->cached_ctx = dt_restore_load_rawdenoise_bayer(pd->env); break; case DT_RESTORE_SENSOR_CLASS_XTRANS: d->cached_ctx = dt_restore_load_rawdenoise_xtrans(pd->env); break; case DT_RESTORE_SENSOR_CLASS_LINEAR: d->cached_ctx = dt_restore_load_rawdenoise_linear(pd->env); break; default: d->cached_ctx = NULL; break; } d->cached_task = NEURAL_TASK_RAW_DENOISE; // mark cached sensor class so a follow-up preview matches; this // does NOT update preview_raw_imgid because we may not have a // fresh full-image buffer for this image yet d->preview_raw_sensor_class = cls; } if(d->cached_ctx) ctx = dt_restore_ref(d->cached_ctx); } dt_pthread_mutex_unlock(&d->ctx_lock); if(!ctx) { dt_print(DT_DEBUG_AI, "[neural_restore] raw preview: failed to load model"); goto cleanup; } if(pd->sequence != g_atomic_int_get(&d->preview_sequence)) { dt_restore_unref(ctx); goto cleanup; } // 3. acquire the full-image buffer. reuse cache if it matches imgid + // sensor type; otherwise load fresh and stage into result for the // UI to install on idle. float *take_full_cfa = NULL; float *take_full_lin = NULL; int full_w = 0, full_h = 0; const float *full_cfa_use = NULL; // borrowed pointer (cache or take_*) const float *full_lin_use = NULL; const gboolean cache_matches = d->preview_raw_imgid == pd->imgid && d->preview_raw_sensor_class == cls && ((use_linear && d->preview_full_lin) || (!use_linear && d->preview_full_cfa)); if(cache_matches) { if(use_linear) { full_lin_use = d->preview_full_lin; full_w = d->preview_lin_w; full_h = d->preview_lin_h; } else { full_cfa_use = d->preview_full_cfa; full_w = d->preview_full_w; full_h = d->preview_full_h; } } else if(use_linear) { if(dt_restore_raw_linear_prepare(ctx, pd->imgid, &take_full_lin, &full_w, &full_h) != 0 || !take_full_lin) { dt_restore_unref(ctx); goto cleanup; } full_lin_use = take_full_lin; } else { // Bayer: read CFA from mipmap cache dt_mipmap_buffer_t mbuf; dt_mipmap_cache_get(&mbuf, pd->imgid, DT_MIPMAP_FULL, DT_MIPMAP_BLOCKING, 'r'); if(!mbuf.buf) { dt_mipmap_cache_release(&mbuf); dt_restore_unref(ctx); goto cleanup; } full_w = img_meta.width; full_h = img_meta.height; const size_t npix = (size_t)full_w * full_h; take_full_cfa = g_try_malloc(npix * sizeof(float)); if(!take_full_cfa) { dt_mipmap_cache_release(&mbuf); dt_restore_unref(ctx); goto cleanup; } if(img_meta.buf_dsc.datatype == TYPE_UINT16) { const uint16_t *src = (const uint16_t *)mbuf.buf; for(size_t i = 0; i < npix; i++) take_full_cfa[i] = (float)src[i]; } else if(img_meta.buf_dsc.datatype == TYPE_FLOAT) { memcpy(take_full_cfa, mbuf.buf, npix * sizeof(float)); } else { dt_mipmap_cache_release(&mbuf); g_free(take_full_cfa); dt_restore_unref(ctx); goto cleanup; } dt_mipmap_cache_release(&mbuf); full_cfa_use = take_full_cfa; } if(pd->sequence != g_atomic_int_get(&d->preview_sequence)) { g_free(take_full_cfa); dt_free_align(take_full_lin); dt_restore_unref(ctx); goto cleanup; } // 3.5. refresh the picker-thumbnail export when we loaded a fresh // full buffer (i.e. imgid / sensor-type changed). match the RGB // preview path: dt_imageio_export_with_flags runs the user's // full pipeline at ~1024 long edge, giving a display-accurate // thumbnail whose colours match what the user sees in darkroom // (and match our before/after ROI pipe outputs). int export_thumb_w = 0; int export_thumb_h = 0; // NB: also gated on the thumbnail itself. _cancel_preview frees // export_pixels but leaves preview_full_cfa alone, so keying this on // cache_matches alone left the picker (which tests export_pixels) // insensitive until restart — issue #21879. the flag is a snapshot // taken on the main thread at launch, so this never reads // d->export_pixels from the worker if(!cache_matches || !pd->have_export) { dt_neural_preview_capture_t cap = {0}; const int export_size = dt_conf_get_int(CONF_PREVIEW_EXPORT_SIZE); cap.parent.max_width = export_size; cap.parent.max_height = export_size; dt_imageio_module_format_t fmt = { .mime = _ai_get_mime, .levels = _ai_check_levels, .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; const dt_colorspaces_color_profile_t *work_cp = dt_colorspaces_get_work_profile(pd->imgid); const dt_colorspaces_color_profile_type_t dst_type = (cfg_type == DT_COLORSPACE_NONE) ? (work_cp ? work_cp->type : DT_COLORSPACE_LIN_REC2020) : cfg_type; dt_imageio_export_with_flags( pd->imgid, "unused", &fmt, (dt_imageio_module_data_t *)&cap, TRUE, // ignore_exif FALSE, // display_byteorder TRUE, // high_quality FALSE, // upscale FALSE, // is_scaling 1.0, // scale_factor FALSE, // thumbnail_export NULL, // filter FALSE, // copy_metadata FALSE, // export_masks dst_type, cfg_file, DT_INTENT_PERCEPTUAL, NULL, NULL, 1, 1, NULL, -1); g_free(cfg_file); if(cap.pixels && cap.cap_w > 0 && cap.cap_h > 0) { take_export_pixels = cap.pixels; export_thumb_w = cap.cap_w; export_thumb_h = cap.cap_h; } } // 4. compute crop region. widget dims define the "100% preview" size, // capped by the model's compiled tile size minus mandatory overlap. const int T = dt_restore_get_tile_size(ctx); // Bayer model upscales 2x; linear is 1:1. so the maximum displayed // crop in sensor pixels: // bayer: 2*T - 4*overlap_packed = 2*T - 128 (for OVERLAP_PACKED=32) // linear: T - 2*overlap_linear = T - 64 (for OVERLAP_LINEAR=32) const int max_disp = use_linear ? (T - 64) : (2 * T - 128); // the raw buffer is always landscape (sensor layout), but the preview // thumbnail the user clicks on is oriented per EXIF. un-rotate the // widget dims + click position into sensor coords before picking the // crop, otherwise portrait images end up sampling the wrong area const dt_image_orientation_t ori = dt_image_orientation(&img_meta); const gboolean swap_xy = (ori & ORIENTATION_SWAP_XY) != 0; int crop_w = MIN(swap_xy ? pd->preview_h : pd->preview_w, max_disp); int crop_h = MIN(swap_xy ? pd->preview_w : pd->preview_h, max_disp); // Bayer: snap to mod 2 (CFA grid) if(!use_linear) { crop_w = (crop_w / 2) * 2; crop_h = (crop_h / 2) * 2; } if(crop_w <= 0 || crop_h <= 0) { g_free(take_full_cfa); dt_free_align(take_full_lin); dt_restore_unref(ctx); goto cleanup; } // display-normalised click (u, v) -> sensor patch. the picker // normalises against the exported thumbnail — the fully processed // image — so the whole geometry chain has to be inverted, not just // the flip iop (issue #21879). the helper also validates the rect // against the forward transform, so a pick near a cropped border // retreats inward instead of rendering nothing. // // it answers in raw sensor coords. the Bayer buffer is the raw // sensor, but the linear one is the post-rawprepare visible area, // so shift into whichever space full_w / full_h are const int buf_off_x = use_linear ? img_meta.crop_x : 0; const int buf_off_y = use_linear ? img_meta.crop_y : 0; int crop_x = 0, crop_y = 0; if(dt_restore_display_to_sensor(pd->imgid, img_meta.width, img_meta.height, pd->patch_center[0], pd->patch_center[1], crop_w, crop_h, use_linear ? 1 : 2, &crop_x, &crop_y) == 0) { crop_x -= buf_off_x; crop_y -= buf_off_y; } else { // no geometry chain: fall back to orientation-only, correct for // an uncropped image and better than failing outright dt_print(DT_DEBUG_AI, "[neural_restore] raw preview: geometry backtransform " "failed, falling back to orientation-only mapping"); const int disp_w = swap_xy ? full_h : full_w; const int disp_h = swap_xy ? full_w : full_h; const float dx_disp = pd->patch_center[0] * disp_w; const float dy_disp = pd->patch_center[1] * disp_h; float sx, sy; if(swap_xy) { sx = dy_disp; sy = dx_disp; } else { sx = dx_disp; sy = dy_disp; } if(ori & ORIENTATION_FLIP_X) sx = (float)full_w - sx; if(ori & ORIENTATION_FLIP_Y) sy = (float)full_h - sy; crop_x = (int)sx - crop_w / 2; crop_y = (int)sy - crop_h / 2; } crop_x = CLAMP(crop_x, 0, full_w - crop_w); crop_y = CLAMP(crop_y, 0, full_h - crop_h); if(!use_linear) { crop_x = (crop_x / 2) * 2; crop_y = (crop_y / 2) * 2; } dt_print(DT_DEBUG_AI, "[neural_restore] raw preview: full=%dx%d ori=0x%x " "patch_center=(%.3f,%.3f) -> sensor=(%d,%d %dx%d) %s", full_w, full_h, (unsigned)ori, pd->patch_center[0], pd->patch_center[1], crop_x, crop_y, crop_w, crop_h, cls_name); // 5. inference // Bayer path uses the _piped variant which runs darktable's full // pixelpipe on both the original CFA and a denoised-patched CFA, so // "before"/"after" match what the user would see after Process + // re-import (same history stack, same filmic/tone curve, same output // profile). Slower (~2-5 s for two pipes) but colour-accurate. // Linear path still uses the simpler in-space blend for now. float *src_rgb = NULL; float *denoised_rgb = NULL; int actual_w = 0, actual_h = 0; int err; if(use_linear) err = dt_restore_raw_linear_preview_piped(ctx, &img_meta, pd->imgid, full_lin_use, full_w, full_h, crop_x, crop_y, crop_w, crop_h, &src_rgb, &denoised_rgb, &actual_w, &actual_h); else err = dt_restore_raw_bayer_preview_piped(ctx, &img_meta, pd->imgid, full_cfa_use, full_w, full_h, crop_x, crop_y, crop_w, crop_h, &src_rgb, &denoised_rgb, &actual_w, &actual_h); dt_restore_unref(ctx); dt_print(DT_DEBUG_AI, "[neural_restore] raw preview: inference returned err=%d " "src=%p denoised=%p requested=%dx%d actual=%dx%d", err, (void *)src_rgb, (void *)denoised_rgb, crop_w, crop_h, actual_w, actual_h); if(err || !src_rgb || !denoised_rgb || actual_w <= 0 || actual_h <= 0) { g_free(src_rgb); g_free(denoised_rgb); g_free(take_full_cfa); dt_free_align(take_full_lin); goto cleanup; } // 6. ship to UI thread. width/height carry the ACTUAL rendered dims // from the pipe, which can be smaller than crop_w/crop_h when the // user's history includes geometry-modifying modules (clipping, // ashift, lens). downstream blend + cairo render must use these. dt_neural_preview_result_raw_t *res = g_new0(dt_neural_preview_result_raw_t, 1); res->self = pd->self; res->src_rgb = src_rgb; res->denoised_rgb = denoised_rgb; res->width = actual_w; res->height = actual_h; res->sequence = pd->sequence; res->take_full_cfa = take_full_cfa; res->take_full_lin = take_full_lin; res->full_w = full_w; res->full_h = full_h; res->full_imgid = pd->imgid; res->full_sensor_class = cls; res->patch_center[0] = pd->patch_center[0]; res->patch_center[1] = pd->patch_center[1]; res->take_export_pixels = take_export_pixels; res->export_thumb_w = export_thumb_w; res->export_thumb_h = export_thumb_h; g_idle_add(_preview_raw_result_idle, res); g_free(pd->reuse_pixels); g_free(pd); return NULL; cleanup: // bail: clear preview_generating on UI thread (stale-sequence bails dropped) g_free(take_export_pixels); g_free(pd->reuse_pixels); if(pd->sequence == g_atomic_int_get(&d->preview_sequence)) _schedule_preview_failed(pd->self, bail_err); g_free(pd); return NULL; } // thread dispatcher: serialises the actual inference / pipe work via // preview_inference_lock so that even when an old worker is still // running, the new one queues up rather than fighting for the GPU. // also re-checks the sequence after acquiring the lock — if the // trigger that spawned us has already been superseded while we were // waiting, drop on the floor without doing anything expensive static gpointer _preview_thread(gpointer data); static gpointer _preview_thread_raw(gpointer data); static gpointer _preview_thread_dispatch(gpointer data) { dt_neural_preview_data_t *pd = (dt_neural_preview_data_t *)data; dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)pd->self->data; g_mutex_lock(&d->preview_inference_lock); if(pd->sequence != g_atomic_int_get(&d->preview_sequence)) { g_mutex_unlock(&d->preview_inference_lock); g_free(pd->reuse_pixels); g_free(pd); return NULL; } gpointer res = (pd->task == NEURAL_TASK_RAW_DENOISE) ? _preview_thread_raw(data) : _preview_thread(data); g_mutex_unlock(&d->preview_inference_lock); return res; } // debounced trigger: rapid tab switches collapse to one preview run. // the timer handle in d->preview_trigger_timer is replaced (and the // previous one removed) so the trigger only fires after the user // settles on a tab for `delay_ms` static gboolean _trigger_preview_from_timer(gpointer user_data); static void _trigger_preview(dt_lib_module_t *self); static void _schedule_preview_refresh(dt_lib_module_t *self, const guint delay_ms) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(!d->model_available || !d->preview_requested) return; if(d->preview_trigger_timer) g_source_remove(d->preview_trigger_timer); d->preview_trigger_timer = g_timeout_add(delay_ms, _trigger_preview_from_timer, self); } static gboolean _trigger_preview_from_timer(gpointer user_data) { dt_lib_module_t *self = (dt_lib_module_t *)user_data; dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; d->preview_trigger_timer = 0; _trigger_preview(self); return G_SOURCE_REMOVE; } static void _trigger_preview(dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(!d->model_available || !d->preview_requested) return; if(dt_view_get_current() == DT_VIEW_DARKROOM) dt_dev_write_history(darktable.develop); // invalidate current preview and bump sequence so running thread exits early d->preview_ready = FALSE; d->preview_error = DT_NR_PREVIEW_ERR_NONE; g_atomic_int_inc(&d->preview_sequence); gtk_widget_queue_draw(d->preview_area); GList *imgs = dt_act_on_get_images(TRUE, FALSE, FALSE); if(!imgs) return; dt_imgid_t imgid = GPOINTER_TO_INT(imgs->data); g_list_free(imgs); if(!dt_is_valid_imgid(imgid)) return; // an edit since the last preview invalidates the cached results AND // the exported thumbnail. the thumbnail matters beyond freshness: // the picker normalises clicks against it while the raw worker // inverts the CURRENT geometry chain, so a stale export would // reintroduce the #21879 offset from the other side. the history // write above has already brought the DB hash up to date dt_history_hash_values_t hh = { 0 }; dt_history_hash_read(imgid, &hh); if(d->preview_hash_imgid != imgid || d->preview_hash_len != hh.current_len || (hh.current_len > 0 && (!d->preview_hash || memcmp(d->preview_hash, hh.current, hh.current_len) != 0))) { // bump the sequence first: in-flight results then discard // themselves on delivery, and a worker that has not started yet // exits before touching any of the state below g_atomic_int_inc(&d->preview_sequence); _preview_cache_invalidate_all(d); // the export needs no barrier: workers get their own copy of the // pixels, and their own snapshot of whether one existed at launch g_free(d->export_pixels); d->export_pixels = NULL; g_free(d->export_cairo); d->export_cairo = NULL; // NB: preview_full_lin is deliberately NOT dropped here, though // it is history-dependent (it comes out of rawprepare + // highlights). an in-flight raw worker borrows it for the whole of // its run, so freeing it needs either a barrier — which would // block the GTK thread for an entire inference — or a deferred // ownership scheme. neither belongs in a mapping fix. the cost is // that on linear / X-Trans, editing highlight reconstruction or // the raw black/white point leaves the model reading a stale // demosaic while the un-prepare math uses current metadata, which // can show as a level step in the patched region g_free(d->preview_hash); d->preview_hash = NULL; if(hh.current && hh.current_len > 0) { d->preview_hash = g_malloc(hh.current_len); memcpy(d->preview_hash, hh.current, hh.current_len); } d->preview_hash_len = hh.current_len; d->preview_hash_imgid = imgid; // the picker draws from the export we just dropped: close it // through the toggle so the button visual follows, and refresh // sensitivity now that export_pixels is NULL if(d->picking_thumbnail) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(d->pick_button), FALSE); _update_button_sensitivity(d); } g_free(hh.basic); g_free(hh.auto_apply); g_free(hh.current); // per-task cache lookup: if we already have a result for this exact // (task, imgid, patch_center) tuple, install it and skip the worker if(_preview_cache_hit(d, d->task, imgid)) { dt_print(DT_DEBUG_AI, "[neural_restore] preview cache hit for %s", _task_log_name(d->task)); _install_cache_slot(self, d->task); d->preview_generating = FALSE; _update_button_sensitivity(d); // cache hit means preview_raw_sensor_class was set by an earlier // worker run on this imgid — refresh the overlay so the DNG // output label appears when _task_changed cleared it on tab switch _update_info_label(d); return; } // compute preview dimensions matching widget aspect ratio const int widget_w = gtk_widget_get_allocated_width(d->preview_area); const int widget_h = gtk_widget_get_allocated_height(d->preview_area); if(widget_w <= 0 || widget_h <= 0) return; const int scale = _task_scale(d->task); // use widget dimensions directly for 1:1 pixel mapping int pw = (widget_w / scale) * scale; int ph = (widget_h / scale) * scale; if(pw < scale || ph < scale) return; d->preview_generating = TRUE; dt_neural_preview_data_t *pd = g_new0(dt_neural_preview_data_t, 1); pd->self = self; pd->imgid = imgid; pd->task = d->task; pd->scale = scale; pd->env = d->env; pd->sequence = g_atomic_int_get(&d->preview_sequence); pd->preview_w = pw; pd->preview_h = ph; pd->patch_center[0] = d->patch_center[0]; pd->patch_center[1] = d->patch_center[1]; // hand the worker its OWN copy of the cached export (re-pick // scenario). aliasing d->export_pixels would tie every invalidation // to a barrier against the in-flight reader; a copy costs one memcpy // and lets the main thread replace the buffer whenever it likes. // the raw worker exports its own thumbnail and never reads this pd->have_export = (d->export_pixels != NULL); if(d->task != NEURAL_TASK_RAW_DENOISE && d->export_pixels && d->export_w > 0 && d->export_h > 0) { const size_t n = (size_t)d->export_w * d->export_h * 4; float *dup = g_try_malloc(n * sizeof(float)); if(dup) { memcpy(dup, d->export_pixels, n * sizeof(float)); pd->reuse_pixels = dup; pd->reuse_w = d->export_w; pd->reuse_h = d->export_h; } } // detach the previous worker (don't join — that would block the // UI thread for the duration of the in-flight inference / pipe // call). preview_inference_lock serialises the actual heavy work, // and the bumped sequence + per-task cache lookup at the new // worker's entry guarantees we don't run two inferences for the // same target. gui_cleanup joins the latest worker for shutdown. if(d->preview_thread) { g_thread_unref(d->preview_thread); d->preview_thread = NULL; } d->preview_thread = g_thread_new("neural_preview", _preview_thread_dispatch, pd); } // map notebook page index to task. pages are ordered in the notebook as: // 0 = raw denoise, 1 = denoise, 2 = upscale (with scale_combo picking 2x/4x) static dt_neural_task_t _task_from_page(dt_lib_neural_restore_t *d, const int page) { switch(page) { case 0: return NEURAL_TASK_RAW_DENOISE; case 1: return NEURAL_TASK_DENOISE; default: { const int scale_pos = dt_bauhaus_combobox_get(d->scale_combo); return (scale_pos == 1) ? NEURAL_TASK_UPSCALE_4X : NEURAL_TASK_UPSCALE_2X; } } } static void _update_task_from_ui(dt_lib_neural_restore_t *d) { d->task = _task_from_page(d, gtk_notebook_get_current_page(d->notebook)); } static void _notebook_page_changed(GtkNotebook *notebook, GtkWidget *page, const guint page_num, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; // switch-page fires before the page changes, so use page_num d->task = _task_from_page(d, page_num); dt_conf_set_int(CONF_ACTIVE_PAGE, page_num); _task_changed(d); // debounced — rapid tab cycling won't pile up worker threads if(d->preview_requested) _schedule_preview_refresh(self, 150); } static void _scale_combo_changed(GtkWidget *widget, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; _update_task_from_ui(d); _task_changed(d); if(d->preview_requested) _schedule_preview_refresh(self, 150); } static void _recovery_slider_changed(GtkWidget *widget, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(d->recovery_changing) return; dt_conf_set_float(CONF_STRENGTH, dt_bauhaus_slider_get(d->recovery_slider)); if(d->preview_ready) { _rebuild_cairo_after(d); gtk_widget_queue_draw(d->preview_area); } } static void _raw_strength_slider_changed(GtkWidget *widget, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; dt_conf_set_float(CONF_RAW_STRENGTH, dt_bauhaus_slider_get(d->raw_strength_slider)); // live preview re-blend (debounced). only fires when raw denoise tab // is active and a preview is already cached — otherwise the model // hasn't run yet and there's nothing to blend. if(d->task == NEURAL_TASK_RAW_DENOISE && d->preview_raw_src_rgb && d->preview_raw_denoised_rgb) _schedule_raw_strength_reblend(self); } static void _process_clicked(GtkWidget *widget, gpointer user_data) { dt_lib_module_t *self = (dt_lib_module_t *)user_data; dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(!d->model_available || _any_selected_processing(d)) return; GList *images = dt_act_on_get_images(TRUE, TRUE, FALSE); if(!images) return; if(dt_view_get_current() == DT_VIEW_DARKROOM) dt_dev_write_history(darktable.develop); dt_neural_job_t *job_data = g_new0(dt_neural_job_t, 1); job_data->task = d->task; job_data->env = d->env; job_data->images = images; job_data->scale = _task_scale(d->task); job_data->strength = dt_conf_key_exists(CONF_STRENGTH) ? dt_conf_get_float(CONF_STRENGTH) : 100.0f; // raw denoise strength: 0..100 in UI, 0..1 for the pipeline job_data->raw_strength = dt_conf_key_exists(CONF_RAW_STRENGTH) ? dt_conf_get_float(CONF_RAW_STRENGTH) / 100.0f : 1.0f; job_data->bpp = dt_conf_key_exists(CONF_BIT_DEPTH) ? dt_conf_get_int(CONF_BIT_DEPTH) : NEURAL_BPP_16; job_data->compression = dt_conf_key_exists(CONF_COMPRESSION) ? dt_conf_get_int(CONF_COMPRESSION) : NEURAL_COMPRESS_DEFLATE_PREDICTOR; job_data->add_to_catalog = dt_conf_key_exists(CONF_ADD_CATALOG) ? dt_conf_get_bool(CONF_ADD_CATALOG) : TRUE; job_data->output_pattern = _task_output_pattern(job_data->task); 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->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 for(GList *l = images; l; l = g_list_next(l)) g_hash_table_add(d->processing_images, l->data); _update_button_sensitivity(d); dt_job_t *job = dt_control_job_create(_process_job_run, "neural restore"); dt_control_job_set_params(job, job_data, _job_cleanup); dt_control_job_add_progress(job, _("neural restore"), TRUE); dt_control_add_job(DT_JOB_QUEUE_USER_BG, job); } // compute geometry for fitting the export image into the widget static void _picking_geometry(const dt_lib_neural_restore_t *d, const int w, const int h, double *img_w, double *img_h, double *ox, double *oy) { const double sx = (double)w / d->export_w; const double sy = (double)h / d->export_h; const double scale = fmin(sx, sy); *img_w = d->export_w * scale; *img_h = d->export_h * scale; *ox = (w - *img_w) / 2.0; *oy = (h - *img_h) / 2.0; } // build cairo surface from cached export pixels (4ch linear float → sRGB 8-bit) static void _build_export_cairo(dt_lib_neural_restore_t *d) { g_free(d->export_cairo); d->export_cairo = NULL; if(!d->export_pixels || d->export_w <= 0 || d->export_h <= 0) return; const int ew = d->export_w; const int eh = d->export_h; const int stride = cairo_format_stride_for_width(CAIRO_FORMAT_RGB24, ew); d->export_cairo = g_malloc(stride * eh); d->export_cairo_stride = stride; for(int y = 0; y < eh; y++) { uint32_t *row = (uint32_t *)(d->export_cairo + y * stride); for(int x = 0; x < ew; x++) { const size_t si = ((size_t)y * ew + x) * 4; const uint8_t r = (uint8_t)(_linear_to_srgb(d->export_pixels[si + 0]) * 255.0f + 0.5f); const uint8_t g = (uint8_t)(_linear_to_srgb(d->export_pixels[si + 1]) * 255.0f + 0.5f); const uint8_t b = (uint8_t)(_linear_to_srgb(d->export_pixels[si + 2]) * 255.0f + 0.5f); row[x] = ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b; } } } static void _pick_toggled(GtkToggleButton *btn, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = self->data; const gboolean active = gtk_toggle_button_get_active(btn); if(active) { // use cached export image for area selection if(!d->export_pixels) { gtk_toggle_button_set_active(btn, FALSE); return; } if(!d->export_cairo) _build_export_cairo(d); if(!d->export_cairo) { gtk_toggle_button_set_active(btn, FALSE); return; } d->picking_thumbnail = TRUE; } else { d->picking_thumbnail = FALSE; } gtk_widget_queue_draw(d->preview_area); } static void _pick_double_click_cb(GtkGestureSingle *gesture, int n_press, double x, double y, dt_lib_module_t *self) { if(n_press < 2) return; dt_lib_neural_restore_t *d = self->data; d->patch_center[0] = 0.5f; d->patch_center[1] = 0.5f; d->picking_thumbnail = FALSE; // free cairo cache (will be rebuilt), but keep export_pixels // so _trigger_preview can reuse them for the center crop g_free(d->export_cairo); d->export_cairo = NULL; gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(d->pick_button), FALSE); d->preview_requested = TRUE; _trigger_preview(self); } // upper bound on tooltip pixel dimensions so we never overflow the // active monitor on very large preview widgets #define DT_NR_TOOLTIP_MAX_PX 1024 // draw the cached preview surfaces into the tooltip widget at zoom, // preserving the user's split divider position. read-only inspector; // no interactive elements static gboolean _zoom_tooltip_draw(GtkWidget *widget, cairo_t *cr, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(!d->cairo_before || !d->cairo_after) return FALSE; const int pw = d->preview_w; const int ph = d->preview_h; if(pw <= 0 || ph <= 0) return FALSE; const int w = gtk_widget_get_allocated_width(widget); const int h = gtk_widget_get_allocated_height(widget); // background cairo_set_source_rgb(cr, 0.15, 0.15, 0.15); cairo_rectangle(cr, 0, 0, w, h); cairo_fill(cr); cairo_surface_t *before_surf = cairo_image_surface_create_for_data( d->cairo_before, CAIRO_FORMAT_RGB24, pw, ph, d->cairo_stride); cairo_surface_t *after_surf = cairo_image_surface_create_for_data( d->cairo_after, CAIRO_FORMAT_RGB24, pw, ph, d->cairo_stride); // fit-to-widget scale (widget is pre-sized to fit the preview at zoom) const double sx = (double)w / pw; const double sy = (double)h / ph; const double scale = fmin(sx, sy); const double img_w = pw * scale; const double img_h = ph * scale; const double ox = (w - img_w) / 2.0; const double oy = (h - img_h) / 2.0; const double div_x = ox + d->split_pos * img_w; // before (left of divider) cairo_save(cr); cairo_rectangle(cr, ox, oy, div_x - ox, img_h); cairo_clip(cr); cairo_translate(cr, ox, oy); cairo_scale(cr, scale, scale); cairo_set_source_surface(cr, before_surf, 0, 0); // NEAREST when scaling up — show actual pixels, not blurred ones cairo_pattern_set_filter(cairo_get_source(cr), scale >= 1.0 ? CAIRO_FILTER_NEAREST : CAIRO_FILTER_BILINEAR); cairo_paint(cr); cairo_restore(cr); // after (right of divider) cairo_save(cr); cairo_rectangle(cr, div_x, oy, ox + img_w - div_x, img_h); cairo_clip(cr); cairo_translate(cr, ox, oy); cairo_scale(cr, scale, scale); cairo_set_source_surface(cr, after_surf, 0, 0); cairo_pattern_set_filter(cairo_get_source(cr), scale >= 1.0 ? CAIRO_FILTER_NEAREST : CAIRO_FILTER_BILINEAR); cairo_paint(cr); cairo_restore(cr); // divider line cairo_set_source_rgb(cr, 1.0, 1.0, 1.0); cairo_set_line_width(cr, 1.5); cairo_move_to(cr, div_x, oy); cairo_line_to(cr, div_x, oy + img_h); cairo_stroke(cr); cairo_surface_destroy(before_surf); cairo_surface_destroy(after_surf); // zoom label, bottom-right. shows the actual scale (may be < the // requested zoom if the size cap kicked in) { char text[32]; snprintf(text, sizeof(text), _("zoom %.0f%%"), scale * 100.0); cairo_select_font_face(cr, "sans-serif", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); cairo_set_font_size(cr, 11.0); cairo_text_extents_t ext; cairo_text_extents(cr, text, &ext); const double pad = 4.0; const double bw = ext.width + pad * 2; const double bh = ext.height + pad * 2; cairo_set_source_rgba(cr, 0.0, 0.0, 0.0, 0.6); cairo_rectangle(cr, w - bw, h - bh, bw, bh); cairo_fill(cr); cairo_set_source_rgb(cr, 1.0, 1.0, 1.0); cairo_move_to(cr, w - bw + pad, h - pad); cairo_show_text(cr, text); } return FALSE; } // inspector tooltip: shows the same before/after split as the preview // area at the configured zoom factor (default 2×), so the user can // actually see individual pixels when assessing noise reduction. // suppressed during states where it would be misleading or interfere static gboolean _preview_query_tooltip(GtkWidget *widget, const gint x, const gint y, const gboolean keyboard_mode, GtkTooltip *tooltip, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(!d->preview_ready) return FALSE; if(!d->cairo_before || !d->cairo_after) return FALSE; if(d->preview_generating) return FALSE; if(d->dragging_split) return FALSE; if(d->picking_thumbnail) return FALSE; if(d->preview_w <= 0 || d->preview_h <= 0) return FALSE; // rc preference. 0 disables the tooltip entirely; anything // else is clamped to [1, 8] so a typo doesn't open a window the // size of a billboard float zoom = dt_conf_key_exists(CONF_PREVIEW_TOOLTIP_ZOOM) ? (float)dt_conf_get_float(CONF_PREVIEW_TOOLTIP_ZOOM) : 2.0f; if(zoom <= 0.0f) return FALSE; zoom = CLAMPF(zoom, 1.0f, 8.0f); int tw = (int)(d->preview_w * zoom); int th = (int)(d->preview_h * zoom); if(tw > DT_NR_TOOLTIP_MAX_PX || th > DT_NR_TOOLTIP_MAX_PX) { const double s = (double)DT_NR_TOOLTIP_MAX_PX / (double)MAX(tw, th); tw = (int)(tw * s); th = (int)(th * s); } GtkWidget *area = gtk_drawing_area_new(); gtk_widget_set_size_request(area, tw, th); g_signal_connect(area, "draw", G_CALLBACK(_zoom_tooltip_draw), self); gtk_widget_show(area); gtk_tooltip_set_custom(tooltip, area); return TRUE; } static gboolean _preview_draw(GtkWidget *widget, cairo_t *cr, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; const int w = gtk_widget_get_allocated_width(widget); const int h = gtk_widget_get_allocated_height(widget); // background cairo_set_source_rgb(cr, 0.15, 0.15, 0.15); cairo_rectangle(cr, 0, 0, w, h); cairo_fill(cr); // thumbnail picking mode: show full image with crop rectangle if(d->picking_thumbnail && d->export_cairo) { cairo_surface_t *thumb_surf = cairo_image_surface_create_for_data( d->export_cairo, CAIRO_FORMAT_RGB24, d->export_w, d->export_h, d->export_cairo_stride); // fit thumbnail to widget double img_w, img_h, ox, oy; _picking_geometry(d, w, h, &img_w, &img_h, &ox, &oy); cairo_save(cr); cairo_translate(cr, ox, oy); cairo_scale(cr, img_w / d->export_w, img_h / d->export_h); cairo_set_source_surface(cr, thumb_surf, 0, 0); cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_BILINEAR); cairo_paint(cr); cairo_restore(cr); // draw crop rectangle at current patch_center. the rectangle // represents the actual displayed patch, not the full tile size: // * RGB denoise / upscale: the preview runs on the exported image // at 1:1, so crop pixels == widget pixels, measured against the // thumbnail's own resolution (export_w == source for this path) // * raw denoise: preview_raw_crop_* carries the pipe's backbuf // dims — already in display orientation (post-flip iop) — so we // scale them against the display-oriented thumbnail (export_w / // export_h), NOT the sensor buffer (preview_full_*). using the // sensor dims would draw the wrong rectangle size on portrait // images where sensor and display axes swap double rw, rh; if(d->task == NEURAL_TASK_RAW_DENOISE && d->preview_raw_crop_w > 0 && d->preview_raw_crop_h > 0 && d->export_w > 0 && d->export_h > 0) { rw = (double)d->preview_raw_crop_w / d->export_w * img_w; rh = (double)d->preview_raw_crop_h / d->export_h * img_h; } else { const int task_scale = _task_scale(d->task); const int crop_w = w / task_scale; const int crop_h = h / task_scale; rw = (double)crop_w / d->export_w * img_w; rh = (double)crop_h / d->export_h * img_h; } // compute rectangle top-left. for RGB denoise / upscale, the click // / motion handlers already clamp patch_center with inner margins, // so the rectangle always fits — match master by NOT pushing here. // for raw denoise, patch_center is free-range in [0, 1]; push the // rectangle inward so it still fits (matches the worker's CLAMP on // crop_x / crop_y and keeps the picker visually honest). double rx = ox + d->patch_center[0] * img_w - rw / 2.0; double ry = oy + d->patch_center[1] * img_h - rh / 2.0; if(d->task == NEURAL_TASK_RAW_DENOISE) { if(rx < ox) rx = ox; if(ry < oy) ry = oy; if(rx + rw > ox + img_w) rx = ox + img_w - rw; if(ry + rh > oy + img_h) ry = oy + img_h - rh; } // dim area outside the rectangle cairo_save(cr); cairo_set_source_rgba(cr, 0.0, 0.0, 0.0, 0.5); cairo_rectangle(cr, 0, 0, w, h); cairo_rectangle(cr, rx, ry, rw, rh); cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD); cairo_fill(cr); cairo_restore(cr); // bright rectangle border cairo_set_source_rgba(cr, 1.0, 1.0, 1.0, 0.8); cairo_set_line_width(cr, 1.5); cairo_rectangle(cr, rx, ry, rw, rh); cairo_stroke(cr); // label cairo_select_font_face(cr, "sans-serif", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); cairo_set_font_size(cr, 11.0); const char *text = _("click to select preview area"); cairo_text_extents_t ext; cairo_text_extents(cr, text, &ext); const double pad = 4.0; const double bh = ext.height + pad * 2; cairo_set_source_rgba(cr, 0.0, 0.0, 0.0, 0.6); cairo_rectangle(cr, 0, h - bh, w, bh); cairo_fill(cr); cairo_set_source_rgb(cr, 1.0, 1.0, 1.0); cairo_move_to(cr, (w - ext.width) / 2.0, h - pad); cairo_show_text(cr, text); cairo_surface_destroy(thumb_surf); return FALSE; } if(!d->preview_ready || !d->cairo_before || !d->cairo_after) { cairo_set_source_rgb(cr, 0.5, 0.5, 0.5); cairo_select_font_face(cr, "sans-serif", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); cairo_set_font_size(cr, 12.0); cairo_text_extents_t ext; const char *text = !d->model_available ? _("model not available") : d->preview_generating ? _("generating preview...") : !d->preview_requested ? _("click to generate preview") : d->preview_error == DT_NR_PREVIEW_ERR_UNSUPPORTED ? _("image not supported by this task") : d->preview_error == DT_NR_PREVIEW_ERR_INIT_FAILED ? _("preview initialization failed") : _("select an image to preview"); cairo_text_extents(cr, text, &ext); cairo_move_to(cr, (w - ext.width) / 2.0, (h + ext.height) / 2.0); cairo_show_text(cr, text); return FALSE; } const int pw = d->preview_w; const int ph = d->preview_h; if(pw <= 0 || ph <= 0) return FALSE; cairo_surface_t *before_surf = cairo_image_surface_create_for_data( d->cairo_before, CAIRO_FORMAT_RGB24, pw, ph, d->cairo_stride); cairo_surface_t *after_surf = cairo_image_surface_create_for_data( d->cairo_after, CAIRO_FORMAT_RGB24, pw, ph, d->cairo_stride); // scale preview to fit widget, centered, never below 100% zoom const double sx = (double)w / pw; const double sy = (double)h / ph; const double scale = fmax(1.0, fmin(sx, sy)); const double img_w = pw * scale; const double img_h = ph * scale; const double ox = (w - img_w) / 2.0; const double oy = (h - img_h) / 2.0; const double div_x = ox + d->split_pos * img_w; // left side: before cairo_save(cr); cairo_rectangle(cr, ox, oy, div_x - ox, img_h); cairo_clip(cr); cairo_translate(cr, ox, oy); cairo_scale(cr, scale, scale); cairo_set_source_surface(cr, before_surf, 0, 0); cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_BILINEAR); cairo_paint(cr); cairo_restore(cr); // right side: after cairo_save(cr); cairo_rectangle(cr, div_x, oy, ox + img_w - div_x, img_h); cairo_clip(cr); cairo_translate(cr, ox, oy); cairo_scale(cr, scale, scale); cairo_set_source_surface(cr, after_surf, 0, 0); cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_BILINEAR); cairo_paint(cr); cairo_restore(cr); // divider line cairo_set_source_rgb(cr, 1.0, 1.0, 1.0); cairo_set_line_width(cr, 1.5); cairo_move_to(cr, div_x, oy); cairo_line_to(cr, div_x, oy + img_h); cairo_stroke(cr); cairo_surface_destroy(before_surf); cairo_surface_destroy(after_surf); cairo_select_font_face(cr, "sans-serif", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); cairo_set_font_size(cr, 11.0); // warning overlay at top if(d->warning_text[0]) { cairo_text_extents_t ext; cairo_text_extents(cr, d->warning_text, &ext); const double pad = 4.0; const double bh = ext.height + pad * 2; cairo_set_source_rgba(cr, 0.8, 0.1, 0.1, 0.85); cairo_rectangle(cr, 0, 0, w, bh); cairo_fill(cr); cairo_set_source_rgb(cr, 1.0, 1.0, 1.0); cairo_move_to(cr, (w - ext.width) / 2.0, pad + ext.height); cairo_show_text(cr, d->warning_text); } // info overlay at bottom if(d->info_text_left[0]) { cairo_text_extents_t ext_l, ext_r; cairo_text_extents(cr, d->info_text_left, &ext_l); 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 = 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); cairo_rectangle(cr, 0, by, w, bh); cairo_fill(cr); const double tx = (w - total_w) / 2.0; const double ty = by + pad + ext_l.height; cairo_set_source_rgb(cr, 1.0, 1.0, 1.0); cairo_move_to(cr, tx, ty); cairo_show_text(cr, d->info_text_left); 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; } // pointer within this distance of the divider shows the resize cursor; // clicks within this distance grip the divider without snapping it #define PREVIEW_DIVIDER_NEAR_PX 3.0 static void _preview_button_press_cb(GtkGestureSingle *gesture, int n_press, double ex, double ey, dt_lib_module_t *self) { GtkWidget *widget = dt_gui_get_widget(gesture); dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(gtk_gesture_single_get_current_button(gesture) != 1) return; // thumbnail picking mode: click to select area and trigger preview if(d->picking_thumbnail && d->export_cairo) { const int w = gtk_widget_get_allocated_width(widget); const int h = gtk_widget_get_allocated_height(widget); double img_w, img_h, ox, oy; _picking_geometry(d, w, h, &img_w, &img_h, &ox, &oy); // convert click to normalized image coords. const float nx = (float)((ex - ox) / img_w); const float ny = (float)((ey - oy) / img_h); if(nx < 0.0f || nx > 1.0f || ny < 0.0f || ny > 1.0f) return; if(d->task == NEURAL_TASK_RAW_DENOISE) { d->patch_center[0] = CLAMP(nx, 0.0f, 1.0f); d->patch_center[1] = CLAMP(ny, 0.0f, 1.0f); } else { const int task_scale = _task_scale(d->task); const float half_w = (float)w / task_scale / (2.0f * d->export_w); const float half_h = (float)h / task_scale / (2.0f * d->export_h); d->patch_center[0] = CLAMP(nx, half_w, 1.0f - half_w); d->patch_center[1] = CLAMP(ny, half_h, 1.0f - half_h); } // patch moved — every cached preview is now stale (different crop) _preview_cache_invalidate_all(d); // exit picking mode d->picking_thumbnail = FALSE; gtk_toggle_button_set_active( GTK_TOGGLE_BUTTON(d->pick_button), FALSE); // trigger preview reusing cached export (skip re-export) d->preview_requested = TRUE; dt_control_log(_("generating preview...")); _trigger_preview(self); return; } // click to start preview generation if(!d->preview_ready && !d->preview_generating) { d->preview_requested = TRUE; _trigger_preview(self); return; } if(!d->preview_ready) return; const int w = gtk_widget_get_allocated_width(widget); const int h = gtk_widget_get_allocated_height(widget); const int pw = d->preview_w; const int ph = d->preview_h; if(pw <= 0 || ph <= 0) return; const double scale = fmax(1.0, fmin((double)w / pw, (double)h / ph)); const double div_ox = (w - pw * scale) / 2.0; const double div_x = div_ox + d->split_pos * pw * scale; if(fabs(ex - div_x) < PREVIEW_DIVIDER_NEAR_PX) { // precision grip on the divider — drag without snapping d->dragging_split = TRUE; return; } // click anywhere else snaps the divider then enters drag d->split_pos = CLAMP((ex - div_ox) / (pw * scale), 0.0, 1.0); d->dragging_split = TRUE; gtk_widget_queue_draw(widget); } static void _preview_button_release_cb(GtkGestureSingle *gesture, int n_press, double x, double y, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(d->dragging_split) { d->dragging_split = FALSE; } } static void _preview_motion_cb(GtkEventControllerMotion *controller, double ex, double ey, dt_lib_module_t *self) { GtkWidget *widget = dt_gui_get_widget(controller); dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; // move crop rectangle while hovering in picking mode if(d->picking_thumbnail && d->export_cairo) { dt_gui_cursor_set(widget, NULL, "neural-restore/divider"); const int w = gtk_widget_get_allocated_width(widget); const int h = gtk_widget_get_allocated_height(widget); double img_w, img_h, ox, oy; _picking_geometry(d, w, h, &img_w, &img_h, &ox, &oy); // motion follows the cursor, with clamping based on task: // * RGB denoise / upscale: inner-margin clamp (master behaviour) // * raw denoise: free-range in [0, 1] so corners are reachable const float rx = (float)((ex - ox) / img_w); const float ry = (float)((ey - oy) / img_h); if(d->task == NEURAL_TASK_RAW_DENOISE) { d->patch_center[0] = CLAMP(rx, 0.0f, 1.0f); d->patch_center[1] = CLAMP(ry, 0.0f, 1.0f); } else { const int task_scale = _task_scale(d->task); const float half_w = (float)w / task_scale / (2.0f * d->export_w); const float half_h = (float)h / task_scale / (2.0f * d->export_h); d->patch_center[0] = CLAMP(rx, half_w, 1.0f - half_w); d->patch_center[1] = CLAMP(ry, half_h, 1.0f - half_h); } gtk_widget_queue_draw(widget); return; } if(d->dragging_split) { dt_gui_cursor_set(widget, NULL, "neural-restore/divider"); const int w = gtk_widget_get_allocated_width(widget); const int pw = d->preview_w; const int ph = d->preview_h; if(pw <= 0 || ph <= 0) return; const int ah = gtk_widget_get_allocated_height(widget); const double scale = fmax(1.0, fmin((double)w / pw, (double)ah / ph)); const double ox = (w - pw * scale) / 2.0; const double img_w = pw * scale; d->split_pos = CLAMP((ex - ox) / img_w, 0.0, 1.0); gtk_widget_queue_draw(widget); return; } // Change the cursor near the divider. The same helper also clears it // when the preview is unavailable, so a completed/cancelled preview // cannot leave the resize cursor behind. if(d->preview_ready && d->preview_w > 0 && d->preview_h > 0) { const int w = gtk_widget_get_allocated_width(widget); const int h = gtk_widget_get_allocated_height(widget); const double scale = fmax(1.0, fmin((double)w / d->preview_w, (double)h / d->preview_h)); const double ox = (w - d->preview_w * scale) / 2.0; const double div_x = ox + d->split_pos * d->preview_w * scale; const gboolean near = fabs(ex - div_x) < PREVIEW_DIVIDER_NEAR_PX; dt_gui_cursor_set(widget, near ? "col-resize" : NULL, "neural-restore/divider"); } else dt_gui_cursor_set(widget, NULL, "neural-restore/divider"); return; } static void _preview_leave_cb(GtkEventControllerMotion *controller, gpointer user_data) { (void)user_data; dt_gui_cursor_set(dt_gui_get_widget(controller), NULL, "neural-restore/divider"); } static void _selection_changed_callback(gpointer instance, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; d->preview_requested = FALSE; _cancel_preview(self); _preview_cache_invalidate_all(d); _update_info_label(d); _update_button_sensitivity(d); } static void _image_changed_callback(gpointer instance, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; d->preview_requested = FALSE; _cancel_preview(self); _preview_cache_invalidate_all(d); _update_info_label(d); _update_button_sensitivity(d); } static void _ai_models_changed_callback(gpointer instance, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; if(d->env) dt_restore_env_refresh(d->env); d->model_available = _check_model_available(d, d->task); _update_info_label(d); _update_button_sensitivity(d); } static void _bpp_combo_changed(GtkWidget *w, dt_lib_module_t *self) { const int idx = dt_bauhaus_combobox_get(w); dt_conf_set_int(CONF_BIT_DEPTH, idx); } static void _compress_combo_changed(GtkWidget *w, dt_lib_module_t *self) { const int idx = dt_bauhaus_combobox_get(w); dt_conf_set_int(CONF_COMPRESSION, 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, ""); } // cached previews are in the old profile – drop them _preview_cache_invalidate_all((dt_lib_neural_restore_t *)self->data); _update_info_label((dt_lib_neural_restore_t *)self->data); } static void _catalog_toggle_changed(GtkWidget *w, dt_lib_module_t *self) { const gboolean active = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(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_pattern_changed(GtkEditable *editable, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; dt_conf_set_string(_task_pattern_key(d->task), gtk_entry_get_text(GTK_ENTRY(editable))); } static void _output_pattern_browse(GtkWidget *button, dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; GtkWidget *dialog = gtk_file_chooser_dialog_new(_("select directory"), GTK_WINDOW(dt_ui_main_window(darktable.gui->ui)), GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER, _("_cancel"), GTK_RESPONSE_CANCEL, _("_select"), GTK_RESPONSE_ACCEPT, NULL); // the entry holds a whole path now, so split it. an emptied one has no halves // to keep, and g_path_get_basename("") would name the file "." const char *current = gtk_entry_get_text(GTK_ENTRY(d->output_pattern_entry)); char *edited = (current && current[0]) ? g_strdup(current) : _task_output_pattern(d->task); char *cur_dir = g_path_get_dirname(edited); if(g_file_test(cur_dir, G_FILE_TEST_IS_DIR)) gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(dialog), cur_dir); if(gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { char *folder = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); if(folder) { // the dialog only picks a folder, so keep whatever names the file. "." and // "/" come back from a path with no name in it, and name nothing char *name = g_path_get_basename(edited); if(!g_strcmp0(name, ".") || !g_strcmp0(name, G_DIR_SEPARATOR_S)) { char *def = _task_output_pattern(d->task); g_free(name); name = g_path_get_basename(def); g_free(def); } char *pattern = g_build_filename(folder, name, NULL); gtk_entry_set_text(GTK_ENTRY(d->output_pattern_entry), pattern); gtk_editable_set_position(GTK_EDITABLE(d->output_pattern_entry), -1); g_free(pattern); g_free(name); g_free(folder); } } g_free(cur_dir); g_free(edited); gtk_widget_destroy(dialog); } void gui_init(dt_lib_module_t *self) { dt_lib_neural_restore_t *d = g_new0(dt_lib_neural_restore_t, 1); self->data = d; d->env = dt_restore_env_init(); d->processing_images = g_hash_table_new(g_direct_hash, g_direct_equal); dt_pthread_mutex_init(&d->ctx_lock); g_mutex_init(&d->preview_inference_lock); d->split_pos = 0.5f; // notebook tabs (denoise / upscale) static dt_action_def_t notebook_def = {}; d->notebook = dt_ui_notebook_new(¬ebook_def); dt_action_define(DT_ACTION(self), NULL, N_("page"), GTK_WIDGET(d->notebook), ¬ebook_def); // raw denoise sits first: it runs earliest in the denoise workflow // (before demosaic-stage processing). bayer / linear variant selection // is driven by the active "rawdenoise" model rather than a UI toggle d->raw_denoise_page = dt_ui_notebook_page(d->notebook, N_("raw denoise"), _("AI raw denoising")); d->denoise_page = dt_ui_notebook_page(d->notebook, N_("denoise"), _("AI denoising")); d->upscale_page = dt_ui_notebook_page(d->notebook, N_("upscale"), _("AI upscaling")); // raw denoise page: strength slider. 100 = full model output, // 0 = unchanged source CFA, linear blend in raw ADC space const float saved_raw_strength = dt_conf_key_exists(CONF_RAW_STRENGTH) ? dt_conf_get_float(CONF_RAW_STRENGTH) : 100.0f; d->raw_strength_slider = dt_bauhaus_slider_new_action(DT_ACTION(self), 0.0f, 100.0f, 1.0f, saved_raw_strength, 0); dt_bauhaus_widget_set_label(d->raw_strength_slider, NULL, N_("strength")); dt_bauhaus_slider_set_format(d->raw_strength_slider, "%"); gtk_widget_set_tooltip_text(d->raw_strength_slider, _("blend between the source CFA (0%) and " "the denoised output (100%)")); g_signal_connect(G_OBJECT(d->raw_strength_slider), "value-changed", G_CALLBACK(_raw_strength_slider_changed), self); dt_gui_box_add(d->raw_denoise_page, d->raw_strength_slider); // denoise page: strength slider. 100 = full denoise, 0 = source-like. // dialing below 100 brings DWT-filtered texture back without // reintroducing the noise-frequency content. const float saved_strength = dt_conf_key_exists(CONF_STRENGTH) ? dt_conf_get_float(CONF_STRENGTH) : 100.0f; d->recovery_slider = dt_bauhaus_slider_new_action(DT_ACTION(self), 0.0f, 100.0f, 1.0f, saved_strength, 0); dt_bauhaus_widget_set_label(d->recovery_slider, NULL, N_("strength")); dt_bauhaus_slider_set_format(d->recovery_slider, "%"); gtk_widget_set_tooltip_text(d->recovery_slider, /* xgettext:no-c-format */ _("100% applies the full AI model output; " "lower values bring back luminance texture " "and grain while keeping color noise suppressed")); g_signal_connect(G_OBJECT(d->recovery_slider), "value-changed", G_CALLBACK(_recovery_slider_changed), self); dt_gui_box_add(d->denoise_page, d->recovery_slider); // upscale page: scale factor selector DT_BAUHAUS_COMBOBOX_NEW_FULL(d->scale_combo, self, NULL, N_("scale"), _("upscale factor"), 0, _scale_combo_changed, self, N_("2x"), N_("4x")); dt_gui_box_add(d->upscale_page, d->scale_combo); // restore saved tab const int saved_page = dt_conf_get_int(CONF_ACTIVE_PAGE); if(saved_page > 0) gtk_notebook_set_current_page(d->notebook, saved_page); _update_task_from_ui(d); d->model_available = _check_model_available(d, d->task); d->preview_hash_imgid = NO_IMGID; // pick area button d->patch_center[0] = 0.5f; d->patch_center[1] = 0.5f; d->picking_thumbnail = FALSE; d->pick_button = dtgtk_togglebutton_new_full(dtgtk_cairo_paint_colorpicker, 0, NULL, &(dtgtk_button_config_t){ .tooltip = _("click to pick preview area on the image thumbnail\n" "double-click to reset to center"), .toggled_cb = G_CALLBACK(_pick_toggled), .toggled_data = self, }); dt_gui_connect_click_all(d->pick_button, _pick_double_click_cb, NULL, self); // preview area (resizable via dt_ui_resize_wrap) d->preview_area = GTK_WIDGET(dt_ui_resize_wrap(NULL, 200, CONF_PREVIEW_HEIGHT)); g_signal_connect(d->preview_area, "draw", G_CALLBACK(_preview_draw), self); dt_gui_connect_click(d->preview_area, _preview_button_press_cb, _preview_button_release_cb, self); dt_gui_connect_motion(d->preview_area, _preview_motion_cb, NULL, _preview_leave_cb, self); // hover-zoom tooltip: shows the same preview at 2x so the user can // see individual pixels. only fires when there's a valid preview; // suppressed during inference / picking / dragging gtk_widget_set_has_tooltip(d->preview_area, TRUE); g_signal_connect(d->preview_area, "query-tooltip", G_CALLBACK(_preview_query_tooltip), self); // process button d->process_button = dt_action_button_new(self, N_("process"), _process_clicked, self, _("process selected images"), 0, 0); // process + pick button row gtk_widget_set_hexpand(d->process_button, TRUE); gtk_widget_set_halign(d->process_button, GTK_ALIGN_FILL); GtkWidget *action_row = dt_gui_hbox(); dt_gui_box_add(action_row, d->process_button); dt_gui_box_add(action_row, d->pick_button); // main layout: notebook, preview, action row, output gtk_widget_set_margin_top(action_row, 4); self->widget = dt_gui_vbox(GTK_WIDGET(d->notebook), d->preview_area, action_row); // output settings dt_gui_new_collapsible_section(&d->cs_output, CONF_EXPAND_OUTPUT, _("output parameters"), GTK_BOX(self->widget), DT_ACTION(self)); GtkWidget *cs_box = GTK_WIDGET(d->cs_output.container); // bit depth const int saved_bpp = dt_conf_key_exists(CONF_BIT_DEPTH) ? dt_conf_get_int(CONF_BIT_DEPTH) : NEURAL_BPP_16; DT_BAUHAUS_COMBOBOX_NEW_FULL(d->bpp_combo, self, NULL, N_("bit depth"), _("output TIFF bit depth"), saved_bpp, _bpp_combo_changed, self, N_("8 bit"), N_("16 bit"), N_("32 bit (float)")); dt_gui_box_add(cs_box, d->bpp_combo); // compression — same options as the TIFF export module const int saved_compress = dt_conf_key_exists(CONF_COMPRESSION) ? dt_conf_get_int(CONF_COMPRESSION) : NEURAL_COMPRESS_DEFLATE_PREDICTOR; DT_BAUHAUS_COMBOBOX_NEW_FULL(d->compress_combo, self, NULL, N_("compression"), _("output TIFF compression"), saved_compress, _compress_combo_changed, self, N_("uncompressed"), N_("deflate"), N_("deflate with predictor")); dt_gui_box_add(cs_box, d->compress_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); // 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 the current collection")); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(d->catalog_toggle), dt_conf_key_exists(CONF_ADD_CATALOG) ? dt_conf_get_bool(CONF_ADD_CATALOG) : TRUE); gtk_widget_set_tooltip_text(d->catalog_toggle, _("automatically import the output image into" " the current collection")); g_signal_connect(d->catalog_toggle, "toggled", G_CALLBACK(_catalog_toggle_changed), self); dt_gui_box_add(catalog_box, d->catalog_toggle); dt_gui_box_add(cs_box, catalog_box); // output path GtkWidget *dir_box = dt_gui_hbox(); d->output_pattern_entry = gtk_entry_new(); _migrate_output_dir(); char *saved_pattern = _task_output_pattern(d->task); gtk_entry_set_text(GTK_ENTRY(d->output_pattern_entry), saved_pattern); // the file name is the part being edited, and it is off the right-hand end gtk_editable_set_position(GTK_EDITABLE(d->output_pattern_entry), -1); g_free(saved_pattern); gtk_widget_set_tooltip_text(d->output_pattern_entry, _("where this task writes, without the extension." " whatever follows the source name is the" " suffix\n" "$(FILE_FOLDER) = source image folder\n" "$(FILE.NAME) = source name without extension")); gtk_widget_set_hexpand(d->output_pattern_entry, TRUE); g_signal_connect(d->output_pattern_entry, "changed", G_CALLBACK(_output_pattern_changed), self); d->output_pattern_button = dtgtk_button_new_full(dtgtk_cairo_paint_directory, 0, NULL, &(dtgtk_button_config_t){ .tooltip = _("select directory"), .clicked_cb = G_CALLBACK(_output_pattern_browse), .clicked_data = self, }); dt_gui_box_add(dir_box, d->output_pattern_entry); dt_gui_box_add(dir_box, d->output_pattern_button); dt_gui_box_add(cs_box, dir_box); g_signal_connect(d->notebook, "switch-page", G_CALLBACK(_notebook_page_changed), self); gtk_widget_show_all(self->widget); gtk_widget_set_no_show_all(self->widget, TRUE); // DT signals DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_SELECTION_CHANGED, _selection_changed_callback); DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_DEVELOP_IMAGE_CHANGED, _image_changed_callback); DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_AI_MODELS_CHANGED, _ai_models_changed_callback); // sync per-task widget visibility for the initially-active tab. // _task_changed does detail-slider + output-knobs visibility and // info/button state — safe to call here after all widgets exist. _task_changed(d); } void gui_cleanup(dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; DT_CONTROL_SIGNAL_DISCONNECT(_selection_changed_callback, self); DT_CONTROL_SIGNAL_DISCONNECT(_image_changed_callback, self); DT_CONTROL_SIGNAL_DISCONNECT(_ai_models_changed_callback, self); if(d) { // cancel all pending timers first, before any state teardown if(d->preview_trigger_timer) { g_source_remove(d->preview_trigger_timer); d->preview_trigger_timer = 0; } if(d->preview_strength_timer) { g_source_remove(d->preview_strength_timer); d->preview_strength_timer = 0; } // signal preview thread to exit and join (blocks; shutdown only) g_atomic_int_inc(&d->preview_sequence); if(d->preview_thread) { g_thread_join(d->preview_thread); d->preview_thread = NULL; } // do not drain the main context here: dispatching arbitrary pending // sources during cleanup crashed on a stale GSource owned by another // lib (issue #20928). idle callbacks guard against d == NULL instead g_mutex_clear(&d->preview_inference_lock); g_free(d->preview_before); g_free(d->preview_after); dt_free_align(d->preview_detail); g_free(d->cairo_before); g_free(d->cairo_after); g_free(d->export_pixels); g_free(d->export_cairo); g_free(d->preview_hash); g_free(d->preview_full_cfa); dt_free_align(d->preview_full_lin); g_free(d->preview_raw_src_rgb); g_free(d->preview_raw_denoised_rgb); _preview_cache_invalidate_all(d); if(d->processing_images) g_hash_table_destroy(d->processing_images); dt_restore_unref(d->cached_ctx); dt_pthread_mutex_destroy(&d->ctx_lock); if(d->env) dt_restore_env_destroy(d->env); g_free(d); } self->data = NULL; } void gui_update(dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; // re-read model availability in case conf changed d->model_available = _check_model_available(d, d->task); _update_info_label(d); _update_button_sensitivity(d); } void gui_reset(dt_lib_module_t *self) { dt_lib_neural_restore_t *d = (dt_lib_neural_restore_t *)self->data; gtk_notebook_set_current_page(d->notebook, 0); dt_conf_set_int(CONF_ACTIVE_PAGE, 0); dt_bauhaus_combobox_set(d->scale_combo, 0); // page 0 is raw denoise: take the task from the page rather than naming one, // or the entry shows one task's pattern while editing writes another's _update_task_from_ui(d); _output_pattern_refresh(d); d->model_available = _check_model_available(d, d->task); d->preview_requested = FALSE; _cancel_preview(self); _update_info_label(d); _update_button_sensitivity(d); } // clang-format off // modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py // vim: shiftwidth=2 expandtab tabstop=2 cindent // kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified; // clang-format on