From daeecc4156001b5bbf02a0082ca49118f358f959 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 12 Jul 2026 09:41:13 +0200 Subject: [PATCH 01/56] initial darktable spektrafilm commit --- data/kernels/programs.conf | 1 + data/kernels/spektrafilm.cl | 700 ++++++++++ src/common/iop_order.c | 5 + src/iop/CMakeLists.txt | 1 + src/iop/spektra_core.c | 441 +++++++ src/iop/spektra_core.h | 689 ++++++++++ src/iop/spektra_sim.c | 2458 +++++++++++++++++++++++++++++++++++ src/iop/spektra_sim.h | 325 +++++ src/iop/spektrafilm.c | 1493 +++++++++++++++++++++ 9 files changed, 6113 insertions(+) create mode 100644 data/kernels/spektrafilm.cl create mode 100644 src/iop/spektra_core.c create mode 100644 src/iop/spektra_core.h create mode 100644 src/iop/spektra_sim.c create mode 100644 src/iop/spektra_sim.h create mode 100644 src/iop/spektrafilm.c diff --git a/data/kernels/programs.conf b/data/kernels/programs.conf index 15ddf324b645..471b192a9ffd 100644 --- a/data/kernels/programs.conf +++ b/data/kernels/programs.conf @@ -42,3 +42,4 @@ capture.cl 38 agx.cl 39 colorharmonizer.cl 40 overlay.cl 41 +spektrafilm.cl 42 diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl new file mode 100644 index 000000000000..2d014c2f8033 --- /dev/null +++ b/data/kernels/spektrafilm.cl @@ -0,0 +1,700 @@ +/* + 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 . +*/ + +/* spektrafilm.cl — OpenCL kernels for the native spektrafilm iop. + * + * Mirrors the CPU stage functions in spektra_sim.c (which are themselves a + * validated port of spektrafilm 0.3.x). Per-pixel colour science runs here; + * the Gaussian blurs (halation bounces, diffusion bank, DIR-coupler + * correction diffusion, grain clumps) are done by darktable's + * dt_gaussian_fast_blur_cl_buffer on the intermediate buffers, exactly as the + * CPU path uses sf_blur_plane3. + * + * Pipeline: expose (CAT16'd RGB -> xy -> tri2quad -> Mitchell-cubic 2D + * spectral LUT × brightness × 2^EV) -> [boost/diffusion/halation on linear] + * -> lograw -> develop_corr -> [host blur] -> develop -> [grain] -> + * print_expose (PCHIP 3D) -> print_develop -> scan (PCHIP 3D -> XYZ -> + * work RGB -> OkLCh compression). + * + * Conventions: + * - working buffers are float4 (.w carries alpha where relevant); + * - all tables come from sf_sim_gpu_export(): the 2D spectral LUT + * (tc_n×tc_n×3), the density curves (256×3), and the 3D PCHIP tables + * (steps³×3 values + per-axis slopes + per-cell bounds) as __global + * float buffers (they exceed __constant limits at 33³+); + * - small matrices are packed into one __constant float block, see the + * SF_M_* offsets below; + * - the CPU engine computes in double; these kernels are float, so expect + * ~1e-3 vs the CPU path (validated with POCL against sf_sim_process). + * - exact-spectral quality has NO GPU path; process_cl falls back to CPU. + */ + +constant sampler_t sampleri = + CLK_NORMALIZED_COORDS_FALSE | CLK_ADDRESS_CLAMP_TO_EDGE | CLK_FILTER_NEAREST; + +#define SF_NLE 256 +#define SF_LOG_EPS 1e-10f + +/* offsets (in floats) into the packed matrix/constant buffer */ +#define SF_M_IN 0 /* 9: work RGB -> XYZ(film ref), CAT16 included */ +#define SF_M_OUT 9 /* 9: XYZ(view) -> work RGB, CAT02 included */ +#define SF_M_COUPLERS 18 /* 9: DIR coupler matrix, amount-scaled */ +#define SF_M_RGB2XYZ 27 /* 9: output RGB -> XYZ (plain, for OkLab) */ +#define SF_M_XYZ2RGB 36 /* 9 */ +#define SF_M_OK1 45 /* 9: OkLab M1 */ +#define SF_M_OK2 54 /* 9: OkLab M2 */ +#define SF_M_OK1I 63 /* 9: inv(M1) */ +#define SF_M_OK2I 72 /* 9: inv(M2) */ +#define SF_M_LM_DONOR 81 /* 6: langmuir donor K[3] + D_ref[3] (K=1e30 = linear) */ +#define SF_M_LM_RECV 87 /* 6: langmuir receiver Kr[3] + c_ref[3] */ +#define SF_M_TOTAL 93 + +static inline float sf_clampf(float x, float lo, float hi) +{ + return fmin(fmax(x, lo), hi); +} + +static inline float3 sf_mat3(__constant const float *m, float3 v) +{ + return (float3)(m[0] * v.x + m[1] * v.y + m[2] * v.z, + m[3] * v.x + m[4] * v.y + m[5] * v.z, + m[6] * v.x + m[7] * v.y + m[8] * v.z); +} + +/* ---- [su] Mitchell-Netravali cubic on the tc_n×tc_n×3 spectral LUT ------ */ + +static float sf_mitchell(float t) +{ + const float B = 1.0f / 3.0f, C = 1.0f / 3.0f; + const float x = fabs(t); + if(x < 1.0f) + return (1.0f / 6.0f) + * ((12.0f - 9.0f * B - 6.0f * C) * x * x * x + + (-18.0f + 12.0f * B + 6.0f * C) * x * x + (6.0f - 2.0f * B)); + else if(x < 2.0f) + return (1.0f / 6.0f) + * ((-B - 6.0f * C) * x * x * x + (6.0f * B + 30.0f * C) * x * x + + (-12.0f * B - 48.0f * C) * x + (8.0f * B + 24.0f * C)); + return 0.0f; +} + +static inline int sf_reflect(int idx, int L) +{ + if(idx < 0) return -idx; + if(idx >= L) return 2 * (L - 1) - idx; + return idx; +} + +static inline void sf_base_frac(float coord, int L, int *base, float *frac) +{ + coord = sf_clampf(coord, 0.0f, (float)(L - 1)); + if(coord >= (float)(L - 1)) + { + *base = L - 2; + *frac = 1.0f; + return; + } + *base = (int)floor(coord); + *frac = coord - *base; +} + +static float3 sf_cubic2d(__global const float *lut, int L, float x, float y) +{ + int xb, yb; + float xf, yf; + sf_base_frac(x, L, &xb, &xf); + sf_base_frac(y, L, &yb, &yf); + float wx[4], wy[4]; + for(int i = 0; i < 4; i++) + { + wx[i] = sf_mitchell(xf + 1.0f - i); + wy[i] = sf_mitchell(yf + 1.0f - i); + } + float3 acc = (float3)(0.0f); + float wsum = 0.0f; + for(int i = 0; i < 4; i++) + { + const int xi = sf_reflect(xb - 1 + i, L); + for(int j = 0; j < 4; j++) + { + const int yj = sf_reflect(yb - 1 + j, L); + const float w = wx[i] * wy[j]; + wsum += w; + const size_t o = ((size_t)xi * L + yj) * 3; + acc += w * (float3)(lut[o], lut[o + 1], lut[o + 2]); + } + } + return (wsum != 0.0f) ? acc / wsum : acc; +} + +/* ---- [dc] density curve interpolation over the uniform le grid ---------- */ +/* x-axis = le/gamma -> index t = (x*gamma - le0)/le_step, endpoint-clamped */ +static inline float sf_curve(__global const float *curves, float x, float gammac, + float le0, float le_step, int c) +{ + const float t = (x * gammac - le0) / le_step; + if(t <= 0.0f) return curves[c]; + if(t >= (float)(SF_NLE - 1)) return curves[(SF_NLE - 1) * 3 + c]; + const int i = (int)t; + const float f = t - i; + return curves[i * 3 + c] + f * (curves[(i + 1) * 3 + c] - curves[i * 3 + c]); +} + +/* ---- [fi] monotone-PCHIP 3D LUT (values + per-axis slopes + cell clamp) - */ + +static inline float sf_hermite(float y0, float y1, float m0, float m1, float t) +{ + const float t2 = t * t, t3 = t2 * t; + return (2.0f * t3 - 3.0f * t2 + 1.0f) * y0 + (t3 - 2.0f * t2 + t) * m0 + + (-2.0f * t3 + 3.0f * t2) * y1 + (t3 - t2) * m1; +} + +static float3 sf_pchip3d(__global const float *lut, __global const float *sx, + __global const float *sy, __global const float *sz, + __global const float *cmin, __global const float *cmax, + const int n, float r, float g, float b) +{ + const int m = n - 1; + int i, j, k; + float tr, tg, tb; + sf_base_frac(r, n, &i, &tr); + sf_base_frac(g, n, &j, &tg); + sf_base_frac(b, n, &k, &tb); + float out[3]; +#define AT(arr, ii, jj, kk, c) arr[((((size_t)(ii)) * n + (jj)) * n + (kk)) * 3 + (c)] + for(int c = 0; c < 3; c++) + { + const float v000 = sf_hermite(AT(lut, i, j, k, c), AT(lut, i + 1, j, k, c), + AT(sx, i, j, k, c), AT(sx, i + 1, j, k, c), tr); + const float v010 = sf_hermite(AT(lut, i, j + 1, k, c), AT(lut, i + 1, j + 1, k, c), + AT(sx, i, j + 1, k, c), AT(sx, i + 1, j + 1, k, c), tr); + const float v001 = sf_hermite(AT(lut, i, j, k + 1, c), AT(lut, i + 1, j, k + 1, c), + AT(sx, i, j, k + 1, c), AT(sx, i + 1, j, k + 1, c), tr); + const float v011 + = sf_hermite(AT(lut, i, j + 1, k + 1, c), AT(lut, i + 1, j + 1, k + 1, c), + AT(sx, i, j + 1, k + 1, c), AT(sx, i + 1, j + 1, k + 1, c), tr); + const float sy00 = mix(AT(sy, i, j, k, c), AT(sy, i + 1, j, k, c), tr); + const float sy10 = mix(AT(sy, i, j + 1, k, c), AT(sy, i + 1, j + 1, k, c), tr); + const float sy01 = mix(AT(sy, i, j, k + 1, c), AT(sy, i + 1, j, k + 1, c), tr); + const float sy11 = mix(AT(sy, i, j + 1, k + 1, c), AT(sy, i + 1, j + 1, k + 1, c), tr); + const float vz0 = sf_hermite(v000, v010, sy00, sy10, tg); + const float vz1 = sf_hermite(v001, v011, sy01, sy11, tg); + const float sz0 = mix(mix(AT(sz, i, j, k, c), AT(sz, i + 1, j, k, c), tr), + mix(AT(sz, i, j + 1, k, c), AT(sz, i + 1, j + 1, k, c), tr), tg); + const float sz1 + = mix(mix(AT(sz, i, j, k + 1, c), AT(sz, i + 1, j, k + 1, c), tr), + mix(AT(sz, i, j + 1, k + 1, c), AT(sz, i + 1, j + 1, k + 1, c), tr), tg); + float v = sf_hermite(vz0, vz1, sz0, sz1, tb); + const size_t ci = ((((size_t)i) * m + j) * m + k) * 3 + c; + v = sf_clampf(v, cmin[ci], cmax[ci]); + out[c] = v; + } +#undef AT + return (float3)(out[0], out[1], out[2]); +} + +/* ---- [gc] Reinhard knee + OkLCh output gamut compression ---------------- */ + +static inline float sf_knee(float d, float threshold, float limit, float power) +{ + if(d <= threshold) return d; + const float scale = limit - threshold; + const float x = (d - threshold) / scale; + const float y = x / pow(1.0f + pow(x, power), 1.0f / power); + return threshold + scale * y; +} + +static inline float3 sf_xyz_to_oklab(__constant const float *mats, float3 xyz) +{ + float3 lms = sf_mat3(mats + SF_M_OK1, xyz); + lms = (float3)(cbrt(lms.x), cbrt(lms.y), cbrt(lms.z)); + return sf_mat3(mats + SF_M_OK2, lms); +} + +static inline float3 sf_oklab_to_xyz(__constant const float *mats, float3 lab) +{ + float3 lms = sf_mat3(mats + SF_M_OK2I, lab); + lms = lms * lms * lms; + return sf_mat3(mats + SF_M_OK1I, lms); +} + +static float sf_cmax_lookup(__global const float *table, const int nl, const int nh, + float L, float h) +{ + const float L_lo_v = 0.02f, L_hi_v = 1.0f; + L = sf_clampf(L, L_lo_v, L_hi_v); + const float h_step = 2.0f * M_PI_F / nh; + const float h_idx = (h + M_PI_F) / h_step; + const float h_floor = floor(h_idx); + int h_lo = ((int)h_floor) % nh; + if(h_lo < 0) h_lo += nh; + const int h_hi = (h_lo + 1) % nh; + const float h_frac = h_idx - h_floor; + const float L_idx = (L - L_lo_v) / (L_hi_v - L_lo_v) * (float)(nl - 1); + int L_lo = (int)floor(L_idx); + L_lo = clamp(L_lo, 0, nl - 2); + const float L_frac = L_idx - L_lo; + const float v00 = table[(size_t)L_lo * nh + h_lo]; + const float v01 = table[(size_t)L_lo * nh + h_hi]; + const float v10 = table[(size_t)(L_lo + 1) * nh + h_lo]; + const float v11 = table[(size_t)(L_lo + 1) * nh + h_hi]; + return v00 * (1 - L_frac) * (1 - h_frac) + v01 * (1 - L_frac) * h_frac + + v10 * L_frac * (1 - h_frac) + v11 * L_frac * h_frac; +} + +/* ---- grain RNG, identical to spektra_core.h (see there for provenance) -- */ +static inline uint sf_h(uint x) +{ + x ^= x >> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} +static inline float sf_u01(uint s) +{ + return (sf_h(s) & 0xffffff) / (float)0x1000000; +} +static inline float sf_nrm(uint s) +{ + float u1 = fmax(sf_u01(s), 1e-7f), u2 = sf_u01(s * 2654435761u + 1u); + return native_sqrt(-2.f * native_log(u1)) * native_cos(6.2831853f * u2); +} +static inline uint sf_pixel_seed(uint xi, uint yi, uint chan) +{ + return xi * 73856093u ^ yi * 19349663u ^ chan * 83492791u; +} +static float sf_layer_particle(float density, float dmax, float npart, float unif, uint seed) +{ + float p = sf_clampf(density / dmax, 1e-6f, 1.f - 1e-6f), od = dmax / npart, + sat = 1.f - p * unif * (1.f - 1e-6f), lam = npart / sat; + float seeds = lam + native_sqrt(fmax(lam, 0.f)) * sf_nrm(seed * 0x9e3779b9u + 1u); + seeds = fmax(seeds, 0.f); + float mean = seeds * p, var = seeds * p * (1.f - p), + g = mean + native_sqrt(fmax(var, 0.f)) * sf_nrm(seed * 0x85ebca6bU + 7u); + g = fmax(g, 0.f); + g = fmin(g, seeds); + return g * od * sat; +} + +/* ======================================================================== */ +/* per-pixel stage kernels */ +/* ======================================================================== */ + +/* stage 1: input image -> linear film raw exposure (spektra_sim: sf_sim_expose) */ +__kernel void spektrafilm_expose(__read_only image2d_t in, __global float4 *plane, + const int w, const int h, __constant float *mats, + __global const float *tc_lut, const int tc_n, + const float ev_scale) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + float4 px = read_imagef(in, sampleri, (int2)(x, y)); + float3 xyz = sf_mat3(mats + SF_M_IN, (float3)(px.x, px.y, px.z)); + const float b = xyz.x + xyz.y + xyz.z; + const float inv = 1.0f / fmax(b, 1e-10f); + const float xx = xyz.x * inv, yy = xyz.y * inv; + /* [su] tri2quad */ + const float tcx = sf_clampf((1.0f - xx) * (1.0f - xx), 0.0f, 1.0f); + /* careful: tri2quad computes from CIE xy, matching spektra_sim tri2quad() */ + const float tcy = sf_clampf(yy / fmax(1.0f - xx, 1e-10f), 0.0f, 1.0f); + const float scale = (float)(tc_n - 1); + float3 raw = sf_cubic2d(tc_lut, tc_n, tcx * scale, tcy * scale); + const float bb = isfinite(b) ? b : 0.0f; + raw *= bb * ev_scale; + plane[(size_t)y * w + x] = (float4)(raw.x, raw.y, raw.z, px.w); +} + +/* stage 3a: linear raw -> log exposure (in place) */ +__kernel void spektrafilm_lograw(__global float4 *plane, const int w, const int h) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + float4 p = plane[k]; + p.x = log10(fmax(p.x, 0.0f) + SF_LOG_EPS); + p.y = log10(fmax(p.y, 0.0f) + SF_LOG_EPS); + p.z = log10(fmax(p.z, 0.0f) + SF_LOG_EPS); + plane[k] = p; +} + +/* stage 3b: DIR coupler correction field (spektra_sim: sf_sim_develop_corr); + blurred host-side with dt_gaussian, then consumed by _develop below */ +__kernel void spektrafilm_develop_corr(__global const float4 *lograw, __global float4 *corr, + const int w, const int h, + __global const float *curves_norm, + __constant float *mats, const float g0, + const float g1, const float g2, const float le0, + const float le_step, const float dmax0, + const float dmax1, const float dmax2, + const int positive) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 lg = lograw[k]; + const float gam[3] = { g0, g1, g2 }; + const float dmx[3] = { dmax0, dmax1, dmax2 }; + const float lgv[3] = { lg.x, lg.y, lg.z }; + float silver[3]; + for(int c = 0; c < 3; c++) + { + const float d = sf_curve(curves_norm, lgv[c], gam[c], le0, le_step, c); + silver[c] = positive ? dmx[c] - d : d; + /* Langmuir donor saturation (dev packs); K=1e30 degenerates to linear */ + const float K = mats[SF_M_LM_DONOR + c], Dref = mats[SF_M_LM_DONOR + 3 + c]; + silver[c] = silver[c] * (K + Dref) / (K + silver[c]); + } + __constant const float *M = mats + SF_M_COUPLERS; /* row donor -> col receiver */ + float out[3]; + for(int m = 0; m < 3; m++) + out[m] = silver[0] * M[0 * 3 + m] + silver[1] * M[1 * 3 + m] + silver[2] * M[2 * 3 + m]; + corr[k] = (float4)(out[0], out[1], out[2], 0.0f); +} + +/* stage 3c: develop to CMY film density (spektra_sim: sf_sim_develop). + `curves` is curves_before when couplers are on, curves_norm otherwise. */ +__kernel void spektrafilm_develop(__global const float4 *lograw, __global const float4 *corr, + const int use_corr, __global float4 *cmy, const int w, + const int h, __global const float *curves, + __constant float *mats, const float g0, + const float g1, const float g2, const float le0, + const float le_step) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 lg = lograw[k]; + float4 cr = use_corr ? corr[k] : (float4)(0.0f); + /* receiver-side Langmuir on the ARRIVED (post-diffusion) inhibitor; + Kr=1e30 degenerates to linear */ + float crv[3] = { cr.x, cr.y, cr.z }; + for(int c = 0; c < 3; c++) + { + const float Kr = mats[SF_M_LM_RECV + c], cref = mats[SF_M_LM_RECV + 3 + c]; + crv[c] = crv[c] * (Kr + cref) / (Kr + crv[c]); + } + const float gam[3] = { g0, g1, g2 }; + const float lgv[3] = { lg.x - crv[0], lg.y - crv[1], lg.z - crv[2] }; + float out[3]; + for(int c = 0; c < 3; c++) out[c] = sf_curve(curves, lgv[c], gam[c], le0, le_step, c); + cmy[k] = (float4)(out[0], out[1], out[2], lg.w); +} + +/* stage 4: grain delta on the developed CMY density (spektra_core model); + delta is blurred host-side, then added back by spektrafilm_grain_add */ +__kernel void spektrafilm_grain_gen(__global const float4 *dens, __global float4 *grain_buf, + const int w, const int h, const float grain_amount, + const int roi_x, const int roi_y, const int mono, + const float dmax0, const float dmax1, const float dmax2, + const float dmin0, const float dmin1, const float dmin2, + const float rms0, const float rms1, const float rms2, + const float unf0, const float unf1, const float unf2) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 d4 = dens[k]; + const float dmn[3] = { dmin0, dmin1, dmin2 }; + /* per-film emulsion D-max: a hardcoded colour-negative 2.2 saturates the + particle model in dense slide areas and tints them channel-dependently */ + const float dmc[3] = { fmax(dmax0, 1e-3f), fmax(dmax1, 1e-3f), fmax(dmax2, 1e-3f) }; + /* per-film catalogue grain (rms-granularity, uniformity) from + film_render_defaults[stock].grain — replaces the earlier one-size-fits-all + constants so e.g. Portra 400 and Tri-X render distinct grain */ + const float rms[3] = { rms0, rms1, rms2 }, unf[3] = { unf0, unf1, unf2 }; + const float A48 = 3.14159265f * 24.0f * 24.0f; + const float ref_um = 10.0f, pix = ref_um * ref_um; + const float dd[3] = { d4.x, d4.y, d4.z }; + float gd[3]; + if(mono) /* B&W stock: one achromatic grain realisation for all channels */ + { + const float dm = (dd[0] + dd[1] + dd[2]) / 3.0f; + const float dmax = dmc[1] + dmn[1], d_ref = 1.0f + dmn[1]; + const float sig = rms[1] / 1000.0f; + const float denom = fmax(d_ref * (dmax - unf[1] * d_ref), 1e-6f); + const float a_grain = sig * sig * A48 / denom; + const float npart = pix / fmax(a_grain, 1e-4f); + const float din = dm + dmn[1]; + uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), 0u); + const float gval = sf_layer_particle(din, dmax, npart, unf[1], seed) - dmn[1]; + const float dl = (gval - dm) * grain_amount; + grain_buf[k] = (float4)(dl, dl, dl, 0.f); + return; + } + for(int c = 0; c < 3; c++) + { + float dmax = dmc[c] + dmn[c], din = dd[c] + dmn[c]; + float d_ref = 1.0f + dmn[c], sig = rms[c] / 1000.0f; + float denom = fmax(d_ref * (dmax - unf[c] * d_ref), 1e-6f); + float a_grain = sig * sig * A48 / denom; + float npart = pix / fmax(a_grain, 1e-4f); + uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), (uint)c); + float g = sf_layer_particle(din, dmax, npart, unf[c], seed) - dmn[c]; + gd[c] = (g - dd[c]) * grain_amount; + } + grain_buf[k] = (float4)(gd[0], gd[1], gd[2], 0.f); +} + +__kernel void spektrafilm_grain_add(__global float4 *dens_buf, __global const float4 *grain_buf, + const int w, const int h, const float renorm) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + float4 d = dens_buf[k]; + float4 g = grain_buf[k]; + dens_buf[k] = (float4)(d.x + g.x * renorm, d.y + g.y * renorm, d.z + g.z * renorm, d.w); +} + +/* stage 5a: CMY film density -> print log exposure (sf_sim_print_expose) */ +__kernel void spektrafilm_print_expose(__global const float4 *cmy, __global float4 *loge, + const int w, const int h, + __global const float *lut, __global const float *sx, + __global const float *sy, __global const float *sz, + __global const float *cmn, __global const float *cmx, + const int steps, const float lo0, const float lo1, + const float lo2, const float hi0, const float hi1, + const float hi2, const float print_exposure) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 in = cmy[k]; + const float scale = (float)(steps - 1); + const float r = (in.x - lo0) / (hi0 - lo0) * scale; + const float g = (in.y - lo1) / (hi1 - lo1) * scale; + const float b = (in.z - lo2) / (hi2 - lo2) * scale; + float3 l1 = sf_pchip3d(lut, sx, sy, sz, cmn, cmx, steps, r, g, b); + /* [st] raw = 10^l1 * print_exposure; back to log10 */ + float3 out; + out.x = log10(fmax(exp10(l1.x) * print_exposure, 0.0f) + SF_LOG_EPS); + out.y = log10(fmax(exp10(l1.y) * print_exposure, 0.0f) + SF_LOG_EPS); + out.z = log10(fmax(exp10(l1.z) * print_exposure, 0.0f) + SF_LOG_EPS); + loge[k] = (float4)(out.x, out.y, out.z, in.w); +} + +/* stage 5b: print log exposure -> print CMY density (sf_sim_print_develop) */ +__kernel void spektrafilm_print_develop(__global const float4 *loge, __global float4 *cmy, + const int w, const int h, + __global const float *print_curves, const float le0, + const float le_step) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 in = loge[k]; + const float lgv[3] = { in.x, in.y, in.z }; + float out[3]; + for(int c = 0; c < 3; c++) + out[c] = sf_curve(print_curves, lgv[c], 1.0f, le0, le_step, c); + cmy[k] = (float4)(out[0], out[1], out[2], in.w); +} + +/* stage 6: scan — CMY density -> log XYZ (PCHIP) -> XYZ -> work RGB with + OkLCh (mode 1) / ACES RGC (mode 2) gamut compression. Runs on the OUTPUT + grid, cropping (ox, oy) from the full-ROI plane and taking alpha from the + input image (spektra_sim: sf_sim_scan). */ +__kernel void spektrafilm_scan(__global const float4 *cmy, __read_only image2d_t in, + __write_only image2d_t out, const int w, const int ow, + const int oh, const int ox, const int oy, + __global const float *lut, __global const float *sx, + __global const float *sy, __global const float *sz, + __global const float *cmn, __global const float *cmx, + const int steps, const float lo0, const float lo1, + const float lo2, const float hi0, const float hi1, + const float hi2, __constant float *mats, + __global const float *cmax_table, const int cmax_nl, + const int cmax_nh, const int compress_mode, + const int bw_on, const float bw_m, const float bw_q) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= ow || y >= oh) return; + const size_t k = (size_t)(y + oy) * w + (x + ox); + const float4 c4 = cmy[k]; + const float scale = (float)(steps - 1); + const float r = (c4.x - lo0) / (hi0 - lo0) * scale; + const float g = (c4.y - lo1) / (hi1 - lo1) * scale; + const float b = (c4.z - lo2) / (hi2 - lo2) * scale; + float3 lx = sf_pchip3d(lut, sx, sy, sz, cmn, cmx, steps, r, g, b); + float3 xyz = (float3)(exp10(lx.x), exp10(lx.y), exp10(lx.z)); + if(bw_on) /* scanner black/white point (positive film scans) */ + { + const float yc = sf_clampf(bw_m * xyz.y + bw_q, 0.0f, 1.0f); + xyz *= yc / (xyz.y + 1e-10f); + } + float3 rgb = sf_mat3(mats + SF_M_OUT, xyz); + + if(compress_mode == 1) /* OkLCh chroma + lightness compression */ + { + float3 lab = sf_xyz_to_oklab(mats, sf_mat3(mats + SF_M_RGB2XYZ, rgb)); + float L = sf_knee(lab.x, 0.7f, 1.0f, 2.2f); /* lightness first */ + const float C = hypot(lab.y, lab.z); + const float hh = atan2(lab.z, lab.y); + const float C_max = fmax(sf_cmax_lookup(cmax_table, cmax_nl, cmax_nh, L, hh), 1e-9f); + const float d = sf_knee(C / C_max, 0.0f, 1.0f, 6.0f); + const float C_new = d * C_max; + float3 lab_new = (float3)(L, C_new * cos(hh), C_new * sin(hh)); + rgb = sf_mat3(mats + SF_M_XYZ2RGB, sf_oklab_to_xyz(mats, lab_new)); + } + else if(compress_mode == 2) /* ACES reference gamut compression style */ + { + const float ach = fmax(rgb.x, fmax(rgb.y, rgb.z)); + if(ach > 1e-12f) + { + float v[3] = { rgb.x, rgb.y, rgb.z }; + for(int c = 0; c < 3; c++) + { + const float d = (ach - v[c]) / ach; + const float dc = sf_knee(d, 0.0f, 1.0f, 6.0f); + v[c] = ach * (1.0f - dc); + } + rgb = (float3)(v[0], v[1], v[2]); + } + } + + const float4 px = read_imagef(in, sampleri, (int2)(x + ox, y + oy)); + write_imagef(out, (int2)(x, y), (float4)(rgb.x, rgb.y, rgb.z, px.w)); +} + +/* passthrough crop when no sim is available */ +__kernel void spektrafilm_passthrough(__read_only image2d_t in, __write_only image2d_t out, + const int ow, const int oh, const int ox, const int oy) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= ow || y >= oh) return; + write_imagef(out, (int2)(x, y), read_imagef(in, sampleri, (int2)(x + ox, y + oy))); +} + +/* ======================================================================== */ +/* spatial-effect kernels (identical to the LUT module's; blurs host-side) */ +/* ======================================================================== */ + +__kernel void spektrafilm_scatter_combine(__global const float4 *core, __global const float4 *tail, + __global float4 *out, const int w, const int h, + const float ws_r, const float ws_g, const float ws_b) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + size_t k = (size_t)y * w + x; + float4 c = core[k], t = tail[k], o; + o.x = (1.f - ws_r) * c.x + ws_r * t.x; + o.y = (1.f - ws_g) * c.y + ws_g * t.y; + o.z = (1.f - ws_b) * c.z + ws_b * t.z; + o.w = c.w; + out[k] = o; +} + +__kernel void spektrafilm_accum(__global const float4 *blurred, __global float4 *acc, const int w, + const int h, const float wk, const int reset) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + size_t k = (size_t)y * w + x; + float4 b = blurred[k]; + float4 a = reset ? (float4)(0.f) : acc[k]; + a.x += wk * b.x; + a.y += wk * b.y; + a.z += wk * b.z; + acc[k] = a; +} + +__kernel void spektrafilm_halation_apply(__global float4 *raw, __global const float4 *blur, + const int w, const int h, const float a_r, const float a_g, + const float a_b) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + size_t k = (size_t)y * w + x; + float4 r = raw[k], b = blur[k]; + r.x = (r.x + a_r * b.x) / (1.f + a_r); + r.y = (r.y + a_g * b.y) / (1.f + a_g); + r.z = (r.z + a_b * b.z) / (1.f + a_b); + raw[k] = r; +} + +__kernel void spektrafilm_max_partials(__global const float4 *plane, const int npix, + __global float *partials, const int npartials) +{ + const int gid = get_global_id(0); + if(gid >= npartials) return; + float m = 0.0f; + for(int i = gid; i < npix; i += npartials) + { + float4 p = plane[i]; + m = fmax(m, fmax(p.x, fmax(p.y, p.z))); + } + partials[gid] = m; +} + +__kernel void spektrafilm_boost(__global float4 *plane, const int w, const int h, + const float boost_ev, const float boost_range, + const float protect_ev, const float maxv) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const int k = y * w + x; + if(boost_ev <= 0.0f || maxv <= 0.0f) return; + + const float midgray = 0.184f; + const float rng = fmin(fmax(boost_range, 0.0f), 1.0f); + const float raw_x0 = midgray * exp2(fmax(protect_ev, 0.0f)); + if(raw_x0 >= maxv) return; + const float a = pow(28.0f, 1.0f - rng); + const float x0 = raw_x0 / maxv; + const float denom = exp(a * (1.0f - x0)) - a * (1.0f - x0) - 1.0f; + if(denom <= 0.0f) return; + const float kk = (exp2(boost_ev) - 1.0f) / denom; + const float inv_max = 1.0f / maxv, boost_scale = kk * maxv; + + float4 p = plane[k]; + float v[3] = { p.x, p.y, p.z }; + for(int c = 0; c < 3; c++) + { + if(v[c] > raw_x0) + { + const float dx = (v[c] - raw_x0) * inv_max; + v[c] = v[c] + boost_scale * (exp(a * dx) - a * dx - 1.0f); + } + } + plane[k] = (float4)(v[0], v[1], v[2], p.w); +} + +__kernel void spektrafilm_diffusion_accum(__global const float4 *blurred, __global float4 *acc, + const int w, const int h, const float wr, const float wg, + const float wb, const int reset) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const int k = y * w + x; + float4 b = blurred[k]; + float4 a = reset ? (float4)(0.f, 0.f, 0.f, 0.f) : acc[k]; + acc[k] = (float4)(a.x + wr * b.x, a.y + wg * b.y, a.z + wb * b.z, b.w); +} + +__kernel void spektrafilm_diffusion_mix(__global float4 *plane, __global const float4 *acc, + const int w, const int h, const float p_s) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const int k = y * w + x; + float4 e = plane[k], s = acc[k]; + plane[k] = (float4)((1.f - p_s) * e.x + p_s * s.x, (1.f - p_s) * e.y + p_s * s.y, + (1.f - p_s) * e.z + p_s * s.z, e.w); +} diff --git a/src/common/iop_order.c b/src/common/iop_order.c index 1cb9effaa6f9..db120a66cd10 100644 --- a/src/common/iop_order.c +++ b/src/common/iop_order.c @@ -144,6 +144,7 @@ const dt_iop_order_entry_t legacy_order[] = { { {45.5f }, "agx", 0}, { {46.0f }, "filmic", 0}, { {46.5f }, "filmicrgb", 0}, + { { 46.7f }, "spektrafilm", 0 }, { {47.0f }, "colisa", 0}, { {48.0f }, "zonesystem", 0}, { {49.0f }, "tonecurve", 0}, @@ -258,6 +259,7 @@ const dt_iop_order_entry_t v30_order[] = { { {45.3f }, "sigmoid", 0}, { {45.5f }, "agx", 0}, { {46.0f }, "filmicrgb", 0}, // same, upgraded + { { 46.7f }, "spektrafilm", 0 }, { {36.0f }, "lut3d", 0}, // apply a creative style or film emulation, possibly non-linear { {47.0f }, "colisa", 0}, // edit contrast while damaging colour { {48.0f }, "tonecurve", 0}, // same @@ -377,6 +379,7 @@ const dt_iop_order_entry_t v50_order[] = { { {45.3f }, "sigmoid", 0}, { {45.5f }, "agx", 0}, { {46.0f }, "filmicrgb", 0}, // same, upgraded + { { 46.7f }, "spektrafilm", 0 }, { {36.0f }, "lut3d", 0}, // apply a creative style or film emulation, possibly non-linear { {47.0f }, "colisa", 0}, // edit contrast while damaging colour { {48.0f }, "tonecurve", 0}, // same @@ -497,6 +500,7 @@ const dt_iop_order_entry_t v30_jpg_order[] = { { {45.5f }, "agx", 0}, { { 45.3f }, "sigmoid", 0}, { { 46.0f }, "filmicrgb", 0 }, // same, upgraded + { { 46.7f }, "spektrafilm", 0 }, { { 36.0f }, "lut3d", 0 }, // apply a creative style or film emulation, possibly non-linear { { 47.0f }, "colisa", 0 }, // edit contrast while damaging colour { { 48.0f }, "tonecurve", 0 }, // same @@ -619,6 +623,7 @@ const dt_iop_order_entry_t v50_jpg_order[] = { { { 45.3f }, "sigmoid", 0}, { {45.5f }, "agx", 0}, { { 46.0f }, "filmicrgb", 0 }, // same, upgraded + { { 46.7f }, "spektrafilm", 0 }, { { 36.0f }, "lut3d", 0 }, // apply a creative style or film emulation, possibly non-linear { { 47.0f }, "colisa", 0 }, // edit contrast while damaging colour { { 48.0f }, "tonecurve", 0 }, // same diff --git a/src/iop/CMakeLists.txt b/src/iop/CMakeLists.txt index 92a69c1037f9..e011ffea0884 100644 --- a/src/iop/CMakeLists.txt +++ b/src/iop/CMakeLists.txt @@ -98,6 +98,7 @@ add_iop(rawoverexposed "rawoverexposed.c") add_iop(velvia "velvia.c") add_iop(vignette "vignette.c") add_iop(splittoning "splittoning.c") +add_iop(spektrafilm "spektrafilm.c" "spektra_sim.c" "spektra_core.c") add_iop(grain "grain.c") add_iop(clahe "clahe.c") add_iop(bilateral "bilateral.cc") diff --git a/src/iop/spektra_core.c b/src/iop/spektra_core.c new file mode 100644 index 000000000000..beae981cf687 --- /dev/null +++ b/src/iop/spektra_core.c @@ -0,0 +1,441 @@ +/* + 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 . +*/ + +/* spektrafilm — spatial effects (grain blur and halation). + * + * These two operations are the only parts of the film simulation that a static + * LUT cannot carry, because they are neighbour-dependent. They live here (rather + * than in the inline-only spektra_core.h) so they can use darktable's Gaussian + * blur (dt_gaussian) instead of a hand-rolled kernel. The math is unchanged from + * the spektrafilm / agx-emulsion reference; only the blur backend differs, so + * edge handling now follows dt_gaussian (DT_IOP_GAUSSIAN_ZERO) rather than the + * previous edge-replicate. + */ + +#include "common/darktable.h" +#include "common/gaussian.h" +#include "common/imagebuf.h" + +#include +#include + +/* The bundle-loader half of spektra_core.h needs file-IO/locale helpers. darktable + poisons bare libc fopen, so map them to glib here (this .c does not itself read + bundles, but the shared header must compile in this translation unit). */ +#include +/* whole-file slurp via glib (g_file_get_contents returns a NUL-terminated, + g_free-owned buffer); maps the header's SF_READ_FILE/SF_FREE_FILE. */ +#define SF_READ_FILE(path, out, len) \ + (g_file_get_contents((path), (out), (len), NULL) ? 0 : -1) +#define SF_FREE_FILE(buf) g_free(buf) +#define SF_STRTOD(s, end) g_ascii_strtod((s), (end)) +#include "spektra_core.h" + +/* Blur one channel `c` of a packed w*h*3 float buffer in place, with the given + * sigma (in pixels), using darktable's Gaussian. The channel is de-interleaved + * into a scratch single-channel plane, blurred, and written back. Per-channel + * sigmas (halation uses a different sigma per R/G/B) are handled by calling this + * once per channel. */ +static void _blur_channel(float *const buf, const int w, const int h, const int c, + const float sigma, float *const plane) +{ + if(sigma < 1e-6f) return; + const size_t npix = (size_t)w * h; + for(size_t i = 0; i < npix; i++) plane[i] = buf[i * 3 + c]; + + const float range = 1.0e9f; /* grain delta / linear light: effectively unbounded */ + const float vmax = range, vmin = -range; + dt_gaussian_t *g = dt_gaussian_init(w, h, 1, &vmax, &vmin, sigma, DT_IOP_GAUSSIAN_ZERO); + if(g) + { + dt_gaussian_blur(g, plane, plane); + dt_gaussian_free(g); + } + for(size_t i = 0; i < npix; i++) buf[i * 3 + c] = plane[i]; +} + +/* Blur all three channels of a packed buffer with the same sigma (grain). */ +void sf_blur_plane3(float *const buf, const int w, const int h, const float sigma, float *const plane) +{ + if(sigma < 0.3f) return; + for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma, plane); +} + +/* Blur a packed buffer with per-channel sigma (scatter / halation passes). */ +static void _blur_per_channel(float *const buf, const int w, const int h, const float sigma[3], + float *const plane) +{ + for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma[c], plane); +} + +/* Apply halation + scatter to a w*h*3 LINEAR plane, in place. + * + * Two stages, both physically motivated and run on linear irradiance: + * 1. Scatter (the emulsion point-spread function): a narrow core Gaussian plus + * a wide three-Gaussian tail, mixed per channel. + * 2. Multi-bounce halation: N reflections off the film base, each a wider + * Gaussian, weighted by a decaying series, mixed back per channel. + * + * `amount` scales the halation strength with a mild non-linearity so that 1.0 is + * the film-accurate value (red 0.05 / green 0.015 / blue 0.0) while higher values + * ramp up faster. `pixel_um` converts the micrometre-on-film radii to pixels. */ +/* Highlight boost (spektrafilm's pre-halation highlight reconstruction). On real + film the brightest highlights are clipped before they can scatter; this bows the + response upward above a threshold so blown highlights carry extra energy into the + halation/scatter that follows. Ported from spektrafilm's boost_highlights: + raw_x0 = midgray * 2^protect_ev (threshold; below it, unchanged) + a = 28^(1 - boost_range) (curve sharpness) + k = (2^boost_ev - 1) / (e^(a(1-x0)) - a(1-x0) - 1) (normaliser) + above x0: y = x + k*max * (e^(a*dx) - a*dx - 1), dx=(x-x0)/max + Operates in place on a linear w*h*3 plane; max is the plane's peak value. */ +void sf_boost_highlights(float *const raw, const int w, const int h, const float boost_ev, + const float boost_range, const float protect_ev) +{ + if(boost_ev <= 0.0f) return; + const size_t nn = (size_t)w * h * 3; + float maxv = 0.0f; + for(size_t i = 0; i < nn; i++) maxv = fmaxf(maxv, raw[i]); + if(maxv <= 0.0f) return; + + const float midgray = 0.184f; + const float rng = fminf(fmaxf(boost_range, 0.0f), 1.0f); + float raw_x0 = midgray * exp2f(fmaxf(protect_ev, 0.0f)); + if(raw_x0 > maxv) return; /* threshold above peak: nothing to boost */ + const float a = powf(28.0f, 1.0f - rng); + const float x0 = raw_x0 / maxv; + const float denom = expf(a * (1.0f - x0)) - a * (1.0f - x0) - 1.0f; + if(denom <= 0.0f) return; + const float k = (exp2f(boost_ev) - 1.0f) / denom; + const float inv_max = 1.0f / maxv, boost_scale = k * maxv; + + for(size_t i = 0; i < nn; i++) + { + const float x = raw[i]; + if(x > raw_x0) + { + const float dx = (x - raw_x0) * inv_max; + raw[i] = x + boost_scale * (expf(a * dx) - a * dx - 1.0f); + } + } +} + +void sf_halation(float *const raw, const int w, const int h, const double pixel_um, const float amount, + const float spatial_scale) +{ + if(amount <= 0.0f) return; + + /* per-channel scatter radii (um on film) and core/tail mix weights */ + static const double sc_core[3] = { 2.2, 2.0, 1.6 }; + static const double sc_tail[3] = { 9.3, 9.7, 9.1 }; + static const double w_s[3] = { 0.78, 0.65, 0.67 }; + /* tail = sum of three Gaussians (amplitude, radius multiplier) */ + static const double tail_amp[3] = { 0.1633, 0.6496, 0.1870 }; + static const double tail_rat[3] = { 0.5360, 1.5236, 2.7684 }; + /* per-channel halation strength: red/green only, blue has none on real film */ + const double eff = pow((double)amount, 1.3); + const double a_tot[3] = { 0.05 * eff, 0.015 * eff, 0.0 }; + const double first_sigma_um = 65.0; /* base bounce radius */ + const double scl = fmax((double)spatial_scale, 1e-3); /* halation size multiplier */ + const int n_bounces = 3; + const double rho = 0.5; /* bounce decay */ + + const size_t npix = (size_t)w * h; + const size_t nn = npix * 3; + float *const plane = dt_alloc_align_float(npix); /* scratch single-channel plane */ + if(!plane) return; + + /* --- stage 1: scatter PSF (core + 3-component tail) --- */ + { + float *const core = dt_alloc_align_float(nn); + float *const tail = dt_alloc_align_float(nn); + float *const comp = dt_alloc_align_float(nn); + if(core && tail && comp) + { + dt_iop_image_copy(core, raw, nn); + float sc[3]; + for(int c = 0; c < 3; c++) sc[c] = fmaxf((float)(sc_core[c] * scl / pixel_um), 1e-6f); + _blur_per_channel(core, w, h, sc, plane); + + memset(tail, 0, sizeof(float) * nn); + for(int g = 0; g < 3; g++) + { + dt_iop_image_copy(comp, raw, nn); + float lt[3]; + for(int c = 0; c < 3; c++) + lt[c] = fmaxf((float)(tail_rat[g] * (sc_tail[c] * scl / pixel_um)), 1e-6f); + _blur_per_channel(comp, w, h, lt, plane); + for(size_t i = 0; i < nn; i++) tail[i] += (float)tail_amp[g] * comp[i]; + } + for(size_t i = 0; i < nn; i++) + { + const int c = i % 3; + raw[i] = (float)((1.0 - w_s[c]) * core[i] + w_s[c] * tail[i]); + } + } + dt_free_align(core); + dt_free_align(tail); + dt_free_align(comp); + } + + /* --- stage 2: multi-bounce halation --- */ + if(a_tot[0] > 0.0 || a_tot[1] > 0.0) + { + double decay[8], dsum = 0.0; + for(int k = 1; k <= n_bounces; k++) + { + decay[k - 1] = pow(rho, k - 1); + dsum += decay[k - 1]; + } + for(int k = 0; k < n_bounces; k++) decay[k] /= dsum; + + float *const blur = dt_alloc_align_float(nn); + float *const comp = dt_alloc_align_float(nn); + if(blur && comp) + { + memset(blur, 0, sizeof(float) * nn); + for(int k = 1; k <= n_bounces; k++) + { + dt_iop_image_copy(comp, raw, nn); + const float sk = fmaxf((float)((first_sigma_um * scl / pixel_um) * sqrt((double)k)), 1e-6f); + const float sig3[3] = { sk, sk, sk }; + _blur_per_channel(comp, w, h, sig3, plane); + const float wk = (float)decay[k - 1]; + for(size_t i = 0; i < nn; i++) blur[i] += wk * comp[i]; + } + for(size_t i = 0; i < nn; i++) + { + const int c = i % 3; + raw[i] = (float)((raw[i] + a_tot[c] * blur[i]) / (1.0 + a_tot[c])); + } + } + dt_free_align(blur); + dt_free_align(comp); + } + + dt_free_align(plane); +} + +/* ---------------- diffusion filter (Black Pro-Mist family) ---------------- + * + * spektrafilm's diffusion filter is an energy-conserving scatter: + * E_out = (1 - p_s) * E_in + p_s * (K_s * E_in) + * where the per-channel PSF K_s is a sum of radial exponentials grouped into + * core / halo / bloom. Each exponential exp(-r/lambda)/(2*pi*lambda^2) has + * radial RMS lambda*sqrt(2); we approximate each as a Gaussian of that sigma so + * the whole PSF becomes a weighted bank of Gaussian blurs (dt_gaussian), summed + * per channel. The strength->p_s table, geometric lambda progressions, group + * weights and warmth redistribution are ported exactly from spektrafilm; only + * the exponential->Gaussian per-component shape is an approximation (a soft + * diffusion halo is dominated by scale, not tail shape). */ + +#define SF_DIFFUSION_MAX_COMP 4 + +typedef struct sf_diff_group_t +{ + double lambda_um; + double spread; + int n; + double alpha; /* bloom only; <=0 = uniform weights */ +} sf_diff_group_t; + +typedef struct sf_diff_family_t +{ + sf_diff_group_t core, halo, bloom; + double w_c, w_h, w_b; + double total_gain; /* family scatter gain in strength->p_s */ +} sf_diff_family_t; + +/* Black Pro-Mist (the app default family). */ +static const sf_diff_family_t SF_FAMILY_BPM = { + { 16.0, 1.5, 2, 0.0 }, { 95.0, 2.0, 3, 0.0 }, { 380.0, 2.5, 4, 3.5 }, + 0.40, 0.47, 0.13, 0.75 +}; + +static const double SF_DIFF_BREAKS[5] = { 0.125, 0.25, 0.5, 1.0, 2.0 }; +static const double SF_DIFF_FRAC[5] = { 0.10, 0.20, 0.35, 0.55, 0.75 }; +static const double SF_HALO_WARMTH_AXIS[3] = { 1.30, 0.15, -1.45 }; + +/* strength -> deflected fraction p_s (log2-interpolated table * family gain) */ +static double sf_diff_strength_to_ps(double strength, const sf_diff_family_t *fam) +{ + if(strength <= 0.0) return 0.0; + const double ls = log2(fmax(strength, 1e-6)); + double base; + if(ls <= log2(SF_DIFF_BREAKS[0])) base = SF_DIFF_FRAC[0]; + else if(ls >= log2(SF_DIFF_BREAKS[4])) base = SF_DIFF_FRAC[4]; + else + { + base = SF_DIFF_FRAC[4]; + for(int i = 0; i < 4; i++) + { + const double lo = log2(SF_DIFF_BREAKS[i]), hi = log2(SF_DIFF_BREAKS[i + 1]); + if(ls >= lo && ls <= hi) + { + const double t = (ls - lo) / (hi - lo); + base = SF_DIFF_FRAC[i] + t * (SF_DIFF_FRAC[i + 1] - SF_DIFF_FRAC[i]); + break; + } + } + } + return fmin(fmax(base * fam->total_gain, 0.0), 0.99); +} + +/* expand a group into (lambda_um[], weight[]) summing to 1; returns count */ +static int sf_diff_expand(const sf_diff_group_t *g, const char is_bloom, double lam[SF_DIFFUSION_MAX_COMP], + double wgt[SF_DIFFUSION_MAX_COMP]) +{ + int n = g->n < 1 ? 1 : (g->n > SF_DIFFUSION_MAX_COMP ? SF_DIFFUSION_MAX_COMP : g->n); + if(n == 1 || g->spread <= 1.0) + { + lam[0] = g->lambda_um; + wgt[0] = 1.0; + return 1; + } + const double llo = log(g->lambda_um / g->spread), lhi = log(g->lambda_um * g->spread); + double wsum = 0.0; + for(int k = 0; k < n; k++) + { + lam[k] = exp(llo + (lhi - llo) * k / (n - 1)); + wgt[k] = is_bloom ? pow(lam[k], 2.0 - g->alpha) : 1.0; + wsum += wgt[k]; + } + for(int k = 0; k < n; k++) wgt[k] /= wsum; + return n; +} + +/* per-channel halo weights after energy-conserving warmth redistribution */ +static void sf_diff_halo_warmth(const double *wgt, int n, double warmth, double out[3][SF_DIFFUSION_MAX_COMP]) +{ + if(n < 2) + { + for(int c = 0; c < 3; c++) + for(int k = 0; k < n; k++) out[c][k] = wgt[k]; + return; + } + warmth = fmin(fmax(warmth, -1.5), 1.5); + double g[SF_DIFFUSION_MAX_COMP], gmean = 0.0, tt = 0.0; + for(int k = 0; k < n; k++) + { + g[k] = -1.0 + 2.0 * k / (n - 1); + gmean += wgt[k] * g[k]; + tt += wgt[k]; + } + gmean /= tt; /* weighted mean, to re-centre */ + for(int k = 0; k < n; k++) g[k] -= gmean; + for(int c = 0; c < 3; c++) + { + double s = 0.0, raw[SF_DIFFUSION_MAX_COMP]; + for(int k = 0; k < n; k++) + { + raw[k] = wgt[k] * (1.0 + warmth * SF_HALO_WARMTH_AXIS[c] * g[k]); + if(raw[k] < 0.0) raw[k] = 0.0; + s += raw[k]; + } + for(int k = 0; k < n; k++) out[c][k] = (s > 0.0) ? raw[k] * (tt / s) : wgt[k]; + } +} + +/* Build the shared Gaussian bank (used by both CPU and GPU). */ +int sf_diffusion_build_plan(float strength, float halo_warmth, sf_diffusion_plan_t *plan) +{ + plan->n = 0; + plan->p_s = 0.0f; + const sf_diff_family_t *fam = &SF_FAMILY_BPM; + const double p_s = sf_diff_strength_to_ps((double)strength, fam); + if(p_s <= 0.0) return 0; + + double clam[SF_DIFFUSION_MAX_COMP], cw[SF_DIFFUSION_MAX_COMP]; + double hlam[SF_DIFFUSION_MAX_COMP], hw[SF_DIFFUSION_MAX_COMP]; + double blam[SF_DIFFUSION_MAX_COMP], bw[SF_DIFFUSION_MAX_COMP]; + const int nc = sf_diff_expand(&fam->core, 0, clam, cw); + const int nh = sf_diff_expand(&fam->halo, 0, hlam, hw); + const int nb = sf_diff_expand(&fam->bloom, 1, blam, bw); + double hch[3][SF_DIFFUSION_MAX_COMP]; + sf_diff_halo_warmth(hw, nh, (double)halo_warmth, hch); + + const double L2 = 1.4142135623730951; /* exp(-r/lambda) ~ Gaussian sigma=lambda*sqrt(2) */ + int idx = 0; + for(int k = 0; k < nc; k++) /* core: channel-independent */ + { + plan->sigma_um[idx] = (float)(clam[k] * L2); + plan->wr[idx] = plan->wg[idx] = plan->wb[idx] = (float)(fam->w_c * cw[k]); + idx++; + } + for(int k = 0; k < nh; k++) /* halo: per channel (warmth) */ + { + plan->sigma_um[idx] = (float)(hlam[k] * L2); + plan->wr[idx] = (float)(fam->w_h * hch[0][k]); + plan->wg[idx] = (float)(fam->w_h * hch[1][k]); + plan->wb[idx] = (float)(fam->w_h * hch[2][k]); + idx++; + } + for(int k = 0; k < nb; k++) /* bloom: channel-independent */ + { + plan->sigma_um[idx] = (float)(blam[k] * L2); + plan->wr[idx] = plan->wg[idx] = plan->wb[idx] = (float)(fam->w_b * bw[k]); + idx++; + } + plan->n = idx; + plan->p_s = (float)p_s; + return 1; +} + +/* Apply the diffusion filter in place on a linear w*h*3 plane. */ +void sf_diffusion_filter(float *const raw, const int w, const int h, const double pixel_um, + const float strength, const float spatial_scale, const float halo_warmth) +{ + if(strength <= 0.0f || spatial_scale <= 0.0f) return; + sf_diffusion_plan_t plan; + if(!sf_diffusion_build_plan(strength, halo_warmth, &plan) || plan.p_s <= 0.0f) return; + + const double sc = fmax((double)spatial_scale, 1e-6); + const size_t npix = (size_t)w * h, nn = npix * 3; + + float *const acc = dt_alloc_align_float(nn); + float *const comp = dt_alloc_align_float(nn); + float *const plane1 = dt_alloc_align_float(npix); + if(!acc || !comp || !plane1) + { + dt_free_align(acc); + dt_free_align(comp); + dt_free_align(plane1); + return; + } + memset(acc, 0, sizeof(float) * nn); + + for(int j = 0; j < plan.n; j++) + { + const float sigma = (float)(plan.sigma_um[j] * sc / fmax(pixel_um, 1e-3)); + dt_iop_image_copy(comp, raw, nn); + for(int c = 0; c < 3; c++) _blur_channel(comp, w, h, c, sigma, plane1); + const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j]; + for(size_t i = 0; i < npix; i++) + { + acc[i * 3 + 0] += wr * comp[i * 3 + 0]; + acc[i * 3 + 1] += wg * comp[i * 3 + 1]; + acc[i * 3 + 2] += wb * comp[i * 3 + 2]; + } + } + + const float ps = plan.p_s; + for(size_t i = 0; i < nn; i++) raw[i] = (1.0f - ps) * raw[i] + ps * acc[i]; + + dt_free_align(acc); + dt_free_align(comp); + dt_free_align(plane1); +} diff --git a/src/iop/spektra_core.h b/src/iop/spektra_core.h new file mode 100644 index 000000000000..f0c8fefb7416 --- /dev/null +++ b/src/iop/spektra_core.h @@ -0,0 +1,689 @@ +/* + 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 . +*/ + +#pragma once +#include +#include +#include +#include +#include +#include + +#ifndef SPEKTRA_INLINE +#define SPEKTRA_INLINE static inline +#endif + +/* Spatial effects implemented in spektra_core.c (they use dt_gaussian and so + need darktable linkage; everything else in this header is inline). */ +void sf_blur_plane3(float *buf, int w, int h, float sigma, float *plane); +void sf_halation(float *raw, int w, int h, double pixel_um, float amount, float spatial_scale); +void sf_boost_highlights(float *raw, int w, int h, float boost_ev, float boost_range, + float protect_ev); +void sf_diffusion_filter(float *raw, int w, int h, double pixel_um, float strength, + float spatial_scale, float halo_warmth); + +/* Diffusion-filter Gaussian bank, built host-side and consumed by the GPU path + (the CPU path builds it internally). Each entry is one Gaussian blur of the + linear plane, with a per-channel weight; the scattered image is their sum, and + the final mix is (1-p_s)*in + p_s*scatter. */ +#define SF_DIFFUSION_MAX_BANK 11 /* core(2) + halo(3) + bloom(4) + margin */ +typedef struct sf_diffusion_plan_t +{ + int n; /* number of Gaussian components */ + float sigma_um[SF_DIFFUSION_MAX_BANK]; /* blur sigma in micrometres (×scale/pixel = px) */ + float wr[SF_DIFFUSION_MAX_BANK]; /* per-channel weight (already ×group weight) */ + float wg[SF_DIFFUSION_MAX_BANK]; + float wb[SF_DIFFUSION_MAX_BANK]; + float p_s; /* scatter fraction */ +} sf_diffusion_plan_t; + +/* Fill `plan` for the given strength/warmth. Returns 0 and sets plan->p_s=0 when + the filter is a no-op. spatial_scale/pixel are applied by the caller (sigma_px + = sigma_um * spatial_scale / pixel_um). */ +int sf_diffusion_build_plan(float strength, float halo_warmth, sf_diffusion_plan_t *plan); + + +/* Whole-file reader for the bundle loader (bundle.json and the .cube LUTs are + small enough to slurp). Inside darktable the including .c maps these to glib + (g_file_get_contents / g_free); darktable poisons bare libc fopen, so no libc + fallback is emitted in a darktable translation unit. The standalone unit test + (-DSF_STANDALONE) gets a small stdio-based fallback. + + SF_READ_FILE(path, char **out_buf, size_t *out_len) -> 0 on success, the buffer + is NUL-terminated and owned by the caller, freed with SF_FREE_FILE. */ +#ifndef SF_READ_FILE +#ifdef SF_STANDALONE +#include +SPEKTRA_INLINE int sf_read_file_stdio(const char *path, char **out, size_t *len) +{ + FILE *f = fopen(path, "rb"); + if(!f) return -1; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if(sz < 0) { fclose(f); return -1; } + char *b = (char *)malloc((size_t)sz + 1); + if(!b) { fclose(f); return -1; } + if(fread(b, 1, (size_t)sz, f) != (size_t)sz) { free(b); fclose(f); return -1; } + b[sz] = 0; + fclose(f); + *out = b; + if(len) *len = (size_t)sz; + return 0; +} +#define SF_READ_FILE(path, out, len) sf_read_file_stdio((path), (out), (len)) +#define SF_FREE_FILE(buf) free(buf) +#else +#error "SF_READ_FILE must be defined (map to g_file_get_contents) before including spektra_core.h" +#endif +#endif + +/* Locale-independent ASCII float parse. darktable runs under the user locale + (e.g. de_DE uses ',' as decimal), but .cube / bundle.json always use '.'. + sscanf("%f")/strtod honour LC_NUMERIC, so we must not use them. The module + maps SF_STRTOD to g_ascii_strtod; standalone uses a small C-locale parser. */ +#ifndef SF_STRTOD +SPEKTRA_INLINE double sf_ascii_strtod(const char *s, char **end) +{ + while(*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++; + double sign = 1.0; + if(*s == '+') + s++; + else if(*s == '-') + { + sign = -1.0; + s++; + } + double val = 0.0; + int any = 0; + while(*s >= '0' && *s <= '9') + { + val = val * 10.0 + (*s - '0'); + s++; + any = 1; + } + if(*s == '.') + { + s++; + double f = 0.0, sc = 1.0; + while(*s >= '0' && *s <= '9') + { + f = f * 10.0 + (*s - '0'); + sc *= 10.0; + s++; + any = 1; + } + val += f / sc; + } + if(any && (*s == 'e' || *s == 'E')) + { + s++; + int es = 1, e = 0; + if(*s == '+') + s++; + else if(*s == '-') + { + es = -1; + s++; + } + while(*s >= '0' && *s <= '9') + { + e = e * 10 + (*s - '0'); + s++; + } + double m = 1.0; + for(int i = 0; i < e; i++) m *= 10.0; + val = es > 0 ? val * m : val / m; + } + if(end) *end = (char *)s; + return any ? sign * val : 0.0; +} +#define SF_STRTOD(s, end) sf_ascii_strtod((s), (end)) +#endif + +SPEKTRA_INLINE float sf_clampf(float x, float lo, float hi) +{ + return x < lo ? lo : (x > hi ? hi : x); +} + +/* ---------------- .cube + bundle ---------------- */ +typedef struct +{ + int n; + float *data; +} sf_cube_t; /* n^3 * 3, R fastest */ +typedef struct +{ + sf_cube_t film, print; + float d_min[3], d_max[3]; /* cmy_film wire */ + char name[256]; /* bundle dir name */ + int valid; + int is_positive; /* slide/reversal film: film cube has inverted density slope */ + int is_combined; /* 1-LUT (combined rgb_in->rgb_out) bundle: one cube in `film`, + no density split, no `print`. Used for B&W and any 1lut bake. */ + float input_gain; /* bundle.json input_exposure.gain: the cube was baked so that + film_pipeline(decode(coord) * gain). At runtime we sample at + coord = srgb_oetf(linear / input_gain). Default 1.0. */ +} sf_bundle_t; + +SPEKTRA_INLINE int sf_load_cube(const char *path, sf_cube_t *c) +{ + char *buf = NULL; + size_t len = 0; + if(SF_READ_FILE(path, &buf, &len) != 0 || !buf) + { +#ifdef SF_DIAG_LOG + SF_DIAG_LOG("[spektrafilm] read cube FAILED: %s\n", path); +#endif + return -1; + } + + c->n = 0; + c->data = NULL; + int idx = 0, cap = 0; + /* Walk the file buffer line by line (the .cube grammar is line-oriented): + header keywords (LUT_3D_SIZE, DOMAIN_*, TITLE) and one "r g b" triplet per + data line, with R varying fastest. */ + char *p = buf; + while(*p) + { + char *eol = p; + while(*eol && *eol != '\n') eol++; + const char hold = *eol; + *eol = 0; /* terminate this line for the parsers below */ + + char *s = p; + while(*s == ' ' || *s == '\t') s++; + if(*s == '#' || *s == '\r' || *s == 0) + { + /* comment or blank: skip */ + } + else if(!strncmp(s, "LUT_3D_SIZE", 11)) + { + c->n = atoi(s + 11); + cap = c->n * c->n * c->n * 3; + c->data = (float *)malloc(sizeof(float) * cap); + if(!c->data) + { + SF_FREE_FILE(buf); + return -1; + } + } + else if(!strncmp(s, "DOMAIN_", 7) || !strncmp(s, "TITLE", 5) || (*s >= 'A' && *s <= 'Z')) + { + /* other header keyword: skip */ + } + else + { + char *e1 = NULL, *e2 = NULL, *e3 = NULL; + const float r = (float)SF_STRTOD(s, &e1); + const float g = (float)SF_STRTOD(e1, &e2); + const float b = (float)SF_STRTOD(e2, &e3); + if(e1 != s && e2 != e1 && e3 != e2 && idx + 3 <= cap) + { + c->data[idx++] = r; + c->data[idx++] = g; + c->data[idx++] = b; + } + } + + if(hold == 0) break; + p = eol + 1; + } + SF_FREE_FILE(buf); + + if(c->n <= 0 || idx != c->n * c->n * c->n * 3) + { +#ifdef SF_DIAG_LOG + SF_DIAG_LOG("[spektrafilm] cube row/size mismatch n=%d got=%d expect=%d: %s\n", c->n, idx, + c->n * c->n * c->n * 3, path); +#endif + free(c->data); + c->data = NULL; + return -1; + } + return 0; +} +SPEKTRA_INLINE void sf_cube_free(sf_cube_t *c) +{ + free(c->data); + c->data = NULL; + c->n = 0; +} + +SPEKTRA_INLINE void sf_cube_sample(const sf_cube_t *c, const float in[3], float out[3]) +{ + const int n = c->n; + float fx = sf_clampf(in[0], 0, 1) * (n - 1), fy = sf_clampf(in[1], 0, 1) * (n - 1), + fz = sf_clampf(in[2], 0, 1) * (n - 1); + int x0 = (int)fx, y0 = (int)fy, z0 = (int)fz, x1 = x0 < n - 1 ? x0 + 1 : x0, + y1 = y0 < n - 1 ? y0 + 1 : y0, z1 = z0 < n - 1 ? z0 + 1 : z0; + float dx = fx - x0, dy = fy - y0, dz = fz - z0; +#define SFI(X, Y, Z) (((size_t)(Z) * n * n + (size_t)(Y) * n + (X)) * 3) + for(int ch = 0; ch < 3; ch++) + { + float a = c->data[SFI(x0, y0, z0) + ch] * (1 - dx) + c->data[SFI(x1, y0, z0) + ch] * dx; + float b = c->data[SFI(x0, y1, z0) + ch] * (1 - dx) + c->data[SFI(x1, y1, z0) + ch] * dx; + float cc = c->data[SFI(x0, y0, z1) + ch] * (1 - dx) + c->data[SFI(x1, y0, z1) + ch] * dx; + float d = c->data[SFI(x0, y1, z1) + ch] * (1 - dx) + c->data[SFI(x1, y1, z1) + ch] * dx; + float e = a * (1 - dy) + b * dy, g = cc * (1 - dy) + d * dy; + out[ch] = e * (1 - dz) + g * dz; + } +#undef SFI +} + +/* tiny JSON scrapes (sufficient for the fixed spektrafilm bundle.json schema) */ +SPEKTRA_INLINE int sf_scrape_float(const char *b, const char *k, float *out) +{ + /* find key k (e.g. "\"gain\"") then the number after the following ':' */ + const char *p = strstr(b, k); + if(!p) return -1; + p = strchr(p, ':'); + if(!p) return -1; + p++; + while(*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; + char *end = NULL; + double d = SF_STRTOD(p, &end); + if(end == p) return -1; + *out = (float)d; + return 0; +} + +SPEKTRA_INLINE int sf_scrape_vec3(const char *b, const char *k, float v[3]) +{ + const char *p = strstr(b, k); + if(!p) return -1; + p = strchr(p, '['); + if(!p) return -1; + p++; + for(int i = 0; i < 3; i++) + { + while(*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++; + char *end = NULL; + double d = SF_STRTOD(p, &end); + if(end == p) return -1; + v[i] = (float)d; + p = end; + } + return 0; +} +SPEKTRA_INLINE int sf_scrape_path(const char *buf, const char *role, char *out, int sz) +{ + const char *p = buf; + while((p = strstr(p, "\"role\""))) + { + const char *c = strchr(p, ':'), *q1 = c ? strchr(c, '"') : 0, + *q2 = q1 ? strchr(q1 + 1, '"') : 0; + if(!q2) + { + p += 5; + continue; + } + int len = (int)(q2 - q1 - 1); + if((int)strlen(role) == len && !strncmp(q1 + 1, role, len)) + { + const char *nr = strstr(q2, "\"role\""), *pa = strstr(q2, "\"path\""); + if(!pa || (nr && pa > nr)) + { + p = q2; + continue; + } + pa = strchr(pa, ':'); + pa = strchr(pa, '"'); + if(!pa) return -1; + pa++; + const char *e = strchr(pa, '"'); + if(!e || e - pa >= sz) return -1; + memcpy(out, pa, e - pa); + out[e - pa] = 0; + return 0; + } + p = q2; + } + return -1; +} +/* load a bundle dir (containing bundle.json + the two cubes) */ +SPEKTRA_INLINE int sf_load_bundle(const char *dir, sf_bundle_t *b) +{ + memset(b, 0, sizeof *b); + char jp[1024]; + snprintf(jp, sizeof jp, "%s/bundle.json", dir); + char *buf = NULL; + size_t sz = 0; + if(SF_READ_FILE(jp, &buf, &sz) != 0 || !buf || sz == 0) + { + SF_FREE_FILE(buf); + return -1; + } + /* input exposure gain (bundle.json input_exposure.gain). The cube maps + output(coord) = film_pipeline(decode(coord) * gain), so at runtime we sample + at coord = srgb_oetf(linear / gain). Default 1.0 when absent (older bundles + or stops_above_midgray=null). Scrape the "gain" key inside "input_exposure". */ + b->input_gain = 1.0f; + { + const char *ie = strstr(buf, "\"input_exposure\""); + if(ie) + { + float g = 1.0f; + if(!sf_scrape_float(ie, "\"gain\"", &g) && g > 1e-4f) b->input_gain = g; + } + } + /* 1-LUT (combined) bundle? It has a single lut with role "combined" and no + film/print density wire. Load that one cube into `film` and mark combined. */ + char cp[256] = {0}; + if(!sf_scrape_path(buf, "combined", cp, sizeof cp)) + { + SF_FREE_FILE(buf); + char full[2048]; + snprintf(full, sizeof full, "%s/%s", dir, cp); + if(sf_load_cube(full, &b->film)) return -1; + b->is_combined = 1; + b->valid = 1; + return 0; /* no density wire / print / positive-detection for combined */ + } + + int ok = + !sf_scrape_vec3(buf, "\"d_max\"", b->d_max) && !sf_scrape_vec3(buf, "\"d_min\"", b->d_min); + char fp[256] = {0}, pp[256] = {0}; + ok = ok && !sf_scrape_path(buf, "film", fp, sizeof fp) && + !sf_scrape_path(buf, "print", pp, sizeof pp); + SF_FREE_FILE(buf); + if(!ok) + { +#ifdef SF_DIAG_LOG + SF_DIAG_LOG("[spektrafilm] bundle.json parse failed (wire/paths) in %s\n", dir); +#endif + return -1; + } + char full[2048]; + snprintf(full, sizeof full, "%s/%s", dir, fp); + if(sf_load_cube(full, &b->film)) return -1; + snprintf(full, sizeof full, "%s/%s", dir, pp); + if(sf_load_cube(full, &b->print)) + { + sf_cube_free(&b->film); + return -1; + } + b->valid = 1; + + /* Detect positive (slide/reversal) film: sample the film cube at black and + white, convert to cmy_film density via the wire, and compare. Negative + films -> density rises with input; positive films -> density falls. This + needs no metadata (bundle.json omits film type) and no name matching. */ + { + float blk[3] = {0.f, 0.f, 0.f}, wht[3] = {1.f, 1.f, 1.f}, fo_b[3], fo_w[3]; + sf_cube_sample(&b->film, blk, fo_b); + sf_cube_sample(&b->film, wht, fo_w); + float d_b = 0.f, d_w = 0.f; + for(int c = 0; c < 3; c++) + { + d_b += b->d_min[c] + fo_b[c] * (b->d_max[c] - b->d_min[c]); + d_w += b->d_min[c] + fo_w[c] * (b->d_max[c] - b->d_min[c]); + } + b->is_positive = (d_w < d_b) ? 1 : 0; /* white darker than black => slide */ + } + return 0; +} +SPEKTRA_INLINE void sf_bundle_free(sf_bundle_t *b) +{ + sf_cube_free(&b->film); + sf_cube_free(&b->print); + b->valid = 0; +} + +SPEKTRA_INLINE void sf_to_density(const sf_bundle_t *b, const float v[3], float d[3]) +{ + for(int c = 0; c < 3; c++) d[c] = b->d_min[c] + v[c] * (b->d_max[c] - b->d_min[c]); +} +SPEKTRA_INLINE void sf_from_density(const sf_bundle_t *b, const float d[3], float v[3]) +{ + for(int c = 0; c < 3; c++) + v[c] = sf_clampf((d[c] - b->d_min[c]) / (b->d_max[c] - b->d_min[c]), 0, 1); +} + +/* ---------------- sRGB transfer (module is scene-linear; cubes are sRGB) ---------------- */ +SPEKTRA_INLINE float sf_srgb_oetf(float x) +{ + x = x < 0 ? 0 : x; + return x <= 0.0031308f ? 12.92f * x : 1.055f * powf(x, 1.0f / 2.4f) - 0.055f; +} +SPEKTRA_INLINE float sf_srgb_eotf(float x) +{ + x = sf_clampf(x, 0, 1); + return x <= 0.04045f ? x / 12.92f : powf((x + 0.055f) / 1.055f, 2.4f); +} + +/* ---------------- grain (validated) ---------------- + * + * Grain must be random per pixel yet perfectly reproducible (stable under + * re-render, pan and zoom, and identical on CPU and GPU). So instead of a + * stateful PRNG we use a stateless integer HASH keyed on the pixel coordinates: + * hash(x, y, channel) -> a random-looking value for that exact pixel. The hash + * constants below are published, well-tested values, NOT tunable parameters; + * any good integer hash would do, and changing them only reshuffles the noise. + */ + +/* sf_h: Chris Wellons' "lowbias32" integer hash finalizer. The multipliers and + shift sequence are the published, bias-minimised constants of that algorithm. */ +SPEKTRA_INLINE uint32_t sf_h(uint32_t x) +{ + x ^= x >> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} +/* sf_u01: hash -> uniform float in [0,1) using the top 24 bits (float mantissa). */ +SPEKTRA_INLINE float sf_u01(uint32_t s) +{ + return (sf_h(s) & 0xffffff) / (float)0x1000000; +} +/* sf_nrm: two uniforms -> one standard-normal sample via the Box-Muller + transform. 6.2831853 is 2*pi; 2654435761 is Knuth's golden-ratio multiplier + (2^32 / phi), used only to decorrelate the second uniform from the first. */ +SPEKTRA_INLINE float sf_nrm(uint32_t s) +{ + float u1 = fmaxf(sf_u01(s), 1e-7f), u2 = sf_u01(s * 2654435761u + 1u); + return sqrtf(-2.f * logf(u1)) * cosf(6.2831853f * u2); +} +/* sf_layer_particle: draw the developed density of one emulsion layer as a + doubly-stochastic process. First the number of developed grains in this pixel + (mean lam, Poisson -> normal approximation), then the fraction that record + signal (binomial -> normal approximation). The 0x9e3779b9 / 0x85ebca6b offsets + are standard hash-mixing constants (golden ratio; murmurhash) that simply give + the two normal draws independent seeds. */ +/* sf_pixel_seed: combine pixel coordinates and a channel/sub-layer index into one + seed for the grain hash. The three large primes are Teschner et al.'s published + spatial-hash constants; XOR-mixing distinct primes per axis keeps neighbouring + pixels and channels from sharing a seed (which would correlate their grain). + Uses ABSOLUTE image coordinates so grain is stable while panning. */ +SPEKTRA_INLINE uint32_t sf_pixel_seed(uint32_t xi, uint32_t yi, uint32_t chan) +{ + return xi * 73856093u ^ yi * 19349663u ^ chan * 83492791u; +} + +/* Print-stage grading applied to the CMY film density before the print cube + (2lut path only). Mirrors the spektrafilm app's print controls, approximated on + the baked density rather than by re-running the paper model: + - print_exposure (stops): a uniform density shift (brighter print = less + density); dchange = -print_exposure * SF_PRINT_EV_TO_DENSITY. + - print_contrast: pivots density about a mid-grey Dp so slopes steepen/flatten. + - filtration_m / filtration_y: subtractive printing filters. Magenta rides the + green record, yellow the blue record; each adds a small per-channel density. + density[] is modified in place; d_ref is a representative mid density (mean of + the film's d_min/d_max) used as the contrast pivot. */ +#define SF_PRINT_EV_TO_DENSITY 0.30103f /* log10(2): one stop == 0.301 density */ +#define SF_FILTRATION_TO_DENSITY 0.30f /* full filtration slider == 0.30 density */ +SPEKTRA_INLINE void sf_apply_print_grading(float density[3], float d_ref, float print_exposure, + float print_contrast, float filtration_m, + float filtration_y) +{ + const float ev = -print_exposure * SF_PRINT_EV_TO_DENSITY; + for(int c = 0; c < 3; c++) + { + float v = density[c] + ev; /* print exposure */ + v = d_ref + (v - d_ref) * print_contrast; /* print contrast (pivot) */ + density[c] = v; + } + /* subtractive filters: M -> green channel (index 1), Y -> blue channel (index 2) */ + density[1] += filtration_m * SF_FILTRATION_TO_DENSITY; + density[2] += filtration_y * SF_FILTRATION_TO_DENSITY; +} + +SPEKTRA_INLINE float sf_layer_particle(float density, float dmax, float npart, float unif, + uint32_t seed) +{ + float p = sf_clampf(density / dmax, 1e-6f, 1 - 1e-6f), od = dmax / npart, + sat = 1.f - p * unif * (1 - 1e-6f), lam = npart / sat; + float seeds = lam + sqrtf(fmaxf(lam, 0)) * sf_nrm(seed * 0x9e3779b9u + 1u); + if(seeds < 0) seeds = 0; + float mean = seeds * p, var = seeds * p * (1 - p), + g = mean + sqrtf(fmaxf(var, 0)) * sf_nrm(seed * 0x85ebca6bU + 7u); + if(g < 0) g = 0; + if(g > seeds) g = seeds; + return g * od * sat; +} +/* SF_GRAIN_REF_UM: the fixed reference scale (spektrafilm's own + pixel_size_um=10) the particle model is generated at, independent of the + live pipe's pixel_um — this keeps grain CHARACTER constant across zoom. + Callers that turn the generated delta into visible clump STRUCTURE (the + blur step) must still convert this reference into real pixels via the + pipe's own pixel_um, or clump SIZE silently stops scaling with output + resolution — see the grain blur in spektrafilm.c/.cl and + _max_halo_sigma's ROI padding, all of which must agree. */ +#define SF_GRAIN_REF_UM 10.0f +/* grain on one CMY-density pixel; strength scales particle count effect via amount */ +SPEKTRA_INLINE void sf_grain_px(float dens[3], float pixel_um, float amount, float size, + uint32_t xi, uint32_t yi) +{ + const float dmin[3] = {0.03f, 0.03f, 0.03f}, dmaxc[3] = {2.2f, 2.2f, 2.2f}; + const float pscale[3] = {1.6f, 1.6f, 3.2f}, unif[3] = {0.97f, 0.99f, 0.97f}; + const int nsub = 1; + /* Grain is rendered at a FIXED reference scale (like spektrafilm's + pixel_size_um=10), NOT the live pipe pixel_um, so grain character stays + constant across zoom. The size slider scales this reference: larger size => + larger effective grain pixel => fewer particles per pixel => coarser grain. + size=1.0 reproduces the app's default look. pixel_um is unused for grain + (still used by halation). */ + const float parea = 0.2f; + const float ref_um = SF_GRAIN_REF_UM / fmaxf(size, 0.05f); /* size up => coarser grain */ + float pix = ref_um * ref_um; + (void)pixel_um; + for(int c = 0; c < 3; c++) + { + float npart = pix / (parea * pscale[c]), dmax = dmaxc[c] + dmin[c]; + float din = dens[c] + dmin[c], acc = 0; + for(int sl = 0; sl < nsub; sl++) + acc += sf_layer_particle( + din, dmax, npart, unif[c], + sf_pixel_seed(xi, yi, (uint32_t)(c + sl * 10))); + acc /= nsub; + acc -= dmin[c]; + dens[c] = dens[c] + (acc - dens[c]) * amount; /* amount=1 -> full spektrafilm grain */ + } +} + +/* apply halation+scatter to a w*h*3 LINEAR plane in place (amount scales both passes) */ +/* Compute the grain DELTA (grained density - clean density) for one pixel into + out_delta[3]. Generation matches the validated per-pixel particle model; the + visible film STRUCTURE comes from blurring this delta buffer afterwards (as + spektrafilm blurs its grain by grain.blur). Generated at a fine fixed scale so + the subsequent blur produces organic clumps rather than 1px speckle. */ +/* dmax_c: the emulsion's actual per-channel maximum density (base-subtracted). + Using a too-small value saturates the particle model in dense areas (slide + shadows) and produces a channel-dependent -- i.e. coloured -- bias. + dmin_c/rms_c/unif_c: the stock's own catalogue grain characteristics + (film_render_defaults[stock].grain in the pack — rms_granularity, + uniformity, density_min), so e.g. Portra 400 and Tri-X no longer share one + hardcoded grain signature. Callers without per-film data may pass the + SF_GRAIN_LEGACY_* arrays below to reproduce the earlier fixed look. */ +/* SF_GRAIN_REF_UM (defined above, with sf_grain_px) is reused here for the + same fine-generation reference scale. */ +SPEKTRA_INLINE void sf_grain_delta_dmax(const float dens[3], float amount, float out_delta[3], + uint32_t xi, uint32_t yi, int mono, + const float dmax_c[3], const float dmin_c[3], + const float rms_c[3], const float unif_c[3]) +{ + const float dmin[3] = { dmin_c[0], dmin_c[1], dmin_c[2] }; + const float dmaxc[3] = { fmaxf(dmax_c[0], 1e-3f), fmaxf(dmax_c[1], 1e-3f), + fmaxf(dmax_c[2], 1e-3f) }; + /* Latest spektrafilm grain model (study a90): per-channel particle area from + catalogue RMS-granularity (sigma_48 through a 48um aperture, ISO 6328): + a_grain = (rms/1000)^2 * A48 / (D_ref (Dmax - u D_ref)), D_ref = 1 + d_min. + N = pixel_area / a_grain. Generated at a fine fixed reference scale; the blur + afterwards sets visible clump size. rms/unif come from the film stock's own + catalogue data (see header comment) rather than one shared constant. */ + const float rms[3] = { rms_c[0], rms_c[1], rms_c[2] }; + const float unif[3] = { unif_c[0], unif_c[1], unif_c[2] }; + const float A48 = 3.14159265f * 24.0f * 24.0f; + const float ref_um = SF_GRAIN_REF_UM, pix = ref_um * ref_um; + /* mono (B&W / combined): the three channels carry the same value, so grain must + be ACHROMATIC — one grain realisation applied identically to all channels. + Per-channel independent grain (the colour path) would otherwise paint colour + speckle onto a grey image. Use channel 1's parameters and the mean density. */ + if(mono) + { + const float dm = (dens[0] + dens[1] + dens[2]) / 3.0f; + const float dmax = dmaxc[1] + dmin[1]; + const float d_ref = 1.0f + dmin[1]; + const float sig = rms[1] / 1000.0f; + const float denom = fmaxf(d_ref * (dmax - unif[1] * d_ref), 1e-6f); + const float a_grain = sig * sig * A48 / denom; + const float npart = pix / fmaxf(a_grain, 1e-4f); + const float din = dm + dmin[1]; + float g = sf_layer_particle(din, dmax, npart, unif[1], + sf_pixel_seed(xi, yi, 0u)) - dmin[1]; + const float d = (g - dm) * amount; + out_delta[0] = out_delta[1] = out_delta[2] = d; /* identical -> grey grain */ + return; + } + for(int c = 0; c < 3; c++) + { + const float dmax = dmaxc[c] + dmin[c]; + const float d_ref = 1.0f + dmin[c]; + const float sig = rms[c] / 1000.0f; + const float denom = fmaxf(d_ref * (dmax - unif[c] * d_ref), 1e-6f); + const float a_grain = sig * sig * A48 / denom; + const float npart = pix / fmaxf(a_grain, 1e-4f); + const float din = dens[c] + dmin[c]; + float g = sf_layer_particle(din, dmax, npart, unif[c], + sf_pixel_seed(xi, yi, (uint32_t)c)); + g -= dmin[c]; + out_delta[c] = (g - dens[c]) * amount; /* delta to be blurred then added */ + } +} + +/* Fallback catalogue values (spektrafilm's original single fixed profile) for + callers with no per-film pack data (see sf_pack_film_grain / sf_sim_film_grain3 + for the real per-stock values). */ +#define SF_GRAIN_LEGACY_DMAX { 2.2f, 2.2f, 2.2f } +#define SF_GRAIN_LEGACY_DMIN { 0.03f, 0.03f, 0.03f } +#define SF_GRAIN_LEGACY_RMS { 6.0f, 8.0f, 10.0f } +#define SF_GRAIN_LEGACY_UNIFORMITY { 0.97f, 0.97f, 0.97f } + +SPEKTRA_INLINE void sf_grain_delta(const float dens[3], float amount, float out_delta[3], + uint32_t xi, uint32_t yi, int mono) +{ + const float legacy_dmax[3] = SF_GRAIN_LEGACY_DMAX; + const float legacy_dmin[3] = SF_GRAIN_LEGACY_DMIN; + const float legacy_rms[3] = SF_GRAIN_LEGACY_RMS; + const float legacy_unif[3] = SF_GRAIN_LEGACY_UNIFORMITY; + sf_grain_delta_dmax(dens, amount, out_delta, xi, yi, mono, legacy_dmax, legacy_dmin, + legacy_rms, legacy_unif); +} diff --git a/src/iop/spektra_sim.c b/src/iop/spektra_sim.c new file mode 100644 index 000000000000..50d80b545ac2 --- /dev/null +++ b/src/iop/spektra_sim.c @@ -0,0 +1,2458 @@ +/* + 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 . +*/ + +/* spektra_sim.c — native port of the spektrafilm runtime (see spektra_sim.h). + * + * Ported from spektrafilm 0.3.3 (GPLv3, Andrea Volpato). Section markers + * reference the Python files each block mirrors so future spektrafilm + * releases can be diffed against this port: + * + * [su] utils/spectral_upsampling.py tc_lut build, tri/quad transforms + * [gc] utils/gamut_compression.py Reinhard knee, xy radial, oklch + * [fi] utils/fast_interp_lut.py Mitchell 2D cubic, PCHIP 3D + * [dc] model/density_curves.py exposure->density interpolation + * [cp] model/couplers.py DIR coupler chemistry + * [mc] utils/morph_curves.py s023 print-curve morph (cdfs) + * [cf] model/color_filters.py dichroic enlarger filters + * [st] runtime stage modules (stages dir) stage orchestration & constants + */ + +#include "spektra_sim.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#define SF_LOG_EPS 1e-10 +#define SF_TC_KNEE_T 0.0 /* [gc] InputGamutCompressSpec.knee */ +#define SF_TC_KNEE_L 1.0 +#define SF_TC_KNEE_P 6.0 +#define SF_OUT_KNEE_T 0.0 /* [gc] OutputGamutCompressSpec.knee */ +#define SF_OUT_KNEE_L 1.0 +#define SF_OUT_KNEE_P 6.0 +#define SF_OUT_LIGHT_T 0.7 /* [gc] lightness_compression default */ +#define SF_OUT_LIGHT_L 1.0 +#define SF_OUT_LIGHT_P 2.2 +#define SF_CMAX_NL 64 /* [gc] _OKLCH_CMAX_TABLE_N_L */ +#define SF_CMAX_NH 720 /* [gc] _OKLCH_CMAX_TABLE_N_H */ +#define SF_CMAX_NBISECT 18 +#define SF_MIDGRAY 0.184 + +/* ------------------------------------------------------------------------ */ +/* small linear algebra */ +/* ------------------------------------------------------------------------ */ + +static void mat3_mul(double out[9], const double a[9], const double b[9]) +{ + double r[9]; + for(int i = 0; i < 3; i++) + for(int j = 0; j < 3; j++) + r[3 * i + j] = a[3 * i + 0] * b[0 + j] + a[3 * i + 1] * b[3 + j] + a[3 * i + 2] * b[6 + j]; + memcpy(out, r, sizeof(r)); +} + +static void mat3_mulv(double out[3], const double m[9], const double v[3]) +{ + double r0 = m[0] * v[0] + m[1] * v[1] + m[2] * v[2]; + double r1 = m[3] * v[0] + m[4] * v[1] + m[5] * v[2]; + double r2 = m[6] * v[0] + m[7] * v[1] + m[8] * v[2]; + out[0] = r0; + out[1] = r1; + out[2] = r2; +} + +static int mat3_inv(double out[9], const double m[9]) +{ + const double a = m[0], b = m[1], c = m[2]; + const double d = m[3], e = m[4], f = m[5]; + const double g = m[6], h = m[7], i = m[8]; + const double A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g; + const double det = a * A + b * B + c * C; + if(fabs(det) < 1e-15) return 0; + const double inv = 1.0 / det; + out[0] = A * inv; + out[1] = -(b * i - c * h) * inv; + out[2] = (b * f - c * e) * inv; + out[3] = B * inv; + out[4] = (a * i - c * g) * inv; + out[5] = -(a * f - c * d) * inv; + out[6] = C * inv; + out[7] = -(a * h - b * g) * inv; + out[8] = (a * e - b * d) * inv; + return 1; +} + +/* whitepoint xy (Y=1) -> XYZ */ +static void xy_to_XYZ(double out[3], const double xy[2]) +{ + const double y = fmax(xy[1], 1e-10); + out[0] = xy[0] / y; + out[1] = 1.0; + out[2] = (1.0 - xy[0] - xy[1]) / y; +} + +/* von Kries chromatic adaptation matrix in a given cone space: + * A = M^-1 · diag(cone_dst / cone_src) · M */ +static void cat_matrix(double out[9], const double cone_m[9], const double src_xy[2], + const double dst_xy[2]) +{ + double src_XYZ[3], dst_XYZ[3], cs[3], cd[3], minv[9], d[9] = { 0 }; + xy_to_XYZ(src_XYZ, src_xy); + xy_to_XYZ(dst_XYZ, dst_xy); + mat3_mulv(cs, cone_m, src_XYZ); + mat3_mulv(cd, cone_m, dst_XYZ); + d[0] = cd[0] / cs[0]; + d[4] = cd[1] / cs[1]; + d[8] = cd[2] / cs[2]; + mat3_inv(minv, cone_m); + double tmp[9]; + mat3_mul(tmp, d, cone_m); + mat3_mul(out, minv, tmp); +} + +/* CAT16 cone matrix (Li et al. 2017) — used by spektrafilm's input side */ +static const double SF_M_CAT16[9] = { 0.401288, 0.650173, -0.051461, -0.250268, 1.204414, + 0.045854, -0.002079, 0.048952, 0.953127 }; +/* CAT02 cone matrix — colour.XYZ_to_RGB default, used by the scanning side */ +static const double SF_M_CAT02[9] = { 0.7328, 0.4286, -0.1624, -0.7036, 1.6975, + 0.0061, 0.0030, 0.0136, 0.9834 }; + +/* OkLab matrices (Ottosson 2020), as used by colour-science */ +static const double SF_OKLAB_M1[9] + = { 0.8189330101, 0.3618667424, -0.1288597137, 0.0329845436, 0.9293118715, + 0.0361456387, 0.0482003018, 0.2643662691, 0.6338517070 }; +static const double SF_OKLAB_M2[9] + = { 0.2104542553, 0.7936177850, -0.0040720468, 1.9779984951, -2.4285922050, + 0.4505937099, 0.0259040371, 0.7827717662, -0.8086757660 }; + +/* ------------------------------------------------------------------------ */ +/* internal structures */ +/* ------------------------------------------------------------------------ */ + +struct sf_pack_t +{ + char *version; + double wavelengths[SF_NWL]; + double log_exposure[SF_NLE]; + double cmfs[SF_NWL][3]; + /* spectral locus polygon (closed: first vertex repeated at the end) */ + int locus_n; /* number of vertices incl. the repeated closing vertex */ + double (*locus)[2]; + GHashTable *illuminants; /* name -> double[SF_NWL] */ + GHashTable *dichroics; /* brand -> double[SF_NWL*3] */ + JsonNode *neutral_filters; /* nested object database */ + JsonNode *film_defaults; /* per-film render defaults */ + JsonParser *parser; /* keeps the JSON tree alive */ + /* hanatos2025 irradiance spectra LUT */ + int tc_n; /* 192 */ + float *spectra; /* tc_n * tc_n * SF_NWL */ +}; + +typedef struct sf_curves_model_t +{ + int n_layers; + double centers[3][8], amplitudes[3][8], sigmas[3][8]; +} sf_curves_model_t; + +struct sf_profile_t +{ + char *stock, *name, *type, *support, *stage, *use, *antihalation; + char *target_print, *channel_model; + char *reference_illuminant, *viewing_illuminant; + double log_sensitivity[SF_NWL][3]; + double channel_density[SF_NWL][3]; + double base_density[SF_NWL]; + double log_exposure[SF_NLE]; + double density_curves[SF_NLE][3]; + int window_n; + double window_params[8]; + sf_curves_model_t curves_model; +}; + +struct sf_sim_t +{ + sf_sim_params_t p; + int film_positive; + int film_bw; /* single-emulsion stock widened to 3 channels: couple the grain */ + int print_positive; + + /* filming */ + double m_in[9]; /* input linear RGB -> XYZ adapted to film ref illuminant */ + double ev_scale; + int tc_n; + double *tc_lut; /* tc_n*tc_n*3 raw CMY exposure */ + + /* film develop */ + double le0, le_step; /* uniform log exposure grid */ + double curves_norm[SF_NLE][3]; + double curves_before[SF_NLE][3]; + double gamma[3]; + double couplers_M[3][3]; + /* Langmuir saturating couplers (spektrafilm dev/0.4+); K = INFINITY keeps + the 0.3.x linear model. Donor side (negative film): inhibitor release + g(D) = D (K + D_ref)/(K + D). Receiver side (positive/reversal film): + response S(c) = c (Kr + c_ref)/(Kr + c), applied AFTER spatial diffusion. + D_ref = d_max/2; c_ref from the amount-independent unit matrix. */ + /* DIR coupler inhibitor diffusion: gaussian core + exponential tail + (upstream models the tail as a 3-gaussian mixture, amp/ratio identical + to the halation tail constants). Per-film from film_render_defaults. */ + double coupler_diff_um, coupler_tail_um, coupler_tail_w; + double couplers_donor_K[3], couplers_donor_Dref[3]; + double couplers_recv_Kr[3], couplers_recv_cref[3]; + int couplers_donor_lm, couplers_recv_lm; /* donor row -> receiver col, scaled by amount */ + int couplers_active; + double film_dmax[3]; /* max of normalized film curves */ + double film_dmin[3]; /* the SAME curves' own floor (mn); grain's D_ref = 1+dmin + must use this, not an independently-sourced value, or + dmax_c+dmin_c no longer reconstructs the real absolute + D-max and the particle count silently drifts */ + /* per-film grain catalogue data (film_render_defaults[stock].grain); the + density floor lives in p.grain_density_min (shared with the enlarger/scan + table-range code below). Defaults to the legacy fixed constants when the + pack has no per-film grain entry (see sf_sim_build). */ + double grain_rms[3], grain_uniformity[3]; + + /* print exposure (exact spectral path) */ + int has_print; + double illum_print[SF_NWL]; /* enlarger source × dichroic pack */ + double illum_preflash[SF_NWL]; + double print_sens[SF_NWL][3]; + double film_chan_density[SF_NWL][3]; + double film_base_density[SF_NWL]; + double midgray_factor; /* scalar exposure factor (geomean logic) */ + double preflash_raw[3]; + double print_exposure; + double enl_lo[3], enl_hi[3]; + + /* print develop */ + double print_curves[SF_NLE][3]; + + /* scanning (exact spectral path) */ + double scan_chan_density[SF_NWL][3]; + double scan_base_density[SF_NWL]; + double illum_view[SF_NWL]; + double cmfs[SF_NWL][3]; + double xyz_norm; + double illum_view_xyz[3]; + double scan_lo[3], scan_hi[3]; + double m_out[9]; /* XYZ (viewing illum) -> linear output RGB (CAT02) */ + /* scanner black/white point correction (positive film scans only): + xyz *= clip(bw_m*Y + bw_q, 0, 1)/Y after xyz = 10^log_xyz. + Mirrors color_reference.black_white_xyz_correction with + black_correction = white_correction = true (levels 0.01 / 0.98). */ + int scan_bw_on; + double scan_bw_m, scan_bw_q; + + /* 3D tables + PCHIP preparation (NULL when lut_steps == 0) */ + int lut_steps; + double *enl_lut, *enl_sx, *enl_sy, *enl_sz, *enl_cmin, *enl_cmax; + double *scan_lut, *scan_sx, *scan_sy, *scan_sz, *scan_cmin, *scan_cmax; + + /* output gamut compression */ + sf_output_compress_t out_compress; + double out_rgb2xyz[9], out_xyz2rgb[9]; + double oklab_m1inv[9], oklab_m2inv[9]; + float *cmax; /* SF_CMAX_NL × SF_CMAX_NH */ +}; + +/* ------------------------------------------------------------------------ */ +/* JSON helpers */ +/* ------------------------------------------------------------------------ */ + +/* null JSON elements decode to NaN (JSON has no NaN literal; the exporter + * writes null for non-finite values) */ +static inline double json_elem_double(JsonArray *arr, int i) +{ + JsonNode *node = json_array_get_element(arr, i); + if(!node || json_node_is_null(node)) return NAN; + return json_node_get_double(node); +} + +static gboolean json_read_darray(JsonObject *obj, const char *key, double *out, int n) +{ + if(!json_object_has_member(obj, key)) return FALSE; + JsonNode *node = json_object_get_member(obj, key); + if(!node || !JSON_NODE_HOLDS_ARRAY(node)) return FALSE; /* tolerate null values */ + JsonArray *arr = json_node_get_array(node); + if(!arr || (int)json_array_get_length(arr) != n) return FALSE; + for(int i = 0; i < n; i++) out[i] = json_elem_double(arr, i); + return TRUE; +} + +/* read an n×m nested array into row-major out */ +static gboolean json_read_dmatrix(JsonObject *obj, const char *key, double *out, int n, int m) +{ + if(!json_object_has_member(obj, key)) return FALSE; + JsonNode *node = json_object_get_member(obj, key); + if(!node || !JSON_NODE_HOLDS_ARRAY(node)) return FALSE; + JsonArray *arr = json_node_get_array(node); + if(!arr || (int)json_array_get_length(arr) != n) return FALSE; + for(int i = 0; i < n; i++) + { + JsonArray *row = json_array_get_array_element(arr, i); + if(!row || (int)json_array_get_length(row) != m) return FALSE; + for(int j = 0; j < m; j++) out[i * m + j] = json_elem_double(row, j); + } + return TRUE; +} + +static char *json_dup_string(JsonObject *obj, const char *key) +{ + if(!json_object_has_member(obj, key)) return NULL; + JsonNode *node = json_object_get_member(obj, key); + if(json_node_is_null(node)) return NULL; + return g_strdup(json_node_get_string(node)); +} + +static void set_error(char **errmsg, const char *fmt, ...) +{ + if(!errmsg) return; + va_list ap; + va_start(ap, fmt); + *errmsg = g_strdup_vprintf(fmt, ap); + va_end(ap); +} + +/* ------------------------------------------------------------------------ */ +/* pack loading */ +/* ------------------------------------------------------------------------ */ + +void sf_pack_free(sf_pack_t *pack) +{ + if(!pack) return; + g_free(pack->version); + g_free(pack->locus); + if(pack->illuminants) g_hash_table_destroy(pack->illuminants); + if(pack->dichroics) g_hash_table_destroy(pack->dichroics); + if(pack->parser) g_object_unref(pack->parser); + free(pack->spectra); + g_free(pack); +} + +sf_pack_t *sf_pack_load(const char *dir, char **errmsg) +{ + sf_pack_t *pack = g_new0(sf_pack_t, 1); + char *json_path = g_build_filename(dir, "pack.json", NULL); + char *lut_path = g_build_filename(dir, "spectra_lut.f32", NULL); + + pack->parser = json_parser_new(); + GError *gerr = NULL; + if(!json_parser_load_from_file(pack->parser, json_path, &gerr)) + { + set_error(errmsg, "spektra_sim: cannot parse %s: %s", json_path, + gerr ? gerr->message : "unknown"); + g_clear_error(&gerr); + goto fail; + } + JsonObject *root = json_node_get_object(json_parser_get_root(pack->parser)); + pack->version = json_dup_string(root, "spektrafilm_version"); + + if(!json_read_darray(root, "wavelengths", pack->wavelengths, SF_NWL) + || !json_read_darray(root, "log_exposure", pack->log_exposure, SF_NLE) + || !json_read_dmatrix(root, "cmfs", &pack->cmfs[0][0], SF_NWL, 3)) + { + set_error(errmsg, "spektra_sim: pack.json misses wavelengths/log_exposure/cmfs " + "or grid sizes changed (expected %d wavelengths, %d exposures)", + SF_NWL, SF_NLE); + goto fail; + } + + /* spectral locus polygon */ + { + JsonArray *arr = json_object_get_array_member(root, "spectral_locus_xy"); + if(!arr) + { + set_error(errmsg, "spektra_sim: pack.json misses spectral_locus_xy"); + goto fail; + } + pack->locus_n = json_array_get_length(arr); + pack->locus = g_malloc0(sizeof(double) * 2 * pack->locus_n); + for(int i = 0; i < pack->locus_n; i++) + { + JsonArray *row = json_array_get_array_element(arr, i); + pack->locus[i][0] = json_array_get_double_element(row, 0); + pack->locus[i][1] = json_array_get_double_element(row, 1); + } + } + + /* illuminants */ + pack->illuminants = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); + { + JsonObject *ill = json_object_get_object_member(root, "illuminants"); + GList *members = ill ? json_object_get_members(ill) : NULL; + for(GList *m = members; m; m = m->next) + { + double *spd = g_new(double, SF_NWL); + if(json_read_darray(ill, m->data, spd, SF_NWL)) + g_hash_table_insert(pack->illuminants, g_strdup(m->data), spd); + else + g_free(spd); + } + g_list_free(members); + } + + /* dichroic filter curves */ + pack->dichroics = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); + { + JsonObject *df = json_object_get_object_member(root, "dichroic_filters"); + GList *members = df ? json_object_get_members(df) : NULL; + for(GList *m = members; m; m = m->next) + { + double *f = g_new(double, SF_NWL * 3); + if(json_read_dmatrix(df, m->data, f, SF_NWL, 3)) + g_hash_table_insert(pack->dichroics, g_strdup(m->data), f); + else + g_free(f); + } + g_list_free(members); + } + + if(json_object_has_member(root, "neutral_print_filters")) + pack->neutral_filters = json_object_get_member(root, "neutral_print_filters"); + if(json_object_has_member(root, "film_render_defaults")) + pack->film_defaults = json_object_get_member(root, "film_render_defaults"); + + /* hanatos2025 spectra LUT */ + { + FILE *fh = g_fopen(lut_path, "rb"); + if(!fh) + { + set_error(errmsg, "spektra_sim: cannot open %s", lut_path); + goto fail; + } + char magic[4]; + int32_t dims[3]; + if(fread(magic, 1, 4, fh) != 4 || memcmp(magic, "SFSL", 4) != 0 + || fread(dims, 4, 3, fh) != 3 || dims[0] != dims[1] || dims[2] != SF_NWL) + { + set_error(errmsg, "spektra_sim: bad spectra_lut header in %s", lut_path); + fclose(fh); + goto fail; + } + pack->tc_n = dims[0]; + const size_t count = (size_t)dims[0] * dims[1] * dims[2]; + pack->spectra = malloc(count * sizeof(float)); + if(!pack->spectra || fread(pack->spectra, sizeof(float), count, fh) != count) + { + set_error(errmsg, "spektra_sim: truncated spectra lut %s", lut_path); + fclose(fh); + goto fail; + } + fclose(fh); + } + + g_free(json_path); + g_free(lut_path); + return pack; + +fail: + g_free(json_path); + g_free(lut_path); + sf_pack_free(pack); + return NULL; +} + +const char *sf_pack_version(const sf_pack_t *pack) +{ + return pack ? pack->version : NULL; +} + +bool sf_pack_neutral_filters(const sf_pack_t *pack, const char *print_stock, + const char *illuminant, const char *film_stock, double cmy[3]) +{ + if(!pack || !pack->neutral_filters) return false; + JsonObject *db = json_node_get_object(pack->neutral_filters); + if(!db || !json_object_has_member(db, print_stock)) return false; + JsonObject *by_ill = json_object_get_object_member(db, print_stock); + if(!by_ill || !json_object_has_member(by_ill, illuminant)) return false; + JsonObject *by_film = json_object_get_object_member(by_ill, illuminant); + if(!by_film || !json_object_has_member(by_film, film_stock)) return false; + JsonArray *arr = json_object_get_array_member(by_film, film_stock); + if(!arr || json_array_get_length(arr) != 3) return false; + for(int i = 0; i < 3; i++) cmy[i] = json_array_get_double_element(arr, i); + return true; +} + +bool sf_pack_film_coupler_diffusion(const sf_pack_t *pack, const char *film_stock, + double *size_um, double *tail_um, double *tail_w) +{ + if(!pack || !pack->film_defaults) return false; + JsonObject *db = json_node_get_object(pack->film_defaults); + if(!db || !json_object_has_member(db, film_stock)) return false; + JsonObject *film = json_object_get_object_member(db, film_stock); + if(!film || !json_object_has_member(film, "dir_couplers")) return false; + JsonObject *dc = json_object_get_object_member(film, "dir_couplers"); + if(!dc) return false; + gboolean ok = FALSE; + if(json_object_has_member(dc, "diffusion_size_um")) + { + *size_um = json_object_get_double_member(dc, "diffusion_size_um"); + ok = TRUE; + } + if(json_object_has_member(dc, "diffusion_tail_um")) + *tail_um = json_object_get_double_member(dc, "diffusion_tail_um"); + if(json_object_has_member(dc, "diffusion_tail_weight")) + *tail_w = json_object_get_double_member(dc, "diffusion_tail_weight"); + return ok; +} + +/* Per-film grain catalogue data: film_render_defaults[stock].grain in the + pack, exported verbatim from spektrafilm's GrainParams (rms_granularity, + uniformity, density_min — see spektrafilm_export_data.py's _grain_export). + Any output pointer may be NULL. Returns false and leaves outputs untouched + if the stock has no "grain" entry (older packs, or the stock predates + per-film grain), so the caller can keep its fallback constants. */ +bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock, + double rms[3], double uniformity[3], double density_min[3]) +{ + if(!pack || !pack->film_defaults) return false; + JsonObject *db = json_node_get_object(pack->film_defaults); + if(!db || !json_object_has_member(db, film_stock)) return false; + JsonObject *film = json_object_get_object_member(db, film_stock); + if(!film || !json_object_has_member(film, "grain")) return false; + JsonObject *gr = json_object_get_object_member(film, "grain"); + if(!gr) return false; + gboolean ok = FALSE; + if(rms && json_object_has_member(gr, "rms_granularity")) + { + ok = json_read_darray(gr, "rms_granularity", rms, 3) || ok; + } + if(uniformity && json_object_has_member(gr, "uniformity")) + ok = json_read_darray(gr, "uniformity", uniformity, 3) || ok; + if(density_min && json_object_has_member(gr, "density_min")) + ok = json_read_darray(gr, "density_min", density_min, 3) || ok; + return ok; +} + +bool sf_pack_film_langmuir(const sf_pack_t *pack, const char *film_stock, + double donor_k[3], double receiver_k[3]) +{ + if(!pack || !pack->film_defaults) return false; + JsonObject *db = json_node_get_object(pack->film_defaults); + if(!db || !json_object_has_member(db, film_stock)) return false; + JsonObject *film = json_object_get_object_member(db, film_stock); + if(!film || !json_object_has_member(film, "dir_couplers")) return false; + JsonObject *dc = json_object_get_object_member(film, "dir_couplers"); + if(!dc || !json_object_has_member(dc, "langmuir_donor_k_rgb")) return false; + json_read_darray(dc, "langmuir_donor_k_rgb", donor_k, 3); + json_read_darray(dc, "langmuir_receiver_k_rgb", receiver_k, 3); + return true; +} + +bool sf_pack_film_defaults(const sf_pack_t *pack, const char *film_stock, + double gamma_samelayer[3], double gamma_inter_r_gb[2], + double gamma_inter_g_rb[2], double gamma_inter_b_rg[2], + double halation_strength[3], double halation_sigma_um[3], + double scatter_core_um[3], double scatter_tail_um[3], + double scatter_tail_weight[3]) +{ + if(!pack || !pack->film_defaults) return false; + JsonObject *db = json_node_get_object(pack->film_defaults); + if(!db || !json_object_has_member(db, film_stock)) return false; + JsonObject *entry = json_object_get_object_member(db, film_stock); + JsonObject *dc = json_object_get_object_member(entry, "dir_couplers"); + JsonObject *ha = json_object_get_object_member(entry, "halation"); + if(dc) + { + if(gamma_samelayer) json_read_darray(dc, "gamma_samelayer_rgb", gamma_samelayer, 3); + if(gamma_inter_r_gb) json_read_darray(dc, "gamma_interlayer_r_to_gb", gamma_inter_r_gb, 2); + if(gamma_inter_g_rb) json_read_darray(dc, "gamma_interlayer_g_to_rb", gamma_inter_g_rb, 2); + if(gamma_inter_b_rg) json_read_darray(dc, "gamma_interlayer_b_to_rg", gamma_inter_b_rg, 2); + } + if(ha) + { + if(halation_strength) json_read_darray(ha, "strength", halation_strength, 3); + if(halation_sigma_um) json_read_darray(ha, "first_sigma_um", halation_sigma_um, 3); + if(scatter_core_um) json_read_darray(ha, "scatter_core_um", scatter_core_um, 3); + if(scatter_tail_um) json_read_darray(ha, "scatter_tail_um", scatter_tail_um, 3); + if(scatter_tail_weight) json_read_darray(ha, "scatter_tail_weight", scatter_tail_weight, 3); + } + return true; +} + +/* ------------------------------------------------------------------------ */ +/* profile loading */ +/* ------------------------------------------------------------------------ */ + +void sf_profile_free(sf_profile_t *p) +{ + if(!p) return; + g_free(p->stock); + g_free(p->name); + g_free(p->type); + g_free(p->support); + g_free(p->stage); + g_free(p->use); + g_free(p->antihalation); + g_free(p->target_print); + g_free(p->channel_model); + g_free(p->reference_illuminant); + g_free(p->viewing_illuminant); + g_free(p); +} + +sf_profile_t *sf_profile_load(const char *path, char **errmsg) +{ + JsonParser *parser = json_parser_new(); + GError *gerr = NULL; + sf_profile_t *p = NULL; + if(!json_parser_load_from_file(parser, path, &gerr)) + { + set_error(errmsg, "spektra_sim: cannot parse profile %s: %s", path, + gerr ? gerr->message : "unknown"); + g_clear_error(&gerr); + g_object_unref(parser); + return NULL; + } + JsonObject *root = json_node_get_object(json_parser_get_root(parser)); + JsonObject *info = json_object_get_object_member(root, "info"); + JsonObject *data = json_object_get_object_member(root, "data"); + if(!info || !data) + { + set_error(errmsg, "spektra_sim: profile %s misses info/data", path); + g_object_unref(parser); + return NULL; + } + + p = g_new0(sf_profile_t, 1); + p->stock = json_dup_string(info, "stock"); + p->name = json_dup_string(info, "name"); + p->type = json_dup_string(info, "type"); + p->support = json_dup_string(info, "support"); + p->stage = json_dup_string(info, "stage"); + p->use = json_dup_string(info, "use"); + p->antihalation = json_dup_string(info, "antihalation"); + p->target_print = json_dup_string(info, "target_print"); + p->channel_model = json_dup_string(info, "channel_model"); + p->reference_illuminant = json_dup_string(info, "reference_illuminant"); + p->viewing_illuminant = json_dup_string(info, "viewing_illuminant"); + + gboolean ok = TRUE; + double wavelengths[SF_NWL]; + ok &= json_read_darray(data, "wavelengths", wavelengths, SF_NWL); + ok &= json_read_dmatrix(data, "log_sensitivity", &p->log_sensitivity[0][0], SF_NWL, 3); + ok &= json_read_dmatrix(data, "channel_density", &p->channel_density[0][0], SF_NWL, 3); + ok &= json_read_darray(data, "base_density", p->base_density, SF_NWL); + ok &= json_read_darray(data, "log_exposure", p->log_exposure, SF_NLE); + ok &= json_read_dmatrix(data, "density_curves", &p->density_curves[0][0], SF_NLE, 3); + if(!ok) + { + set_error(errmsg, "spektra_sim: profile %s has unexpected data shapes " + "(model grid change? re-run the exporter and update the module)", + path); + sf_profile_free(p); + g_object_unref(parser); + return NULL; + } + + /* optional pieces */ + if(json_object_has_member(data, "hanatos2025_adaptation_window_params")) + { + JsonNode *node = json_object_get_member(data, "hanatos2025_adaptation_window_params"); + JsonArray *arr = (node && JSON_NODE_HOLDS_ARRAY(node)) ? json_node_get_array(node) : NULL; + p->window_n = arr ? MIN((int)json_array_get_length(arr), 8) : 0; + for(int i = 0; i < p->window_n; i++) + p->window_params[i] = json_array_get_double_element(arr, i); + } + if(json_object_has_member(data, "density_curves_model")) + { + JsonNode *mnode = json_object_get_member(data, "density_curves_model"); + JsonObject *m = (mnode && JSON_NODE_HOLDS_OBJECT(mnode)) ? json_node_get_object(mnode) : NULL; + JsonNode *cnode = (m && json_object_has_member(m, "centers")) + ? json_object_get_member(m, "centers") : NULL; + JsonArray *centers = (cnode && JSON_NODE_HOLDS_ARRAY(cnode)) ? json_node_get_array(cnode) : NULL; + if(centers && json_array_get_length(centers) == 3) + { + JsonArray *row0 = json_array_get_array_element(centers, 0); + const int nl = MIN((int)json_array_get_length(row0), 8); + p->curves_model.n_layers = nl; + json_read_dmatrix(m, "centers", &p->curves_model.centers[0][0], 3, nl); + json_read_dmatrix(m, "amplitudes", &p->curves_model.amplitudes[0][0], 3, nl); + json_read_dmatrix(m, "sigmas", &p->curves_model.sigmas[0][0], 3, nl); + /* json_read_dmatrix packed rows tightly into an 3×8 array — repack */ + if(nl != 8) + { + double c[24], a[24], s[24]; + json_read_dmatrix(m, "centers", c, 3, nl); + json_read_dmatrix(m, "amplitudes", a, 3, nl); + json_read_dmatrix(m, "sigmas", s, 3, nl); + for(int ch = 0; ch < 3; ch++) + for(int l = 0; l < nl; l++) + { + p->curves_model.centers[ch][l] = c[ch * nl + l]; + p->curves_model.amplitudes[ch][l] = a[ch * nl + l]; + p->curves_model.sigmas[ch][l] = s[ch * nl + l]; + } + } + } + } + + g_object_unref(parser); + return p; +} + +const char *sf_profile_stock(const sf_profile_t *p) { return p->stock; } +const char *sf_profile_name(const sf_profile_t *p) { return p->name; } +const char *sf_profile_stage(const sf_profile_t *p) { return p->stage; } +const char *sf_profile_type(const sf_profile_t *p) { return p->type; } +const char *sf_profile_target_print(const sf_profile_t *p) { return p->target_print; } + +/* ------------------------------------------------------------------------ */ +/* parameter defaults & colour spaces */ +/* ------------------------------------------------------------------------ */ + +/* linear RGB -> XYZ matrices (source-white relative), colour-science values */ +/* NOTE: colour-science ships the *published rounded* matrices for sRGB and + * ProPhoto (not the primaries-derived ones); we match those exactly so the + * numerics agree with the spektrafilm reference. */ +static const double SF_M_SRGB_TO_XYZ[9] + = { 0.4124, 0.3576, 0.1805, 0.2126, 0.7152, 0.0722, 0.0193, 0.1192, 0.9505 }; +static const double SF_SRGB_WHITE_XY[2] = { 0.3127, 0.3290 }; + +static const double SF_M_PROPHOTO_TO_XYZ[9] + = { 0.7977, 0.1352, 0.0313, 0.2880, 0.7119, 0.0001, 0.0, 0.0, 0.8249 }; +static const double SF_D50_WHITE_XY[2] = { 0.3457, 0.3585 }; + +static const double SF_M_REC2020_TO_XYZ[9] + = { 0.6369580483012913, 0.1446169035862083, 0.1688809751641721, + 0.2627002120112671, 0.6779980715188708, 0.0593017164698620, + 0.0000000000000000, 0.0280726930490874, 1.0609850577107909 }; + +void sf_sim_params_defaults(sf_sim_params_t *p) +{ + memset(p, 0, sizeof(*p)); + p->exposure_comp_ev = 0.0; + p->density_curve_gamma = 1.0; + p->couplers_active = true; + p->couplers_amount = 1.0; + /* generic negative-film gammas ([st] params_builder); overwritten from the + * pack's per-film digested defaults in sf_sim_build() */ + const double gs[3] = { 0.336, 0.319, 0.273 }; + const double gr[2] = { 0.353, 0.302 }, gg[2] = { 0.154, 0.353 }, gb[2] = { 0.168, 0.226 }; + memcpy(p->gamma_samelayer, gs, sizeof(gs)); + memcpy(p->gamma_inter_r_gb, gr, sizeof(gr)); + memcpy(p->gamma_inter_g_rb, gg, sizeof(gg)); + memcpy(p->gamma_inter_b_rg, gb, sizeof(gb)); + p->inhibition_samelayer = 1.0; + p->inhibition_interlayer = 1.0; + p->grain_density_min[0] = p->grain_density_min[1] = p->grain_density_min[2] = 0.03; + p->enlarger_illuminant = "TH-KG3"; + p->dichroic_brand = "custom"; + p->print_exposure = 1.0; + p->print_exposure_compensation = true; + p->normalize_print_exposure = true; + p->c_filter_neutral = 0.0; + p->m_filter_neutral = 65.0; + p->y_filter_neutral = 55.0; + p->neutral_from_db = true; + p->morph_active = false; + p->morph_gamma = p->morph_gamma_fast = p->morph_gamma_slow = 1.0; + p->morph_gamma_r = p->morph_gamma_g = p->morph_gamma_b = 1.0; + p->scan_film = false; + p->lut_steps = 0; + p->input_gamut_compress = true; + p->output_compress = SF_OUTPUT_COMPRESS_OKLCH; + sf_sim_params_set_input_prophoto(p); /* reference IOParams default */ + sf_sim_params_set_output_srgb(p); +} + +void sf_sim_params_set_input_srgb(sf_sim_params_t *p) +{ + memcpy(p->input_rgb_to_xyz, SF_M_SRGB_TO_XYZ, sizeof(SF_M_SRGB_TO_XYZ)); + memcpy(p->input_white_xy, SF_SRGB_WHITE_XY, sizeof(SF_SRGB_WHITE_XY)); +} + +void sf_sim_params_set_input_prophoto(sf_sim_params_t *p) +{ + memcpy(p->input_rgb_to_xyz, SF_M_PROPHOTO_TO_XYZ, sizeof(SF_M_PROPHOTO_TO_XYZ)); + memcpy(p->input_white_xy, SF_D50_WHITE_XY, sizeof(SF_D50_WHITE_XY)); +} + +void sf_sim_params_set_input_rec2020(sf_sim_params_t *p) +{ + memcpy(p->input_rgb_to_xyz, SF_M_REC2020_TO_XYZ, sizeof(SF_M_REC2020_TO_XYZ)); + memcpy(p->input_white_xy, SF_SRGB_WHITE_XY, sizeof(SF_SRGB_WHITE_XY)); +} + +void sf_sim_params_set_output_srgb(sf_sim_params_t *p) +{ + memcpy(p->output_rgb_to_xyz, SF_M_SRGB_TO_XYZ, sizeof(SF_M_SRGB_TO_XYZ)); + mat3_inv(p->output_xyz_to_rgb, SF_M_SRGB_TO_XYZ); + memcpy(p->output_white_xy, SF_SRGB_WHITE_XY, sizeof(SF_SRGB_WHITE_XY)); +} + +void sf_sim_params_set_output_rec2020(sf_sim_params_t *p) +{ + memcpy(p->output_rgb_to_xyz, SF_M_REC2020_TO_XYZ, sizeof(SF_M_REC2020_TO_XYZ)); + mat3_inv(p->output_xyz_to_rgb, SF_M_REC2020_TO_XYZ); + memcpy(p->output_white_xy, SF_SRGB_WHITE_XY, sizeof(SF_SRGB_WHITE_XY)); +} + +/* ------------------------------------------------------------------------ */ +/* [su] triangular <-> square chromaticity coordinates */ +/* ------------------------------------------------------------------------ */ + +static inline void tri2quad(double out[2], const double tc[2]) +{ + const double tx = tc[0], ty = tc[1]; + double y = ty / fmax(1.0 - tx, 1e-10); + double x = (1.0 - tx) * (1.0 - tx); + out[0] = CLAMP(x, 0.0, 1.0); + out[1] = CLAMP(y, 0.0, 1.0); +} + +static inline void quad2tri(double out[2], const double xy[2]) +{ + const double sq = sqrt(xy[0]); + out[0] = 1.0 - sq; + out[1] = xy[1] * sq; +} + +/* ------------------------------------------------------------------------ */ +/* [gc] Reinhard knee, radial xy compression toward the spectral locus */ +/* ------------------------------------------------------------------------ */ + +static inline double reinhard_knee(double d, double threshold, double limit, double power) +{ + if(d <= threshold) return d; + const double scale = limit - threshold; + const double x = (d - threshold) / scale; + const double y = x / pow(1.0 + pow(x, power), 1.0 / power); + return threshold + scale * y; +} + +/* distance from origin along unit direction to the first polygon crossing */ +static double ray_polygon_distance(const double origin[2], const double dir[2], + const double (*poly)[2], int n_vertices) +{ + double t_min = INFINITY; + for(int k = 0; k + 1 < n_vertices; k++) + { + const double ax = poly[k][0], ay = poly[k][1]; + const double ex = poly[k + 1][0] - ax, ey = poly[k + 1][1] - ay; + const double denom = dir[0] * ey - dir[1] * ex; + if(fabs(denom) <= 1e-12) continue; + const double ox = origin[0] - ax, oy = origin[1] - ay; + const double t = (-ox * ey + oy * ex) / denom; + const double s = (-ox * dir[1] + oy * dir[0]) / denom; + if(t > 1e-9 && s >= 0.0 && s <= 1.0 && t < t_min) t_min = t; + } + return t_min; +} + +static void compress_xy_radial(double out[2], const double xy[2], const double white[2], + const double (*locus)[2], int locus_n) +{ + const double dx = xy[0] - white[0], dy = xy[1] - white[1]; + const double dist = sqrt(dx * dx + dy * dy); + if(dist < 1e-9) + { + out[0] = xy[0]; + out[1] = xy[1]; + return; + } + const double dir[2] = { dx / dist, dy / dist }; + const double boundary = ray_polygon_distance(white, dir, locus, locus_n); + const double d_norm = dist / fmax(boundary, 1e-12); + const double d_c = reinhard_knee(d_norm, SF_TC_KNEE_T, SF_TC_KNEE_L, SF_TC_KNEE_P); + out[0] = white[0] + dir[0] * d_c * boundary; + out[1] = white[1] + dir[1] * d_c * boundary; +} + +/* ------------------------------------------------------------------------ */ +/* [fi] Mitchell–Netravali 2D cubic LUT interpolation (reflected bounds) */ +/* ------------------------------------------------------------------------ */ + +static inline double mitchell_weight(double t) +{ + const double B = 1.0 / 3.0, C = 1.0 / 3.0; + const double x = fabs(t); + if(x < 1.0) + return (1.0 / 6.0) + * ((12.0 - 9.0 * B - 6.0 * C) * x * x * x + (-18.0 + 12.0 * B + 6.0 * C) * x * x + + (6.0 - 2.0 * B)); + else if(x < 2.0) + return (1.0 / 6.0) + * ((-B - 6.0 * C) * x * x * x + (6.0 * B + 30.0 * C) * x * x + + (-12.0 * B - 48.0 * C) * x + (8.0 * B + 24.0 * C)); + return 0.0; +} + +static inline int safe_index(int idx, int L) +{ + if(idx < 0) return -idx; + if(idx >= L) return 2 * (L - 1) - idx; + return idx; +} + +static inline void cubic_base_fraction(double coord, int L, int *base, double *frac) +{ + coord = CLAMP(coord, 0.0, (double)(L - 1)); + if(coord >= (double)(L - 1)) + { + *base = L - 2; + *frac = 1.0; + return; + } + *base = (int)floor(coord); + *frac = coord - *base; +} + +/* lut: L×L×3 doubles, coords already scaled to [0, L-1] */ +static void cubic_interp_2d(double out[3], const double *lut, int L, double x, double y) +{ + int xb, yb; + double xf, yf; + cubic_base_fraction(x, L, &xb, &xf); + cubic_base_fraction(y, L, &yb, &yf); + double wx[4], wy[4]; + for(int i = 0; i < 4; i++) + { + wx[i] = mitchell_weight(xf + 1.0 - i); + wy[i] = mitchell_weight(yf + 1.0 - i); + } + double acc[3] = { 0, 0, 0 }, wsum = 0.0; + for(int i = 0; i < 4; i++) + { + const int xi = safe_index(xb - 1 + i, L); + for(int j = 0; j < 4; j++) + { + const int yj = safe_index(yb - 1 + j, L); + const double w = wx[i] * wy[j]; + wsum += w; + const double *px = lut + ((size_t)xi * L + yj) * 3; + acc[0] += w * px[0]; + acc[1] += w * px[1]; + acc[2] += w * px[2]; + } + } + if(wsum != 0.0) + for(int c = 0; c < 3; c++) acc[c] /= wsum; + out[0] = acc[0]; + out[1] = acc[1]; + out[2] = acc[2]; +} + +/* bilinear sampling on the same layout with clamped ("nearest") bounds — + * used only for the tc_lut compression remap at build time */ +static void bilinear_2d_clamped(double out[3], const double *lut, int L, double x, double y) +{ + x = CLAMP(x, 0.0, (double)(L - 1)); + y = CLAMP(y, 0.0, (double)(L - 1)); + const int x0 = (int)floor(x), y0 = (int)floor(y); + const int x1 = MIN(x0 + 1, L - 1), y1 = MIN(y0 + 1, L - 1); + const double tx = x - x0, ty = y - y0; + for(int c = 0; c < 3; c++) + { + const double v00 = lut[((size_t)x0 * L + y0) * 3 + c]; + const double v01 = lut[((size_t)x0 * L + y1) * 3 + c]; + const double v10 = lut[((size_t)x1 * L + y0) * 3 + c]; + const double v11 = lut[((size_t)x1 * L + y1) * 3 + c]; + out[c] = (v00 * (1 - ty) + v01 * ty) * (1 - tx) + (v10 * (1 - ty) + v11 * ty) * tx; + } +} + +/* ------------------------------------------------------------------------ */ +/* [fi] monotone PCHIP 3D LUT interpolation */ +/* ------------------------------------------------------------------------ */ + +static void fill_monotone_slopes_1d(const double *values, double *slopes, int size) +{ + if(size == 1) + { + slopes[0] = 0.0; + return; + } + double deltas[64] = { 0 }; /* zero-init: gcc -Wmaybe-uninitialized cannot prove size bounds */ + for(int i = 0; i < size - 1; i++) deltas[i] = values[i + 1] - values[i]; + if(size == 2) + { + slopes[0] = slopes[1] = deltas[0]; + return; + } + double left = 0.5 * (3.0 * deltas[0] - deltas[1]); + if(left * deltas[0] <= 0.0) + left = 0.0; + else if(deltas[0] * deltas[1] < 0.0 && fabs(left) > fabs(3.0 * deltas[0])) + left = 3.0 * deltas[0]; + slopes[0] = left; + for(int i = 1; i < size - 1; i++) + { + const double dp = deltas[i - 1], dn = deltas[i]; + slopes[i] = (dp == 0.0 || dn == 0.0 || dp * dn <= 0.0) ? 0.0 : 2.0 * dp * dn / (dp + dn); + } + double right = 0.5 * (3.0 * deltas[size - 2] - deltas[size - 3]); + if(right * deltas[size - 2] <= 0.0) + right = 0.0; + else if(deltas[size - 2] * deltas[size - 3] < 0.0 && fabs(right) > fabs(3.0 * deltas[size - 2])) + right = 3.0 * deltas[size - 2]; + slopes[size - 1] = right; +} + +typedef struct sf_pchip3d_t +{ + int n; + const double *lut, *sx, *sy, *sz, *cmin, *cmax; +} sf_pchip3d_t; + +/* precompute per-axis monotone slopes and per-cell bounds for an n³×3 LUT */ +static void pchip3d_prepare(const double *lut, int n, double *sx, double *sy, double *sz, + double *cmin, double *cmax) +{ + double line[64], slopes[64]; +#define LUT(i, j, k, c) lut[((((size_t)(i)) * n + (j)) * n + (k)) * 3 + (c)] +#define SLOT(arr, i, j, k, c) arr[((((size_t)(i)) * n + (j)) * n + (k)) * 3 + (c)] + for(int j = 0; j < n; j++) + for(int k = 0; k < n; k++) + for(int c = 0; c < 3; c++) + { + for(int i = 0; i < n; i++) line[i] = LUT(i, j, k, c); + fill_monotone_slopes_1d(line, slopes, n); + for(int i = 0; i < n; i++) SLOT(sx, i, j, k, c) = slopes[i]; + } + for(int i = 0; i < n; i++) + for(int k = 0; k < n; k++) + for(int c = 0; c < 3; c++) + { + for(int j = 0; j < n; j++) line[j] = LUT(i, j, k, c); + fill_monotone_slopes_1d(line, slopes, n); + for(int j = 0; j < n; j++) SLOT(sy, i, j, k, c) = slopes[j]; + } + for(int i = 0; i < n; i++) + for(int j = 0; j < n; j++) + for(int c = 0; c < 3; c++) + { + for(int k = 0; k < n; k++) line[k] = LUT(i, j, k, c); + fill_monotone_slopes_1d(line, slopes, n); + for(int k = 0; k < n; k++) SLOT(sz, i, j, k, c) = slopes[k]; + } + const int m = n - 1; + for(int i = 0; i < m; i++) + for(int j = 0; j < m; j++) + for(int k = 0; k < m; k++) + for(int c = 0; c < 3; c++) + { + double mn = LUT(i, j, k, c), mx = mn; + for(int di = 0; di < 2; di++) + for(int dj = 0; dj < 2; dj++) + for(int dk = 0; dk < 2; dk++) + { + const double s = LUT(i + di, j + dj, k + dk, c); + if(s < mn) mn = s; + if(s > mx) mx = s; + } + const size_t idx = ((((size_t)i) * m + j) * m + k) * 3 + c; + cmin[idx] = mn; + cmax[idx] = mx; + } +#undef LUT +#undef SLOT +} + +static inline double hermite_value(double y0, double y1, double m0, double m1, double t) +{ + const double t2 = t * t, t3 = t2 * t; + return (2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * m0 + + (-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * m1; +} + +static inline double linear_mix(double v0, double v1, double t) { return v0 + t * (v1 - v0); } + +/* r, g, b in [0, n-1] index units */ +static void pchip3d_interp(const sf_pchip3d_t *P, double r, double g, double b, double out[3]) +{ + const int n = P->n, m = n - 1; + int i, j, k; + double tr, tg, tb; + cubic_base_fraction(r, n, &i, &tr); + cubic_base_fraction(g, n, &j, &tg); + cubic_base_fraction(b, n, &k, &tb); +#define AT(arr, ii, jj, kk, c) arr[((((size_t)(ii)) * n + (jj)) * n + (kk)) * 3 + (c)] + for(int c = 0; c < 3; c++) + { + const double v000 = hermite_value(AT(P->lut, i, j, k, c), AT(P->lut, i + 1, j, k, c), + AT(P->sx, i, j, k, c), AT(P->sx, i + 1, j, k, c), tr); + const double v010 + = hermite_value(AT(P->lut, i, j + 1, k, c), AT(P->lut, i + 1, j + 1, k, c), + AT(P->sx, i, j + 1, k, c), AT(P->sx, i + 1, j + 1, k, c), tr); + const double v001 + = hermite_value(AT(P->lut, i, j, k + 1, c), AT(P->lut, i + 1, j, k + 1, c), + AT(P->sx, i, j, k + 1, c), AT(P->sx, i + 1, j, k + 1, c), tr); + const double v011 + = hermite_value(AT(P->lut, i, j + 1, k + 1, c), AT(P->lut, i + 1, j + 1, k + 1, c), + AT(P->sx, i, j + 1, k + 1, c), AT(P->sx, i + 1, j + 1, k + 1, c), tr); + const double sy00 = linear_mix(AT(P->sy, i, j, k, c), AT(P->sy, i + 1, j, k, c), tr); + const double sy10 = linear_mix(AT(P->sy, i, j + 1, k, c), AT(P->sy, i + 1, j + 1, k, c), tr); + const double sy01 = linear_mix(AT(P->sy, i, j, k + 1, c), AT(P->sy, i + 1, j, k + 1, c), tr); + const double sy11 + = linear_mix(AT(P->sy, i, j + 1, k + 1, c), AT(P->sy, i + 1, j + 1, k + 1, c), tr); + const double vz0 = hermite_value(v000, v010, sy00, sy10, tg); + const double vz1 = hermite_value(v001, v011, sy01, sy11, tg); + const double sz0 + = linear_mix(linear_mix(AT(P->sz, i, j, k, c), AT(P->sz, i + 1, j, k, c), tr), + linear_mix(AT(P->sz, i, j + 1, k, c), AT(P->sz, i + 1, j + 1, k, c), tr), tg); + const double sz1 = linear_mix( + linear_mix(AT(P->sz, i, j, k + 1, c), AT(P->sz, i + 1, j, k + 1, c), tr), + linear_mix(AT(P->sz, i, j + 1, k + 1, c), AT(P->sz, i + 1, j + 1, k + 1, c), tr), tg); + double v = hermite_value(vz0, vz1, sz0, sz1, tb); + const size_t cidx = ((((size_t)i) * m + j) * m + k) * 3 + c; + v = CLAMP(v, P->cmin[cidx], P->cmax[cidx]); + out[c] = v; + } +#undef AT +} + +/* ------------------------------------------------------------------------ */ +/* [dc] density curve interpolation helpers */ +/* ------------------------------------------------------------------------ */ + +/* np.interp over an increasing xp of size n, endpoint-clamped */ +static double interp_general(double x, const double *xp, const double *fp, int n) +{ + if(x <= xp[0]) return fp[0]; + if(x >= xp[n - 1]) return fp[n - 1]; + int lo = 0, hi = n - 1; + while(hi - lo > 1) + { + const int mid = (lo + hi) >> 1; + if(xp[mid] <= x) + lo = mid; + else + hi = mid; + } + const double dx = xp[hi] - xp[lo]; + if(dx <= 0.0) return fp[hi]; + const double t = (x - xp[lo]) / dx; + return fp[lo] + t * (fp[hi] - fp[lo]); +} + +/* [dc] interpolate one channel of a (SF_NLE, 3) curve table over the uniform + * log-exposure grid divided by the per-channel gamma factor: + * x-axis = le/gamma -> index t = (x*gamma - le0) / le_step */ +static inline double interp_curve_uniform(double x, double gammac, double le0, + double le_step, const double (*curves)[3], int c) +{ + const double t = (x * gammac - le0) / le_step; + if(t <= 0.0) return curves[0][c]; + if(t >= (double)(SF_NLE - 1)) return curves[SF_NLE - 1][c]; + const int i = (int)t; + const double f = t - i; + return curves[i][c] + f * (curves[i + 1][c] - curves[i][c]); +} + +/* ------------------------------------------------------------------------ */ +/* [mc] cdfs density curve model + s023 morph */ +/* ------------------------------------------------------------------------ */ + +static inline double norm_cdf(double z) { return 0.5 * (1.0 + erf(z * M_SQRT1_2)); } + +/* evaluate one channel of the cdfs model over the log-exposure grid. + * signed z: negated for positive profiles ([mc] _signed_z) */ +static void eval_cdfs_channel(double *out, const double *le, int nle, const double *centers, + const double *amps, const double *sigmas, int n_layers, + int positive) +{ + for(int i = 0; i < nle; i++) out[i] = 0.0; + for(int l = 0; l < n_layers; l++) + { + for(int i = 0; i < nle; i++) + { + double z = (le[i] - centers[l]) / sigmas[l]; + if(positive) z = -z; + out[i] += amps[l] * norm_cdf(z); + } + } +} + +#define SF_SIGMA_FLOOR 0.05 /* [mc] NormCdfsFitConfig.sigma_floor */ + +/* [mc] apply_print_curves_morph without developer exhaustion. + * With morph inactive this reduces to a plain model evaluation. */ +static void build_print_curves(double (*curves)[3], const sf_profile_t *print, + const sf_sim_params_t *p) +{ + const int positive = (print->type && strcmp(print->type, "positive") == 0); + const sf_curves_model_t *m = &print->curves_model; + const int nl = m->n_layers; + + for(int c = 0; c < 3; c++) + { + double centers[8], amps[8], sigmas[8]; + memcpy(centers, m->centers[c], sizeof(centers)); + memcpy(amps, m->amplitudes[c], sizeof(amps)); + memcpy(sigmas, m->sigmas[c], sizeof(sigmas)); + + if(p->morph_active && nl > 0) + { + /* speed-layer indices by ascending center ([mc] _speed_layer_indices) */ + int order[8]; + for(int i = 0; i < nl; i++) order[i] = i; + for(int i = 0; i < nl; i++) + for(int j = i + 1; j < nl; j++) + if(centers[order[j]] < centers[order[i]]) + { + const int t = order[i]; + order[i] = order[j]; + order[j] = t; + } + const int i_fast = order[0], i_mid = order[nl / 2], i_slow = order[nl - 1]; + const double gch = (c == 0) ? p->morph_gamma_r : (c == 1) ? p->morph_gamma_g + : p->morph_gamma_b; + const double g_fast = p->morph_gamma * gch * p->morph_gamma_fast; + /* [mc] note: the mid sub-layer intentionally uses gamma_factor_slow */ + const double g_mid = p->morph_gamma * gch * p->morph_gamma_slow; + const double g_slow = g_mid; + sigmas[i_fast] = fmax(sigmas[i_fast] / g_fast, SF_SIGMA_FLOOR); + centers[i_fast] = centers[i_fast] / g_fast; + sigmas[i_mid] = fmax(sigmas[i_mid] / g_mid, SF_SIGMA_FLOOR); + centers[i_mid] = centers[i_mid] / g_mid; + sigmas[i_slow] = fmax(sigmas[i_slow] / g_slow, SF_SIGMA_FLOOR); + centers[i_slow] = centers[i_slow] / g_slow; + } + + double column[SF_NLE]; + eval_cdfs_channel(column, print->log_exposure, SF_NLE, centers, amps, sigmas, nl, positive); + for(int i = 0; i < SF_NLE; i++) curves[i][c] = column[i]; + } +} + +/* ------------------------------------------------------------------------ */ +/* [cf] dichroic enlarger filters */ +/* ------------------------------------------------------------------------ */ + +/* filtered[l] = src[l] * prod_c (1 - (1 - F[l][c]) * (1 - 10^(-cc_c/100))) */ +static void apply_dichroic_cc(double *out, const double *src, const double *filters, + const double cc[3]) +{ + double dim[3]; + for(int c = 0; c < 3; c++) dim[c] = 1.0 - pow(10.0, -cc[c] / 100.0); + for(int l = 0; l < SF_NWL; l++) + { + double total = 1.0; + for(int c = 0; c < 3; c++) total *= 1.0 - (1.0 - filters[l * 3 + c]) * dim[c]; + out[l] = src[l] * total; + } +} + +/* ------------------------------------------------------------------------ */ +/* exact spectral kernels shared by build (LUT fill) and per-pixel paths */ +/* ------------------------------------------------------------------------ */ + +/* [st] printing._film_cmy_to_print_log_raw — WITHOUT the print_exposure and + * second log step, which run outside the (optional) 3D table */ +static void cmy_to_print_lograw(const sf_sim_t *s, const double cmy[3], double out[3]) +{ + double raw[3] = { 0.0, 0.0, 0.0 }; + for(int l = 0; l < SF_NWL; l++) + { + double ds = s->film_base_density[l]; + for(int c = 0; c < 3; c++) ds += s->film_chan_density[l][c] * cmy[c]; + /* [st] density_to_light zeroes NaN transmittance (missing spectral data) */ + double light = s->illum_print[l] * pow(10.0, -ds); + if(!isfinite(light)) light = 0.0; + for(int m = 0; m < 3; m++) raw[m] += light * s->print_sens[l][m]; + } + for(int m = 0; m < 3; m++) + { + double r = raw[m] * s->midgray_factor + s->preflash_raw[m]; + out[m] = log10(fmax(r, 0.0) + SF_LOG_EPS); + } +} + +/* np.interp equivalent over xp = -curve[i] (ascending for positive film), + fp = le[i]; endpoint-clamped exactly like numpy */ +static double interp_ascending(double x, const double *curve, const double *le, int n) +{ + if(x <= -curve[0]) return le[0]; + if(x >= -curve[n - 1]) return le[n - 1]; + for(int i = 0; i < n - 1; i++) + { + const double x0 = -curve[i], x1 = -curve[i + 1]; + if(x >= x0 && x <= x1) + { + const double t = (x1 > x0) ? (x - x0) / (x1 - x0) : 0.0; + return le[i] + t * (le[i + 1] - le[i]); + } + } + return le[n - 1]; +} + +/* [st] scanning cmy_to_log_xyz */ +static void cmy_to_log_xyz(const sf_sim_t *s, const double cmy[3], double out[3]) +{ + double xyz[3] = { 0.0, 0.0, 0.0 }; + for(int l = 0; l < SF_NWL; l++) + { + double ds = s->scan_base_density[l]; + for(int c = 0; c < 3; c++) ds += s->scan_chan_density[l][c] * cmy[c]; + double light = s->illum_view[l] * pow(10.0, -ds); + if(!isfinite(light)) light = 0.0; + for(int m = 0; m < 3; m++) xyz[m] += light * s->cmfs[l][m]; + } + for(int m = 0; m < 3; m++) + out[m] = log10(fmax(xyz[m] / s->xyz_norm, 0.0) + SF_LOG_EPS); +} + +/* ------------------------------------------------------------------------ */ +/* [gc] OkLab conversions and output C_max(L, h) table */ +/* ------------------------------------------------------------------------ */ + +static inline void xyz_to_oklab(const double xyz[3], double lab[3]) +{ + double lms[3]; + mat3_mulv(lms, SF_OKLAB_M1, xyz); + for(int i = 0; i < 3; i++) lms[i] = cbrt(lms[i]); + mat3_mulv(lab, SF_OKLAB_M2, lms); +} + +static inline void oklab_to_xyz(const sf_sim_t *s, const double lab[3], double xyz[3]) +{ + double lms[3]; + mat3_mulv(lms, s->oklab_m2inv, lab); + for(int i = 0; i < 3; i++) lms[i] = lms[i] * lms[i] * lms[i]; + mat3_mulv(xyz, s->oklab_m1inv, lms); +} + +#define SF_CMAX_L_LO 0.02 /* [gc] _get_output_c_max_table oklch L_grid */ +#define SF_CMAX_L_HI 1.0 + +/* bisect the max in-cube OkLch chroma per (L, h) ([gc] _build_polar_..._table) */ +static void build_cmax_table(sf_sim_t *s) +{ + s->cmax = malloc(sizeof(float) * SF_CMAX_NL * SF_CMAX_NH); +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(int i = 0; i < SF_CMAX_NL; i++) + { + const double L = SF_CMAX_L_LO + + (SF_CMAX_L_HI - SF_CMAX_L_LO) * i / (double)(SF_CMAX_NL - 1); + for(int j = 0; j < SF_CMAX_NH; j++) + { + const double h = -M_PI + 2.0 * M_PI * j / (double)SF_CMAX_NH; + const double ch = cos(h), sh = sin(h); + double lo = 0.0, hi = 0.5; + for(int b = 0; b < SF_CMAX_NBISECT; b++) + { + const double mid = 0.5 * (lo + hi); + const double lab[3] = { L, mid * ch, mid * sh }; + double xyz[3], rgb[3]; + oklab_to_xyz(s, lab, xyz); + mat3_mulv(rgb, s->out_xyz2rgb, xyz); + const int in_gamut = rgb[0] >= -1e-6 && rgb[0] <= 1.0 + 1e-6 && rgb[1] >= -1e-6 + && rgb[1] <= 1.0 + 1e-6 && rgb[2] >= -1e-6 && rgb[2] <= 1.0 + 1e-6; + if(in_gamut) + lo = mid; + else + hi = mid; + } + s->cmax[(size_t)i * SF_CMAX_NH + j] = (float)lo; + } + } +} + +/* [gc] _c_max_lookup — bilinear, L clamped, hue wrapped */ +static inline double cmax_lookup(const sf_sim_t *s, double L, double h) +{ + L = CLAMP(L, SF_CMAX_L_LO, SF_CMAX_L_HI); + const double h_step = 2.0 * M_PI / SF_CMAX_NH; + const double h_idx = (h + M_PI) / h_step; + const double h_floor = floor(h_idx); + int h_lo = ((int)h_floor) % SF_CMAX_NH; + if(h_lo < 0) h_lo += SF_CMAX_NH; + const int h_hi = (h_lo + 1) % SF_CMAX_NH; + const double h_frac = h_idx - h_floor; + + const double L_idx + = (L - SF_CMAX_L_LO) / (SF_CMAX_L_HI - SF_CMAX_L_LO) * (double)(SF_CMAX_NL - 1); + int L_lo = (int)floor(L_idx); + L_lo = CLAMP(L_lo, 0, SF_CMAX_NL - 2); + const int L_hi = L_lo + 1; + const double L_frac = L_idx - L_lo; + + const float *T = s->cmax; + const double v00 = T[(size_t)L_lo * SF_CMAX_NH + h_lo]; + const double v01 = T[(size_t)L_lo * SF_CMAX_NH + h_hi]; + const double v10 = T[(size_t)L_hi * SF_CMAX_NH + h_lo]; + const double v11 = T[(size_t)L_hi * SF_CMAX_NH + h_hi]; + return v00 * (1 - L_frac) * (1 - h_frac) + v01 * (1 - L_frac) * h_frac + + v10 * L_frac * (1 - h_frac) + v11 * L_frac * h_frac; +} + +/* [gc] compress_rgb_oklch_chroma with lightness_compression (0.7, 1, 2.2) */ +static void compress_rgb_oklch(const sf_sim_t *s, double rgb[3]) +{ + double xyz[3], lab[3]; + mat3_mulv(xyz, s->out_rgb2xyz, rgb); + xyz_to_oklab(xyz, lab); + double L = lab[0]; + const double a = lab[1], b = lab[2]; + /* lightness first, so C_max is looked up at the corrected L */ + L = reinhard_knee(L, SF_OUT_LIGHT_T, SF_OUT_LIGHT_L, SF_OUT_LIGHT_P); + const double C = hypot(a, b); + const double h = atan2(b, a); + const double C_max = fmax(cmax_lookup(s, L, h), 1e-9); + const double d = reinhard_knee(C / C_max, SF_OUT_KNEE_T, SF_OUT_KNEE_L, SF_OUT_KNEE_P); + const double C_new = d * C_max; + const double lab_new[3] = { L, C_new * cos(h), C_new * sin(h) }; + oklab_to_xyz(s, lab_new, xyz); + mat3_mulv(rgb, s->out_xyz2rgb, xyz); +} + +/* [gc] compress_rgb_aces_rgc — per-channel knee on achromatic distance */ +static void compress_rgb_aces(double rgb[3]) +{ + const double ach = fmax(rgb[0], fmax(rgb[1], rgb[2])); + if(ach <= 1e-12) return; + for(int c = 0; c < 3; c++) + { + const double d = (ach - rgb[c]) / ach; + const double dc = reinhard_knee(d, SF_OUT_KNEE_T, SF_OUT_KNEE_L, SF_OUT_KNEE_P); + rgb[c] = ach * (1.0 - dc); + } +} + +/* ------------------------------------------------------------------------ */ +/* build */ +/* ------------------------------------------------------------------------ */ + +static void illuminant_xy_from_spd(double out[2], const double *spd, + const double cmfs[][3]) +{ + double xyz[3] = { 0.0, 0.0, 0.0 }; + for(int l = 0; l < SF_NWL; l++) + for(int c = 0; c < 3; c++) xyz[c] += spd[l] * cmfs[l][c]; + const double sum = xyz[0] + xyz[1] + xyz[2]; + out[0] = xyz[0] / sum; + out[1] = xyz[1] / sum; +} + +/* [su] one 2D LUT lookup of the filming stage: linear RGB -> raw exposure */ +static void expose_pixel(const double m_in[9], const double *tc_lut, int tc_n, + const double rgb[3], double raw[3]) +{ + double xyz[3]; + mat3_mulv(xyz, m_in, rgb); + const double b = xyz[0] + xyz[1] + xyz[2]; + const double xy[2] = { xyz[0] / fmax(b, 1e-10), xyz[1] / fmax(b, 1e-10) }; + double tc[2]; + tri2quad(tc, xy); + const double scale = (double)(tc_n - 1); + cubic_interp_2d(raw, tc_lut, tc_n, tc[0] * scale, tc[1] * scale); + const double bb = isfinite(b) ? b : 0.0; + for(int c = 0; c < 3; c++) raw[c] *= bb; +} + +/* [st] filming._simple_rgb_to_density_spectral: the gray reference used to + * balance the print exposure. NOTE the reference computes this in *sRGB* + * (the _rgb_to_film_raw defaults), independent of the io input space. */ +static void midgray_density_spectral(const sf_sim_t *s, const sf_profile_t *film, + const double film_ref_xy[2], double gray, + double ds[SF_NWL]) +{ + double m_srgb[9], cat[9]; + cat_matrix(cat, SF_M_CAT16, SF_SRGB_WHITE_XY, film_ref_xy); + mat3_mul(m_srgb, cat, SF_M_SRGB_TO_XYZ); + + const double rgb[3] = { gray, gray, gray }; + double raw[3]; + expose_pixel(m_srgb, s->tc_lut, s->tc_n, rgb, raw); + + double cmy[3]; + for(int c = 0; c < 3; c++) + { + const double lograw = log10(raw[c] + SF_LOG_EPS); + /* develop_simple: UNNORMALIZED stock curves */ + cmy[c] = interp_curve_uniform(lograw, s->gamma[c], s->le0, s->le_step, + film->density_curves, c); + } + for(int l = 0; l < SF_NWL; l++) + { + ds[l] = film->base_density[l]; + for(int c = 0; c < 3; c++) ds[l] += film->channel_density[l][c] * cmy[c]; + } +} + +/* [st] printing._exposure_factor: 1 / geomean of the midgray print raw */ +static double exposure_factor(const sf_sim_t *s, const double ds[SF_NWL]) +{ + double raw[3] = { 0.0, 0.0, 0.0 }; + for(int l = 0; l < SF_NWL; l++) + { + double light = s->illum_print[l] * pow(10.0, -ds[l]); + if(!isfinite(light)) light = 0.0; + for(int m = 0; m < 3; m++) raw[m] += light * s->print_sens[l][m]; + } + double log_sum = 0.0; + for(int m = 0; m < 3; m++) log_sum += log(fmax(raw[m], 1e-10)); + return 1.0 / exp(log_sum / 3.0); +} + +/* fill a steps^3 table by sampling fn over [lo, hi]^3 and prepare PCHIP */ +typedef void (*sf_cell_fn)(const sf_sim_t *, const double[3], double[3]); + +static void build_lut3d(const sf_sim_t *s, sf_cell_fn fn, const double lo[3], + const double hi[3], int steps, double **lut, double **sx, + double **sy, double **sz, double **cmin, double **cmax_) +{ + const size_t n3 = (size_t)steps * steps * steps * 3; + const size_t m3 = (size_t)(steps - 1) * (steps - 1) * (steps - 1) * 3; + *lut = malloc(n3 * sizeof(double)); + *sx = malloc(n3 * sizeof(double)); + *sy = malloc(n3 * sizeof(double)); + *sz = malloc(n3 * sizeof(double)); + *cmin = malloc(m3 * sizeof(double)); + *cmax_ = malloc(m3 * sizeof(double)); +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(int i = 0; i < steps; i++) + for(int j = 0; j < steps; j++) + for(int k = 0; k < steps; k++) + { + const double cmy[3] = { lo[0] + (hi[0] - lo[0]) * i / (double)(steps - 1), + lo[1] + (hi[1] - lo[1]) * j / (double)(steps - 1), + lo[2] + (hi[2] - lo[2]) * k / (double)(steps - 1) }; + fn(s, cmy, *lut + ((((size_t)i) * steps + j) * steps + k) * 3); + } + pchip3d_prepare(*lut, steps, *sx, *sy, *sz, *cmin, *cmax_); +} + +void sf_sim_free(sf_sim_t *s) +{ + if(!s) return; + free(s->tc_lut); + free(s->enl_lut); free(s->enl_sx); free(s->enl_sy); free(s->enl_sz); + free(s->enl_cmin); free(s->enl_cmax); + free(s->scan_lut); free(s->scan_sx); free(s->scan_sy); free(s->scan_sz); + free(s->scan_cmin); free(s->scan_cmax); + free(s->cmax); + g_free(s); +} + +double sf_sim_film_dmax(const sf_sim_t *sim, int ch) +{ + return sim->film_dmax[CLAMP(ch, 0, 2)]; +} + +sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, + const sf_profile_t *print, const sf_sim_params_t *params, + char **errmsg) +{ + if(!pack || !film || !params || (!print && !params->scan_film)) + { + set_error(errmsg, "spektra_sim: build needs pack, film and (unless scan_film) print"); + return NULL; + } + sf_sim_t *s = g_new0(sf_sim_t, 1); + s->p = *params; + sf_sim_params_t *p = &s->p; + s->film_positive = (film->type && strcmp(film->type, "positive") == 0); + s->film_bw = (film->channel_model && strcmp(film->channel_model, "bw") == 0); + s->print_positive = (print && print->type && strcmp(print->type, "positive") == 0); + s->has_print = !p->scan_film; + s->out_compress = p->output_compress; + s->print_exposure = p->print_exposure; + s->lut_steps = p->lut_steps; + if(s->lut_steps == 1) s->lut_steps = 0; + if(s->lut_steps > 64) s->lut_steps = 64; /* pchip line buffers are 64 wide */ + memcpy(s->cmfs, pack->cmfs, sizeof(s->cmfs)); + + /* per-film digested coupler gammas from the pack — applied only when the + * caller left the generic defaults untouched */ + { + sf_sim_params_t generic; + sf_sim_params_defaults(&generic); + if(memcmp(p->gamma_samelayer, generic.gamma_samelayer, sizeof(p->gamma_samelayer)) == 0 + && memcmp(p->gamma_inter_r_gb, generic.gamma_inter_r_gb, sizeof(p->gamma_inter_r_gb)) == 0 + && memcmp(p->gamma_inter_g_rb, generic.gamma_inter_g_rb, sizeof(p->gamma_inter_g_rb)) == 0 + && memcmp(p->gamma_inter_b_rg, generic.gamma_inter_b_rg, sizeof(p->gamma_inter_b_rg)) == 0) + sf_pack_film_defaults(pack, film->stock, p->gamma_samelayer, p->gamma_inter_r_gb, + p->gamma_inter_g_rb, p->gamma_inter_b_rg, NULL, NULL, NULL, + NULL, NULL); + } + /* neutral enlarger filters from the release database */ + if(s->has_print && p->neutral_from_db) + { + double cmy[3]; + if(sf_pack_neutral_filters(pack, print->stock, p->enlarger_illuminant, film->stock, cmy)) + { + p->c_filter_neutral = cmy[0]; + p->m_filter_neutral = cmy[1]; + p->y_filter_neutral = cmy[2]; + } + } + + /* ----- filming: input matrix and tc_lut ------------------------------- */ + const double *illu_ref = g_hash_table_lookup(pack->illuminants, film->reference_illuminant); + if(!illu_ref) + { + set_error(errmsg, "spektra_sim: pack misses reference illuminant '%s'", + film->reference_illuminant); + sf_sim_free(s); + return NULL; + } + double film_ref_xy[2]; + illuminant_xy_from_spd(film_ref_xy, illu_ref, pack->cmfs); + { + double cat[9]; + cat_matrix(cat, SF_M_CAT16, p->input_white_xy, film_ref_xy); + mat3_mul(s->m_in, cat, p->input_rgb_to_xyz); + } + s->ev_scale = pow(2.0, p->exposure_comp_ev); + + /* [su] compute_hanatos2025_tc_lut: spectra × (sensitivity × window / norm) */ + const int n = pack->tc_n; + s->tc_n = n; + s->tc_lut = malloc((size_t)n * n * 3 * sizeof(double)); + { + double sens_w[SF_NWL][3]; + for(int l = 0; l < SF_NWL; l++) + for(int m = 0; m < 3; m++) + { + const double v = pow(10.0, film->log_sensitivity[l][m]); + sens_w[l][m] = isfinite(v) ? v : 0.0; + } + if(film->window_n == 4) /* erf4 spectral bandpass, white-balance preserving */ + { + const double c_uv = film->window_params[0], s_uv = film->window_params[1]; + const double c_ir = film->window_params[2], s_ir = film->window_params[3]; + double w[SF_NWL]; + for(int l = 0; l < SF_NWL; l++) + { + const double wl = pack->wavelengths[l]; + const double e_uv = 0.5 * (1.0 + erf((wl - c_uv) / (s_uv * M_SQRT2))); + const double e_ir = 0.5 * (1.0 - erf((wl - c_ir) / (s_ir * M_SQRT2))); + w[l] = e_uv * e_ir; + } + for(int m = 0; m < 3; m++) + { + double num = 0.0, den = 0.0; + for(int l = 0; l < SF_NWL; l++) + { + num += sens_w[l][m] * illu_ref[l] * w[l]; + den += sens_w[l][m] * illu_ref[l]; + } + const double norm = num / den; + for(int l = 0; l < SF_NWL; l++) sens_w[l][m] *= w[l] / norm; + } + } +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(int i = 0; i < n; i++) + for(int j = 0; j < n; j++) + { + const float *spec = pack->spectra + ((size_t)i * n + j) * SF_NWL; + double acc[3] = { 0.0, 0.0, 0.0 }; + for(int l = 0; l < SF_NWL; l++) + { + const double sp = spec[l]; + for(int m = 0; m < 3; m++) acc[m] += sp * sens_w[l][m]; + } + double *dst = s->tc_lut + ((size_t)i * n + j) * 3; + dst[0] = acc[0]; + dst[1] = acc[1]; + dst[2] = acc[2]; + } + /* [gc] remap_tc_lut_for_compression: new_lut[tc] = old_lut[compress(tc)] */ + if(p->input_gamut_compress) + { + double *old = malloc((size_t)n * n * 3 * sizeof(double)); + memcpy(old, s->tc_lut, (size_t)n * n * 3 * sizeof(double)); + const double scale = (double)(n - 1); +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(int i = 0; i < n; i++) + for(int j = 0; j < n; j++) + { + const double tc[2] = { i / scale, j / scale }; + double xy[2], cxy[2], ctc[2]; + quad2tri(xy, tc); + compress_xy_radial(cxy, xy, film_ref_xy, pack->locus, pack->locus_n); + tri2quad(ctc, cxy); + bilinear_2d_clamped(s->tc_lut + ((size_t)i * n + j) * 3, old, n, + ctc[0] * scale, ctc[1] * scale); + } + free(old); + } + } + + /* ----- film develop ---------------------------------------------------- */ + s->le0 = film->log_exposure[0]; + s->le_step = (film->log_exposure[SF_NLE - 1] - film->log_exposure[0]) / (SF_NLE - 1); + for(int c = 0; c < 3; c++) s->gamma[c] = p->density_curve_gamma; + for(int c = 0; c < 3; c++) + { + double mn = INFINITY, mx = -INFINITY; + for(int i = 0; i < SF_NLE; i++) + { + const double v = film->density_curves[i][c]; + if(v < mn) mn = v; + if(v > mx) mx = v; + } + for(int i = 0; i < SF_NLE; i++) s->curves_norm[i][c] = film->density_curves[i][c] - mn; + s->film_dmax[c] = mx - mn; + s->film_dmin[c] = mn; + } + /* [cp] per-film grain catalogue data (film_render_defaults[stock].grain); + falls back to spektrafilm's original single fixed profile when the pack + predates per-film grain or the stock has no entry. density_min shares + p->grain_density_min with the enlarger/scan table-range code below, so + it is overwritten in place rather than kept as a separate sim field. */ + { + /* matches SF_GRAIN_LEGACY_RMS / SF_GRAIN_LEGACY_UNIFORMITY in + spektra_core.h — spektrafilm's original single fixed grain profile */ + const double legacy_rms[3] = { 6.0, 8.0, 10.0 }; + const double legacy_unif[3] = { 0.97, 0.97, 0.97 }; + for(int c = 0; c < 3; c++) + { + s->grain_rms[c] = legacy_rms[c]; + s->grain_uniformity[c] = legacy_unif[c]; + } + sf_pack_film_grain(pack, film->stock, s->grain_rms, s->grain_uniformity, + p->grain_density_min); + } + /* [cp] coupler matrix: donor row -> receiver column, scaled by amount */ + s->couplers_active = p->couplers_active; + { + double M[3][3] = { { 0 } }; + M[0][0] = p->gamma_samelayer[0] * p->inhibition_samelayer; + M[1][1] = p->gamma_samelayer[1] * p->inhibition_samelayer; + M[2][2] = p->gamma_samelayer[2] * p->inhibition_samelayer; + M[0][1] = p->gamma_inter_r_gb[0] * p->inhibition_interlayer; + M[0][2] = p->gamma_inter_r_gb[1] * p->inhibition_interlayer; + M[1][0] = p->gamma_inter_g_rb[0] * p->inhibition_interlayer; + M[1][2] = p->gamma_inter_g_rb[1] * p->inhibition_interlayer; + M[2][0] = p->gamma_inter_b_rg[0] * p->inhibition_interlayer; + M[2][1] = p->gamma_inter_b_rg[1] * p->inhibition_interlayer; + for(int i = 0; i < 3; i++) + for(int j = 0; j < 3; j++) s->couplers_M[i][j] = M[i][j] * p->couplers_amount; + + /* [cp] Langmuir parameters (dev/0.4+ packs; absent -> linear 0.3.x). + Negative: donor-side saturation, K = k*d_max, D_ref = d_max/2. + Positive/reversal: linear donor, receiver-side saturation with + c_ref[m] = sum_k D_ref[k]*M_unit[k][m] from the amount-INdependent + matrix, Kr = k_recv * 2*c_ref. */ + for(int c = 0; c < 3; c++) + { + s->couplers_donor_K[c] = INFINITY; + s->couplers_recv_Kr[c] = INFINITY; + s->couplers_donor_Dref[c] = 0.5 * s->film_dmax[c]; + s->couplers_recv_cref[c] = 0.0; + } + s->couplers_donor_lm = 0; + s->couplers_recv_lm = 0; + s->coupler_diff_um = SF_COUPLER_BLUR_UM; + s->coupler_tail_um = 0.0; + s->coupler_tail_w = 0.0; + sf_pack_film_coupler_diffusion(pack, film->stock, &s->coupler_diff_um, + &s->coupler_tail_um, &s->coupler_tail_w); + if(s->coupler_tail_w <= 0.0 || s->coupler_tail_um <= 0.0) + { + s->coupler_tail_um = 0.0; + s->coupler_tail_w = 0.0; + } + double lm_donor[3], lm_recv[3]; + if(sf_pack_film_langmuir(pack, film->stock, lm_donor, lm_recv)) + { + if(s->film_positive) + { + s->couplers_recv_lm = 1; + for(int m = 0; m < 3; m++) + { + double cref = 0.0; + for(int k = 0; k < 3; k++) cref += s->couplers_donor_Dref[k] * M[k][m]; + s->couplers_recv_cref[m] = cref; + s->couplers_recv_Kr[m] = lm_recv[m] * 2.0 * cref; + } + } + else + { + s->couplers_donor_lm = 1; + for(int c = 0; c < 3; c++) + s->couplers_donor_K[c] = lm_donor[c] * s->film_dmax[c]; + } + } + } + /* [cp] compute_density_curves_before_dir_couplers */ + if(s->couplers_active) + { + double le_0[SF_NLE][3]; + for(int i = 0; i < SF_NLE; i++) + for(int m = 0; m < 3; m++) + { + double cac = 0.0; + for(int k = 0; k < 3; k++) + { + double silver = s->film_positive ? s->film_dmax[k] - s->curves_norm[i][k] + : s->curves_norm[i][k]; + if(s->couplers_donor_lm) + silver = silver * (s->couplers_donor_K[k] + s->couplers_donor_Dref[k]) + / (s->couplers_donor_K[k] + silver); + cac += silver * s->couplers_M[k][m]; + } + if(s->couplers_recv_lm) + cac = cac * (s->couplers_recv_Kr[m] + s->couplers_recv_cref[m]) + / (s->couplers_recv_Kr[m] + cac); + le_0[i][m] = film->log_exposure[i] - cac; + } + for(int c = 0; c < 3; c++) + { + double xp[SF_NLE], fp[SF_NLE]; + for(int i = 0; i < SF_NLE; i++) + { + xp[i] = le_0[i][c]; + fp[i] = s->film_positive ? -s->curves_norm[i][c] : s->curves_norm[i][c]; + } + for(int i = 0; i < SF_NLE; i++) + { + const double v = interp_general(film->log_exposure[i], xp, fp, SF_NLE); + s->curves_before[i][c] = s->film_positive ? -v : v; + } + } + } + else + memcpy(s->curves_before, s->curves_norm, sizeof(s->curves_before)); + + /* ----- printing -------------------------------------------------------- */ + if(s->has_print) + { + const double *illu_src = g_hash_table_lookup(pack->illuminants, p->enlarger_illuminant); + const double *filters = g_hash_table_lookup(pack->dichroics, p->dichroic_brand); + if(!illu_src || !filters) + { + set_error(errmsg, "spektra_sim: pack misses enlarger illuminant '%s' or dichroic '%s'", + p->enlarger_illuminant, p->dichroic_brand); + sf_sim_free(s); + return NULL; + } + const double cc_print[3] = { p->c_filter_neutral, p->m_filter_neutral + p->m_filter_shift, + p->y_filter_neutral + p->y_filter_shift }; + const double cc_pre[3] = { p->c_filter_neutral, p->m_filter_neutral + p->preflash_m_shift, + p->y_filter_neutral + p->preflash_y_shift }; + apply_dichroic_cc(s->illum_print, illu_src, filters, cc_print); + apply_dichroic_cc(s->illum_preflash, illu_src, filters, cc_pre); + for(int l = 0; l < SF_NWL; l++) + for(int m = 0; m < 3; m++) + { + const double v = pow(10.0, print->log_sensitivity[l][m]); + s->print_sens[l][m] = isfinite(v) ? v : 0.0; + } + memcpy(s->film_chan_density, film->channel_density, sizeof(s->film_chan_density)); + memcpy(s->film_base_density, film->base_density, sizeof(s->film_base_density)); + + /* [st] midgray print balance (geometric-mean normalization) */ + s->midgray_factor = 1.0; + { + double ds_mid[SF_NWL], ds_comp[SF_NWL]; + midgray_density_spectral(s, film, film_ref_xy, SF_MIDGRAY, ds_mid); + const double f_mid = exposure_factor(s, ds_mid); + double f_comp = 1.0; + if(p->print_exposure_compensation) + { + midgray_density_spectral(s, film, film_ref_xy, SF_MIDGRAY * s->ev_scale, ds_comp); + f_comp = exposure_factor(s, ds_comp); + } + if(p->print_exposure_compensation && !p->normalize_print_exposure) + s->midgray_factor = f_comp / f_mid; + else if(p->normalize_print_exposure && p->print_exposure_compensation) + s->midgray_factor = f_comp; + else if(p->normalize_print_exposure && !p->print_exposure_compensation) + s->midgray_factor = f_mid; + else + s->midgray_factor = 1.0; + } + /* [st] preflash through the base density only */ + s->preflash_raw[0] = s->preflash_raw[1] = s->preflash_raw[2] = 0.0; + if(p->preflash_exposure > 0.0) + for(int l = 0; l < SF_NWL; l++) + { + double light = s->illum_preflash[l] * pow(10.0, -film->base_density[l]); + if(!isfinite(light)) light = 0.0; + for(int m = 0; m < 3; m++) + s->preflash_raw[m] += light * s->print_sens[l][m] * p->preflash_exposure; + } + + /* enlarger table range: [-grain density_min, nanmax(unnormalized curves)] */ + for(int c = 0; c < 3; c++) + { + double mx = -INFINITY; + for(int i = 0; i < SF_NLE; i++) + if(film->density_curves[i][c] > mx) mx = film->density_curves[i][c]; + s->enl_lo[c] = -p->grain_density_min[c]; + s->enl_hi[c] = mx; + } + build_print_curves(s->print_curves, print, p); + } + + /* ----- scanning -------------------------------------------------------- */ + { + const sf_profile_t *sp = s->has_print ? print : film; + memcpy(s->scan_chan_density, sp->channel_density, sizeof(s->scan_chan_density)); + memcpy(s->scan_base_density, sp->base_density, sizeof(s->scan_base_density)); + const double *illu_view = g_hash_table_lookup(pack->illuminants, sp->viewing_illuminant); + if(!illu_view) + { + set_error(errmsg, "spektra_sim: pack misses viewing illuminant '%s'", + sp->viewing_illuminant); + sf_sim_free(s); + return NULL; + } + memcpy(s->illum_view, illu_view, sizeof(s->illum_view)); + s->xyz_norm = 0.0; + for(int l = 0; l < SF_NWL; l++) s->xyz_norm += illu_view[l] * pack->cmfs[l][1]; + for(int c = 0; c < 3; c++) + { + s->illum_view_xyz[c] = 0.0; + for(int l = 0; l < SF_NWL; l++) s->illum_view_xyz[c] += illu_view[l] * pack->cmfs[l][c]; + s->illum_view_xyz[c] /= s->xyz_norm; + } + /* scan table range */ + if(s->has_print) + for(int c = 0; c < 3; c++) + { + double mn = INFINITY, mx = -INFINITY; + for(int i = 0; i < SF_NLE; i++) + { + const double v = print->density_curves[i][c]; + if(v < mn) mn = v; + if(v > mx) mx = v; + } + s->scan_lo[c] = mn; + s->scan_hi[c] = mx; + } + else + for(int c = 0; c < 3; c++) + { + s->scan_lo[c] = -p->grain_density_min[c]; + s->scan_hi[c] = s->film_dmax[c]; /* == nanmax(curves) - min; see below */ + } + /* reference uses nanmax of the raw film curves for scan_film */ + if(!s->has_print) + for(int c = 0; c < 3; c++) + { + double mx = -INFINITY; + for(int i = 0; i < SF_NLE; i++) + if(film->density_curves[i][c] > mx) mx = film->density_curves[i][c]; + s->scan_hi[c] = mx; + } + /* output matrix: CAT02 from the viewing illuminant to the output white */ + double view_xy[2] = { s->illum_view_xyz[0] + / (s->illum_view_xyz[0] + s->illum_view_xyz[1] + + s->illum_view_xyz[2]), + s->illum_view_xyz[1] + / (s->illum_view_xyz[0] + s->illum_view_xyz[1] + + s->illum_view_xyz[2]) }; + double cat[9]; + cat_matrix(cat, SF_M_CAT02, view_xy, p->output_white_xy); + mat3_mul(s->m_out, p->output_xyz_to_rgb, cat); + } + + /* ----- scanner black/white point for positive film scans ---------------- */ + /* A slide has base density and never reaches the paper's D-max; a real + scanner sets black/white points. Reference: color_reference.py with + scanner.black_correction = white_correction = true, which upstream's UI + uses for slides -- off (upstream default) the scan is washed out. Only + affects scan-film mode with positive film; negatives are untouched. */ + s->scan_bw_on = 0; + s->scan_bw_m = 1.0; + s->scan_bw_q = 0.0; + if(!s->has_print && s->film_positive) + { + /* upstream treats the 0.98 / 0.01 scanner levels as sRGB-encoded and + linearizes them (color_reference._remove_sRGB_cctf) */ + const double white_level = pow((0.98 + 0.055) / 1.055, 2.4); + const double black_level = 0.01 / 12.92; + double cmy_black[3], cmy_white[3] = { 0.0, 0.0, 0.0 }; + for(int c = 0; c < 3; c++) + { + double mx = -INFINITY; + for(int i = 0; i < SF_NLE; i++) + { + const double v = film->density_curves[i][c]; + if(isfinite(v) && v > mx) mx = v; + } + cmy_black[c] = mx; + } + double lxb[3], lxw[3]; + cmy_to_log_xyz(s, cmy_black, lxb); + cmy_to_log_xyz(s, cmy_white, lxw); + const double y_black = pow(10.0, lxb[1]), y_white = pow(10.0, lxw[1]); + const double m = (white_level - black_level) / (y_white - y_black + 1e-10); + const double q = black_level - m * y_black; + s->scan_bw_on = 1; + s->scan_bw_m = m; + s->scan_bw_q = q; + + /* film exposure correction so midgray still lands on midgray after the + correction (reference: black_white_filming_exposure_correction) */ + const double midgray_corrected = (0.184 - q) / m; + if(midgray_corrected > 0.0) + { + const double density_midgray = -log10(0.184); + const double density_midgray_corrected = -log10(midgray_corrected); + double dmin_av = 0.0; + int nvalid = 0; + for(int i = 0; i < SF_NWL; i++) + if(isfinite(film->base_density[i])) + { + dmin_av += film->base_density[i]; + nvalid++; + } + dmin_av = nvalid ? dmin_av / nvalid : 0.0; + double curve_av[SF_NLE]; + for(int i = 0; i < SF_NLE; i++) + { + double sum = 0.0; + int nc = 0; + for(int c = 0; c < 3; c++) + if(isfinite(film->density_curves[i][c])) + { + sum += film->density_curves[i][c]; + nc++; + } + curve_av[i] = nc ? sum / nc : 0.0; + } + /* np.interp(x, -curve_av, log_exposure): -curve_av ascends for positive + film (density falls with exposure); endpoint clamp like np.interp */ + const double le_mid_c = -interp_ascending(-(density_midgray_corrected - dmin_av), + curve_av, film->log_exposure, SF_NLE); + const double le_mid = -interp_ascending(-(density_midgray - dmin_av), curve_av, + film->log_exposure, SF_NLE); + const double exposure_correction = pow(10.0, le_mid_c - le_mid); + s->ev_scale /= exposure_correction; /* raw *= 1/correction */ + } + } + + /* ----- runtime 3D tables ------------------------------------------------ */ + if(s->lut_steps >= 2) + { + if(s->has_print) + build_lut3d(s, cmy_to_print_lograw, s->enl_lo, s->enl_hi, s->lut_steps, &s->enl_lut, + &s->enl_sx, &s->enl_sy, &s->enl_sz, &s->enl_cmin, &s->enl_cmax); + build_lut3d(s, cmy_to_log_xyz, s->scan_lo, s->scan_hi, s->lut_steps, &s->scan_lut, + &s->scan_sx, &s->scan_sy, &s->scan_sz, &s->scan_cmin, &s->scan_cmax); + } + + /* ----- output gamut compression ----------------------------------------- */ + memcpy(s->out_rgb2xyz, p->output_rgb_to_xyz, sizeof(s->out_rgb2xyz)); + memcpy(s->out_xyz2rgb, p->output_xyz_to_rgb, sizeof(s->out_xyz2rgb)); + mat3_inv(s->oklab_m1inv, SF_OKLAB_M1); + mat3_inv(s->oklab_m2inv, SF_OKLAB_M2); + if(s->out_compress == SF_OUTPUT_COMPRESS_OKLCH) build_cmax_table(s); + + return s; +} + +/* ------------------------------------------------------------------------ */ +/* per-pixel stages */ +/* ------------------------------------------------------------------------ */ + +void sf_sim_expose(const sf_sim_t *sim, const float *rgb_in, float *raw, size_t npix, + int nch_in, int nch_out) +{ +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(size_t px = 0; px < npix; px++) + { + const float *in = rgb_in + px * nch_in; + float *out = raw + px * nch_out; + const double rgb[3] = { in[0], in[1], in[2] }; + double r[3]; + expose_pixel(sim->m_in, sim->tc_lut, sim->tc_n, rgb, r); + for(int c = 0; c < 3; c++) out[c] = (float)(r[c] * sim->ev_scale); + } +} + +void sf_sim_lograw(float *raw, size_t npix, int nch) +{ +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(size_t px = 0; px < npix; px++) + { + float *v = raw + px * nch; + for(int c = 0; c < 3; c++) + v[c] = (float)log10(fmax((double)v[c], 0.0) + SF_LOG_EPS); + } +} + +void sf_sim_develop_corr(const sf_sim_t *sim, const float *lograw, float *corr, + size_t npix, int nch_in) +{ + if(!sim->couplers_active) + { + memset(corr, 0, npix * 3 * sizeof(float)); + return; + } +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(size_t px = 0; px < npix; px++) + { + const float *in = lograw + px * nch_in; + float *out = corr + px * 3; + double silver[3]; + for(int c = 0; c < 3; c++) + { + const double d = interp_curve_uniform(in[c], sim->gamma[c], sim->le0, sim->le_step, + sim->curves_norm, c); + silver[c] = sim->film_positive ? sim->film_dmax[c] - d : d; + if(sim->couplers_donor_lm) + silver[c] = silver[c] * (sim->couplers_donor_K[c] + sim->couplers_donor_Dref[c]) + / (sim->couplers_donor_K[c] + silver[c]); + } + for(int m = 0; m < 3; m++) + { + double acc = 0.0; + for(int k = 0; k < 3; k++) acc += silver[k] * sim->couplers_M[k][m]; + out[m] = (float)acc; + } + } +} + +void sf_sim_develop(const sf_sim_t *sim, const float *lograw, const float *corr, + float *cmy, size_t npix, int nch_in, int nch_out) +{ + const int use_corr = sim->couplers_active && corr != NULL; + const double(*curves)[3] = use_corr ? sim->curves_before : sim->curves_norm; +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(size_t px = 0; px < npix; px++) + { + const float *in = lograw + px * nch_in; + const float *cr = use_corr ? corr + px * 3 : NULL; + float *out = cmy + px * nch_out; + for(int c = 0; c < 3; c++) + { + double crv = cr ? (double)cr[c] : 0.0; + /* receiver-side Langmuir applies to the inhibitor that ARRIVES, i.e. + after the spatial diffusion blur, hence here and not in _corr */ + if(cr && sim->couplers_recv_lm) + crv = crv * (sim->couplers_recv_Kr[c] + sim->couplers_recv_cref[c]) + / (sim->couplers_recv_Kr[c] + crv); + const double x = (double)in[c] - crv; + out[c] = (float)interp_curve_uniform(x, sim->gamma[c], sim->le0, sim->le_step, + curves, c); + } + } +} + +void sf_sim_print_expose(const sf_sim_t *sim, const float *cmy, float *lograw, + size_t npix, int nch_in, int nch_out) +{ + const int steps = sim->lut_steps; + const sf_pchip3d_t P = { steps, sim->enl_lut, sim->enl_sx, sim->enl_sy, sim->enl_sz, + sim->enl_cmin, sim->enl_cmax }; +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(size_t px = 0; px < npix; px++) + { + const float *in = cmy + px * nch_in; + float *out = lograw + px * nch_out; + double l1[3]; + if(steps >= 2) + { + const double scale = (double)(steps - 1); + const double r = (in[0] - sim->enl_lo[0]) / (sim->enl_hi[0] - sim->enl_lo[0]) * scale; + const double g = (in[1] - sim->enl_lo[1]) / (sim->enl_hi[1] - sim->enl_lo[1]) * scale; + const double b = (in[2] - sim->enl_lo[2]) / (sim->enl_hi[2] - sim->enl_lo[2]) * scale; + pchip3d_interp(&P, r, g, b, l1); + } + else + { + const double c[3] = { in[0], in[1], in[2] }; + cmy_to_print_lograw(sim, c, l1); + } + /* [st] raw = 10^l1 * print_exposure; back to log10 */ + for(int m = 0; m < 3; m++) + { + const double r = pow(10.0, l1[m]) * sim->print_exposure; + out[m] = (float)log10(fmax(r, 0.0) + SF_LOG_EPS); + } + } +} + +void sf_sim_print_develop(const sf_sim_t *sim, const float *lograw, float *cmy, + size_t npix, int nch_in, int nch_out) +{ +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(size_t px = 0; px < npix; px++) + { + const float *in = lograw + px * nch_in; + float *out = cmy + px * nch_out; + for(int c = 0; c < 3; c++) + out[c] = (float)interp_curve_uniform(in[c], 1.0, sim->le0, sim->le_step, + sim->print_curves, c); + } +} + +void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, size_t npix, + int nch_in, int nch_out) +{ + const int steps = sim->lut_steps; + const sf_pchip3d_t P = { steps, sim->scan_lut, sim->scan_sx, sim->scan_sy, sim->scan_sz, + sim->scan_cmin, sim->scan_cmax }; +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(size_t px = 0; px < npix; px++) + { + const float *in = cmy + px * nch_in; + float *out = rgb_out + px * nch_out; + double lx[3]; + if(steps >= 2) + { + const double scale = (double)(steps - 1); + const double r = (in[0] - sim->scan_lo[0]) / (sim->scan_hi[0] - sim->scan_lo[0]) * scale; + const double g = (in[1] - sim->scan_lo[1]) / (sim->scan_hi[1] - sim->scan_lo[1]) * scale; + const double b = (in[2] - sim->scan_lo[2]) / (sim->scan_hi[2] - sim->scan_lo[2]) * scale; + pchip3d_interp(&P, r, g, b, lx); + } + else + { + const double c[3] = { in[0], in[1], in[2] }; + cmy_to_log_xyz(sim, c, lx); + } + double xyz[3], rgb[3]; + for(int m = 0; m < 3; m++) xyz[m] = pow(10.0, lx[m]); + if(sim->scan_bw_on) + { + /* scanner black/white point (positive film): scale toward Y in [0,1] */ + const double y = xyz[1]; + double yc = sim->scan_bw_m * y + sim->scan_bw_q; + yc = yc < 0.0 ? 0.0 : (yc > 1.0 ? 1.0 : yc); + const double sc = yc / (y + 1e-10); + for(int m = 0; m < 3; m++) xyz[m] *= sc; + } + mat3_mulv(rgb, sim->m_out, xyz); + if(sim->out_compress == SF_OUTPUT_COMPRESS_OKLCH) + compress_rgb_oklch(sim, rgb); + else if(sim->out_compress == SF_OUTPUT_COMPRESS_ACES_RGC) + compress_rgb_aces(rgb); + for(int c = 0; c < 3; c++) out[c] = (float)rgb[c]; + } +} + +void sf_sim_process(const sf_sim_t *sim, const float *rgb_in, float *rgb_out, size_t npix, + int nch_in, int nch_out) +{ + float *tmp = malloc(npix * 3 * sizeof(float)); + float *corr = sim->couplers_active ? malloc(npix * 3 * sizeof(float)) : NULL; + sf_sim_expose(sim, rgb_in, tmp, npix, nch_in, 3); + sf_sim_lograw(tmp, npix, 3); + if(corr) sf_sim_develop_corr(sim, tmp, corr, npix, 3); + sf_sim_develop(sim, tmp, corr, tmp, npix, 3, 3); + if(sim->has_print) + { + sf_sim_print_expose(sim, tmp, tmp, npix, 3, 3); + sf_sim_print_develop(sim, tmp, tmp, npix, 3, 3); + } + sf_sim_scan(sim, tmp, rgb_out, npix, 3, nch_out); + free(corr); + free(tmp); +} + +/* ------------------------------------------------------------------------ */ +/* GPU export: float copies of the per-pixel tables */ +/* ------------------------------------------------------------------------ */ + +static float *dup_f(const double *src, size_t n) +{ + float *dst = malloc(n * sizeof(float)); + if(dst) + for(size_t i = 0; i < n; i++) dst[i] = (float)src[i]; + return dst; +} + +static void cp9f(float dst[9], const double src[9]) +{ + for(int i = 0; i < 9; i++) dst[i] = (float)src[i]; +} + +/* variants that keep the 2D array type so the compiler sees the full extent + (a plain &a[0][0] decay trips -Werror=stringop-overread on gcc) */ +static void cp33f(float dst[9], const double src[3][3]) +{ + for(int i = 0; i < 3; i++) + for(int j = 0; j < 3; j++) dst[i * 3 + j] = (float)src[i][j]; +} + +static float *dup_f3(const double (*src)[3], size_t rows) +{ + float *dst = malloc(rows * 3 * sizeof(float)); + if(dst) + for(size_t i = 0; i < rows; i++) + for(int c = 0; c < 3; c++) dst[i * 3 + c] = (float)src[i][c]; + return dst; +} + +sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *s) +{ + if(!s || s->lut_steps < 2) return NULL; /* exact spectral: no GPU path */ + sf_sim_gpu_t *g = calloc(1, sizeof(sf_sim_gpu_t)); + if(!g) return NULL; + + cp9f(g->m_in, s->m_in); + g->ev_scale = (float)s->ev_scale; + g->tc_n = s->tc_n; + g->tc_lut = dup_f(s->tc_lut, (size_t)s->tc_n * s->tc_n * 3); + + for(int c = 0; c < 3; c++) g->gamma[c] = (float)s->gamma[c]; + g->le0 = (float)s->le0; + g->le_step = (float)s->le_step; + g->curves_norm = dup_f3(s->curves_norm, SF_NLE); + g->curves_before = dup_f3(s->curves_before, SF_NLE); + cp33f(g->couplers_M, (const double (*)[3])s->couplers_M); + for(int c = 0; c < 3; c++) g->film_dmax[c] = (float)s->film_dmax[c]; + for(int c = 0; c < 3; c++) + { + g->grain_rms[c] = (float)s->grain_rms[c]; + g->grain_uniformity[c] = (float)s->grain_uniformity[c]; + /* self-consistent with g->film_dmax: see sf_sim_film_grain3 */ + g->grain_dmin[c] = (float)s->film_dmin[c]; + } + g->film_positive = s->film_positive; + g->couplers_active = s->couplers_active; + + g->has_print = s->has_print; + g->steps = s->lut_steps; + const size_t n3 = (size_t)s->lut_steps * s->lut_steps * s->lut_steps * 3; + const size_t m3 = (size_t)(s->lut_steps - 1) * (s->lut_steps - 1) * (s->lut_steps - 1) * 3; + if(s->has_print) + { + for(int c = 0; c < 3; c++) + { + g->enl_lo[c] = (float)s->enl_lo[c]; + g->enl_hi[c] = (float)s->enl_hi[c]; + } + g->enl_lut = dup_f(s->enl_lut, n3); + g->enl_sx = dup_f(s->enl_sx, n3); + g->enl_sy = dup_f(s->enl_sy, n3); + g->enl_sz = dup_f(s->enl_sz, n3); + g->enl_cmin = dup_f(s->enl_cmin, m3); + g->enl_cmax = dup_f(s->enl_cmax, m3); + g->print_exposure = (float)s->print_exposure; + g->print_curves = dup_f3(s->print_curves, SF_NLE); + } + for(int c = 0; c < 3; c++) + { + g->scan_lo[c] = (float)s->scan_lo[c]; + g->scan_hi[c] = (float)s->scan_hi[c]; + } + g->scan_lut = dup_f(s->scan_lut, n3); + g->scan_sx = dup_f(s->scan_sx, n3); + g->scan_sy = dup_f(s->scan_sy, n3); + g->scan_sz = dup_f(s->scan_sz, n3); + g->scan_cmin = dup_f(s->scan_cmin, m3); + g->scan_cmax = dup_f(s->scan_cmax, m3); + cp9f(g->m_out, s->m_out); + g->scan_bw_on = s->scan_bw_on; + g->scan_bw_m = (float)s->scan_bw_m; + g->scan_bw_q = (float)s->scan_bw_q; + g->film_bw = s->film_bw; + g->coupler_diff_um = (float)s->coupler_diff_um; + g->coupler_tail_um = (float)s->coupler_tail_um; + g->coupler_tail_w = (float)s->coupler_tail_w; + g->couplers_donor_lm = s->couplers_donor_lm; + g->couplers_recv_lm = s->couplers_recv_lm; + for(int c = 0; c < 3; c++) + { + /* INFINITY-safe: when linear, ship K large enough that the float + formula degenerates to identity even without isinf checks */ + g->couplers_donor_K[c] = s->couplers_donor_lm ? (float)s->couplers_donor_K[c] : 1e30f; + g->couplers_donor_Dref[c] = (float)s->couplers_donor_Dref[c]; + g->couplers_recv_Kr[c] = s->couplers_recv_lm ? (float)s->couplers_recv_Kr[c] : 1e30f; + g->couplers_recv_cref[c] = (float)s->couplers_recv_cref[c]; + } + + g->out_compress = s->out_compress; + cp9f(g->out_rgb2xyz, s->out_rgb2xyz); + cp9f(g->out_xyz2rgb, s->out_xyz2rgb); + cp9f(g->oklab_m1, SF_OKLAB_M1); + cp9f(g->oklab_m2, SF_OKLAB_M2); + cp9f(g->oklab_m1inv, s->oklab_m1inv); + cp9f(g->oklab_m2inv, s->oklab_m2inv); + g->cmax_table = s->cmax; /* borrowed; may be NULL when compression != oklch */ + g->cmax_nl = SF_CMAX_NL; + g->cmax_nh = SF_CMAX_NH; + return g; +} + +void sf_sim_gpu_free(sf_sim_gpu_t *g) +{ + if(!g) return; + free(g->tc_lut); + free(g->curves_norm); + free(g->curves_before); + free(g->enl_lut); free(g->enl_sx); free(g->enl_sy); free(g->enl_sz); + free(g->enl_cmin); free(g->enl_cmax); + free(g->print_curves); + free(g->scan_lut); free(g->scan_sx); free(g->scan_sy); free(g->scan_sz); + free(g->scan_cmin); free(g->scan_cmax); + free(g); +} + +int sf_sim_film_bw(const sf_sim_t *sim) { return sim ? sim->film_bw : 0; } + +void sf_sim_coupler_diffusion(const sf_sim_t *sim, double *size_um, double *tail_um, + double *tail_w) +{ + *size_um = sim ? sim->coupler_diff_um : SF_COUPLER_BLUR_UM; + *tail_um = sim ? sim->coupler_tail_um : 0.0; + *tail_w = sim ? sim->coupler_tail_w : 0.0; +} + +void sf_sim_film_dmax3(const sf_sim_t *sim, float dmax[3]) +{ + for(int c = 0; c < 3; c++) dmax[c] = sim ? (float)sim->film_dmax[c] : 2.2f; +} + +void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3]) +{ + /* matches SF_GRAIN_LEGACY_* in spektra_core.h */ + static const float legacy_rms[3] = { 6.0f, 8.0f, 10.0f }; + static const float legacy_unif[3] = { 0.97f, 0.97f, 0.97f }; + static const float legacy_dmin[3] = { 0.03f, 0.03f, 0.03f }; + for(int c = 0; c < 3; c++) + { + rms[c] = sim ? (float)sim->grain_rms[c] : legacy_rms[c]; + uniformity[c] = sim ? (float)sim->grain_uniformity[c] : legacy_unif[c]; + /* film_dmin (this module's own curve floor), NOT p.grain_density_min: + the grain formula reconstructs dmax_abs = dmax_c + dmin, which is only + the film's real absolute D-max when dmin is the SAME floor that + produced dmax_c. An independently-sourced density_min (e.g. from a + separate curve-fit pass upstream) breaks that identity and silently + biases the particle count — see sf_grain_delta_dmax. */ + dmin[c] = sim ? (float)sim->film_dmin[c] : legacy_dmin[c]; + } +} + diff --git a/src/iop/spektra_sim.h b/src/iop/spektra_sim.h new file mode 100644 index 000000000000..f5769f0b08c9 --- /dev/null +++ b/src/iop/spektra_sim.h @@ -0,0 +1,325 @@ +/* + 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 . +*/ + +/* spektra_sim — native port of the spektrafilm runtime pipeline. + * + * This is a C implementation of the deterministic per-pixel model of + * spektrafilm (https://github.com/andreavolpato/spektrafilm), the spectral + * film simulation by Andrea Volpato (GPLv3; film modeling powered by + * spektrafilm). It replaces the baked .cube-bundle approach: all colour + * science is computed at parameter-commit time from a *data pack* exported + * from a spektrafilm release (measured stock profiles, the hanatos2025 + * irradiance spectra LUT, illuminant SPDs, dichroic filter curves), so a new + * spektrafilm release is adopted by re-running the exporter — no rebake, no + * code changes as long as the model version matches. + * + * Model version tracked by this port: spektrafilm 0.3.x runtime + * (SimulationPipeline: filming.expose → filming.develop → printing.expose → + * printing.develop → scanning.scan). The stochastic / spatial effects + * (grain, halation, scatter, diffusion filters, coupler diffusion blur) are + * intentionally *not* in this engine — they act between the per-pixel + * stages and stay in the caller (darktable already has fast gaussian + * infrastructure; see spektra_core.c). + * + * Pipeline stages exposed here (all deterministic, all pure per-pixel): + * + * rgb_in --expose--> raw (linear film exposure) [caller: highlight + * boost, diffusion filter, scatter, halation in linear domain] + * raw --lograw--> log_raw + * log_raw --develop_corr--> DIR coupler correction [caller: blur] + * (log_raw, corr) --develop--> cmy film density [caller: grain] + * cmy --print_expose--> log_raw_print + * log_raw_print --print_develop--> cmy print density + * cmy --scan--> rgb_out (linear, output primaries, gamut compressed) + * + * The heavy spectral integrals (print exposure through the filtered + * enlarger illuminant, scan through the viewing illuminant to XYZ) can run + * either exactly per pixel or through runtime-built 3D tables with monotone + * PCHIP interpolation — the same two paths the reference implementation + * has (use_enlarger_lut / use_scanner_lut). The tables are built here from + * profile data at sf_sim_build() time; nothing is pre-baked on disk. + */ + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define SF_NWL 81 /* 380..780 nm in 5 nm steps — spektrafilm SPECTRAL_SHAPE */ +#define SF_NLE 256 /* log-exposure grid — spektrafilm LOG_EXPOSURE */ + +typedef struct sf_pack_t sf_pack_t; +typedef struct sf_profile_t sf_profile_t; +typedef struct sf_sim_t sf_sim_t; + +/* ---------------------------------------------------------------- pack -- */ + +/* Load a data pack directory (pack.json + spectra_lut.f32 + profiles/). + * On failure returns NULL and sets *errmsg (caller frees with free()). */ +sf_pack_t *sf_pack_load(const char *dir, char **errmsg); +void sf_pack_free(sf_pack_t *pack); +const char *sf_pack_version(const sf_pack_t *pack); + +/* Neutral enlarger filter database lookup (Kodak CC units, CMY order). + * Returns true and fills cmy[3] when a calibration exists for the triple. */ +bool sf_pack_neutral_filters(const sf_pack_t *pack, const char *print_stock, + const char *illuminant, const char *film_stock, + double cmy[3]); + +/* Per-film digested render defaults from the release (DIR coupler gamma + * matrix, halation preset). Any pointer may be NULL. Returns false if the + * stock has no entry (generic defaults are then left untouched). */ +/* Langmuir K factors from film_render_defaults (dev/0.4+ packs); returns + false and leaves outputs untouched when the pack predates them. */ +bool sf_pack_film_langmuir(const sf_pack_t *pack, const char *film_stock, + double donor_k[3], double receiver_k[3]); + +bool sf_pack_film_defaults(const sf_pack_t *pack, const char *film_stock, + double gamma_samelayer[3], + double gamma_inter_r_gb[2], + double gamma_inter_g_rb[2], + double gamma_inter_b_rg[2], + double halation_strength[3], + double halation_sigma_um[3], + double scatter_core_um[3], + double scatter_tail_um[3], + double scatter_tail_weight[3]); + +/* ------------------------------------------------------------- profile -- */ + +sf_profile_t *sf_profile_load(const char *path, char **errmsg); +void sf_profile_free(sf_profile_t *profile); +/* ---------------------------------------------------------- GPU export -- */ + +/* Float copies of everything a per-pixel GPU port needs. Buffers are malloc'd + * and owned by this struct EXCEPT cmax_table, which borrows the sim's own + * float table (keep the sim alive while using the export). + * Only the table-based paths export: lut_steps must be >= 2 (exact spectral + * has no GPU path) or sf_sim_gpu_export() returns NULL. */ +typedef struct sf_sim_gpu_t +{ + /* expose: work RGB -> film raw exposure */ + float m_in[9]; + float ev_scale; + int tc_n; + float *tc_lut; /* tc_n * tc_n * 3 */ + /* film develop */ + float gamma[3]; + float le0, le_step; + float *curves_norm; /* SF_NLE*3 */ + float *curves_before; /* SF_NLE*3 (== curves_norm when couplers off) */ + float couplers_M[9]; /* row donor -> column receiver, amount-scaled */ + float film_dmax[3]; + int film_positive, couplers_active; + /* printing (has_print == 0 in scan-film mode; buffers NULL then) */ + int has_print, steps; + float enl_lo[3], enl_hi[3]; + float *enl_lut, *enl_sx, *enl_sy, *enl_sz; /* steps^3 * 3 */ + float *enl_cmin, *enl_cmax; /* (steps-1)^3 * 3 */ + float print_exposure; + float *print_curves; /* SF_NLE*3 */ + /* scanning */ + float scan_lo[3], scan_hi[3]; + float *scan_lut, *scan_sx, *scan_sy, *scan_sz, *scan_cmin, *scan_cmax; + float m_out[9]; /* XYZ(view illuminant) -> output RGB, CAT02 included */ + int scan_bw_on; /* scanner black/white point (positive film scans) */ + float scan_bw_m, scan_bw_q; + /* Langmuir couplers (dev/0.4+ packs; flags 0 on 0.3.x = linear) */ + int film_bw; /* B&W stock: achromatic (channel-coupled) grain */ + float coupler_diff_um, coupler_tail_um, coupler_tail_w; + int couplers_donor_lm, couplers_recv_lm; + float couplers_donor_K[3], couplers_donor_Dref[3]; + float couplers_recv_Kr[3], couplers_recv_cref[3]; + /* per-film grain catalogue data (film_render_defaults[stock].grain in the + pack): RMS-granularity, uniformity and density floor, replacing the + earlier one-size-fits-all constants */ + float grain_rms[3], grain_uniformity[3], grain_dmin[3]; + /* output gamut compression */ + int out_compress; /* sf_output_compress_t */ + float out_rgb2xyz[9], out_xyz2rgb[9]; + float oklab_m1[9], oklab_m2[9], oklab_m1inv[9], oklab_m2inv[9]; + const float *cmax_table; /* cmax_nl * cmax_nh, borrowed from the sim */ + int cmax_nl, cmax_nh; +} sf_sim_gpu_t; + +sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *sim); +void sf_sim_gpu_free(sf_sim_gpu_t *g); + +int sf_sim_film_bw(const sf_sim_t *sim); +void sf_sim_film_dmax3(const sf_sim_t *sim, float dmax[3]); +/* per-film grain catalogue data (rms_granularity, uniformity, density_min); + falls back to the legacy fixed constants (SF_GRAIN_LEGACY_* in + spektra_core.h) when sim is NULL or the pack predates per-film grain. */ +void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3]); +bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock, + double rms[3], double uniformity[3], double density_min[3]); +#define SF_COUPLER_BLUR_UM 20.0 /* gaussian core default when pack lacks it */ +/* exponential-tail gaussian mixture (upstream fit, n=3) — shared with halation */ +#define SF_EXPTAIL_A0 0.1633 +#define SF_EXPTAIL_A1 0.6496 +#define SF_EXPTAIL_A2 0.1870 +#define SF_EXPTAIL_R0 0.5360 +#define SF_EXPTAIL_R1 1.5236 +#define SF_EXPTAIL_R2 2.7684 + +void sf_sim_coupler_diffusion(const sf_sim_t *sim, double *size_um, double *tail_um, + double *tail_w); +bool sf_pack_film_coupler_diffusion(const sf_pack_t *pack, const char *film_stock, + double *size_um, double *tail_um, double *tail_w); + +const char *sf_profile_stock(const sf_profile_t *p); +const char *sf_profile_name(const sf_profile_t *p); +const char *sf_profile_stage(const sf_profile_t *p); /* "filming" / "printing" */ +const char *sf_profile_type(const sf_profile_t *p); /* "negative" / "positive" */ +const char *sf_profile_target_print(const sf_profile_t *p); /* may be NULL */ + +/* -------------------------------------------------------------- params -- */ + +typedef enum sf_output_compress_t +{ + SF_OUTPUT_COMPRESS_OFF = 0, + SF_OUTPUT_COMPRESS_OKLCH = 1, /* reference default */ + SF_OUTPUT_COMPRESS_ACES_RGC = 2, +} sf_output_compress_t; + +typedef struct sf_sim_params_t +{ + /* camera / filming */ + double exposure_comp_ev; /* 0 */ + double density_curve_gamma; /* 1 */ + + /* DIR couplers (matrix part; spatial diffusion is the caller's blur) */ + bool couplers_active; /* true */ + double couplers_amount; /* 1 */ + double gamma_samelayer[3]; /* filled from pack film defaults */ + double gamma_inter_r_gb[2], gamma_inter_g_rb[2], gamma_inter_b_rg[2]; + double inhibition_samelayer; /* 1 */ + double inhibition_interlayer; /* 1 */ + + /* grain reference floor — used for table ranges even when grain itself + * runs in the caller (reference: GrainParams.density_min) */ + double grain_density_min[3]; /* (0.03, 0.03, 0.03) */ + + /* enlarger */ + const char *enlarger_illuminant; /* "TH-KG3" */ + const char *dichroic_brand; /* "custom" — reference color_enlarger default */ + double print_exposure; /* 1 */ + bool print_exposure_compensation;/* true */ + bool normalize_print_exposure; /* true */ + double c_filter_neutral, m_filter_neutral, y_filter_neutral; /* CC units; + seeded from the pack database in sf_sim_build() when neutral_from_db */ + bool neutral_from_db; /* true */ + double y_filter_shift, m_filter_shift; /* user CC shifts */ + double preflash_exposure; /* 0 */ + double preflash_y_shift, preflash_m_shift; + + /* print curve morph (s023) — identity at defaults */ + bool morph_active; + double morph_gamma, morph_gamma_fast, morph_gamma_slow; + double morph_gamma_r, morph_gamma_g, morph_gamma_b; + + /* scanning / output */ + bool scan_film; /* false: full negative→print→scan chain */ + int lut_steps; /* 0 = exact spectral per pixel; + >=2 = runtime 3D tables (ref default 17; + 33 recommended for production) */ + + /* input colour handling: linear RGB -> XYZ (source-white relative) and the + * source whitepoint xy. The engine appends a CAT16 adaptation to the film + * reference illuminant, matching spektrafilm's _rgb_to_tc_b(). */ + double input_rgb_to_xyz[9]; + double input_white_xy[2]; + bool input_gamut_compress; /* true — radial xy Reinhard (0,1,6) */ + + /* output colour handling: XYZ (output-white relative) <-> linear output + * RGB and the output whitepoint xy. The engine prepends a CAT02 + * adaptation from the viewing illuminant, matching colour.XYZ_to_RGB + * defaults used by spektrafilm's scanning stage. */ + double output_rgb_to_xyz[9]; + double output_xyz_to_rgb[9]; + double output_white_xy[2]; + sf_output_compress_t output_compress; /* SF_OUTPUT_COMPRESS_OKLCH */ +} sf_sim_params_t; + +void sf_sim_params_defaults(sf_sim_params_t *p); +/* convenience: fill input or output side with sRGB / ProPhoto matrices */ +void sf_sim_params_set_input_srgb(sf_sim_params_t *p); +void sf_sim_params_set_input_prophoto(sf_sim_params_t *p); +void sf_sim_params_set_input_rec2020(sf_sim_params_t *p); +void sf_sim_params_set_output_srgb(sf_sim_params_t *p); +void sf_sim_params_set_output_rec2020(sf_sim_params_t *p); + +/* --------------------------------------------------------------- build -- */ + +/* Build all runtime tables. print may be NULL when params->scan_film. + * Seeds params->gamma_* coupler defaults and neutral filters from the pack + * unless the caller already customized them (see .neutral_from_db). */ +sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, + const sf_profile_t *print, const sf_sim_params_t *params, + char **errmsg); +void sf_sim_free(sf_sim_t *sim); + +/* info for the caller's spatial effects */ +double sf_sim_film_dmax(const sf_sim_t *sim, int ch); /* normalized curve max */ + +/* ------------------------------------------------------ per-pixel API --- */ +/* All buffers are interleaved float with `nch` floats per pixel (>= 3); + * channels 0..2 are read/written, remaining channels are left untouched. + * In-place operation (in == out) is allowed for every stage. */ + +/* linear input RGB -> linear film raw exposure (includes 2^ev) */ +void sf_sim_expose(const sf_sim_t *sim, const float *rgb_in, float *raw, + size_t npix, int nch_in, int nch_out); + +/* raw -> log10(max(raw,0) + 1e-10), in place */ +void sf_sim_lograw(float *raw, size_t npix, int nch); + +/* DIR coupler correction field (to be spatially blurred by the caller). + * corr is a 3-channel interleaved buffer. No-op fill of zeros when couplers + * are inactive. */ +void sf_sim_develop_corr(const sf_sim_t *sim, const float *lograw, float *corr, + size_t npix, int nch_in); + +/* (lograw, blurred corr) -> cmy film density. corr may be NULL (no couplers). */ +void sf_sim_develop(const sf_sim_t *sim, const float *lograw, const float *corr, + float *cmy, size_t npix, int nch_in, int nch_out); + +/* cmy film density -> log print raw exposure (through the enlarger) */ +void sf_sim_print_expose(const sf_sim_t *sim, const float *cmy, float *lograw, + size_t npix, int nch_in, int nch_out); + +/* log print raw -> cmy print density */ +void sf_sim_print_develop(const sf_sim_t *sim, const float *lograw, float *cmy, + size_t npix, int nch_in, int nch_out); + +/* cmy density (print, or film when scan_film) -> linear output RGB, + * gamut compressed per params->output_compress */ +void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, + size_t npix, int nch_in, int nch_out); + +/* convenience: the full deterministic chain without spatial effects */ +void sf_sim_process(const sf_sim_t *sim, const float *rgb_in, float *rgb_out, + size_t npix, int nch_in, int nch_out); + +#ifdef __cplusplus +} +#endif diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c new file mode 100644 index 000000000000..02f710185ee9 --- /dev/null +++ b/src/iop/spektrafilm.c @@ -0,0 +1,1493 @@ +/* + 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 . +*/ + +/* spektrafilm — native spectral film simulation. + * + * Film modeling powered by spektrafilm (https://github.com/andreavolpato/spektrafilm), + * GPLv3, © Andrea Volpato. Film/paper profile data CC BY-SA 4.0. + * + * Unlike the earlier LUT-bundle module, this module computes the full + * spektrafilm colour pipeline natively per pixel: + * + * scene-linear work RGB + * -> CAT16 to the film's reference illuminant, xy -> spectral upsampling + * (hanatos2025 tc LUT) x film sensitivity = camera exposure + * -> highlight boost / diffusion / halation (linear, spatial) + * -> log exposure -> DIR coupler correction (blurred) = film development + * -> CMY film density -> grain (density, spatial) + * -> enlarger (dichroic-filtered light through the negative, + * print paper sensitivity, midgray-balanced) = print exposure + * -> print density curves (with optional contrast morph) + * -> viewing illuminant through the print, CMFs -> XYZ + * -> CAT02 -> work RGB -> OkLCh gamut compression = scanning + * + * The per-pixel colour science lives in spektra_sim.c (a validated port of + * spektrafilm 0.3.x, max deviation < 1e-4 vs the Python reference); the + * spatial effects (grain / halation / diffusion / highlight boost) live in + * spektra_core.h/.c, both shared with the OpenCL-side ports. + * + * Data: drop a data pack exported by tools/spektrafilm_export_data.py into + * /spektrafilm/ (pack.json + spectra_lut.f32) + * /spektrafilm/profiles/ (*.json film + paper profiles) + * Upgrading to a new spektrafilm release = re-running the exporter. + * + * This is a scene-to-display view transform: enable it INSTEAD of + * sigmoid / filmic / agx. + * + * Both CPU (process, OpenMP) and GPU (process_cl, data/kernels/spektrafilm.cl) + * paths exist. The GPU kernels were validated against the CPU engine with + * POCL to ~1e-6; exact-spectral quality stays CPU-only. + */ + +#include "bauhaus/bauhaus.h" +#include "common/darktable.h" +#include "common/file_location.h" +#include "control/control.h" +#include "develop/imageop.h" +#include "develop/tiling.h" +#include "develop/imageop_gui.h" +#include "develop/imageop_math.h" +#include "common/imagebuf.h" +#include "common/iop_profile.h" +#include "common/opencl.h" +#include "common/gaussian.h" +#include "gui/gtk.h" +#include "iop/iop_api.h" + +#include +#include +#include +#include +#include +#include + +#define SPEKTRA_INLINE static inline +#define SF_READ_FILE(path, out, len) \ + (g_file_get_contents((path), (out), (len), NULL) ? 0 : -1) +#define SF_FREE_FILE(buf) g_free(buf) +#define SF_DIAG_LOG(...) dt_print(DT_DEBUG_DEV, __VA_ARGS__) +#define SF_STRTOD(s, end) g_ascii_strtod((s), (end)) +#include "spektra_core.h" +#include "spektra_sim.h" + +DT_MODULE_INTROSPECTION(1, dt_iop_spektrafilm_params_t) + +/* Spatial-scale constants, micrometres on film unless noted (see the LUT + module for the full rationale; these are shared with modify_roi_in() and + tiling_callback() so the halo math stays in sync). */ +#define SF_HALATION_FIRST_SIGMA_UM 65.0f +#define SF_HALATION_PSF_SIGMAS 1.7320508f /* sqrt(3) */ +#define SF_GRAIN_BLUR_FACTOR 0.8f +#define SF_GRAIN_SIZE_MIN 0.05f +#define SF_HALO_SIGMAS 4.0f +#define SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM 950.0f +/* DIR coupler inhibitor diffusion; spektrafilm params_schema + dir_couplers.diffusion_size_um default (a plain gaussian in the reference) */ + +#define SF_MAX_PROFILES 128 +#define SF_NAME_LEN 128 +#define SF_PATH_LEN 1024 + +typedef enum dt_iop_spektrafilm_quality_t +{ + DT_SPEKTRAFILM_Q_DRAFT = 0, // $DESCRIPTION: "draft (17³ table)" + DT_SPEKTRAFILM_Q_STANDARD = 1, // $DESCRIPTION: "standard (33³ table)" + DT_SPEKTRAFILM_Q_HIGH = 2, // $DESCRIPTION: "high (49³ table)" + DT_SPEKTRAFILM_Q_EXACT = 3, // $DESCRIPTION: "exact spectral (very slow)" +} dt_iop_spektrafilm_quality_t; + +typedef struct dt_iop_spektrafilm_params_t +{ + uint32_t film_hash; // $DEFAULT: 0 (0 = first available filming stock) + uint32_t paper_hash; // $DEFAULT: 0 (0 = the film's target print stock) + float exposure_ev; // $MIN: -4.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "film exposure" + float print_exposure_ev; // $MIN: -3.0 $MAX: 3.0 $DEFAULT: 0.0 $DESCRIPTION: "print exposure" + gboolean print_auto_exposure; // $DEFAULT: TRUE $DESCRIPTION: "auto print exposure" + float print_contrast; // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print contrast" + float filter_m; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration M" + float filter_y; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration Y" + float couplers_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "DIR couplers" + gboolean scan_film; // $DEFAULT: FALSE $DESCRIPTION: "scan the film (skip print)" + dt_iop_spektrafilm_quality_t quality; // $DEFAULT: DT_SPEKTRAFILM_Q_STANDARD $DESCRIPTION: "quality" + gboolean halation_on; // $DEFAULT: TRUE $DESCRIPTION: "enable halation" + float halation_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "halation" + float halation_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "halation size" + float boost_ev; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 0.0 $DESCRIPTION: "highlight boost" + float boost_range; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.3 $DESCRIPTION: "boost range" + float protect_ev; // $MIN: 0.0 $MAX: 6.0 $DEFAULT: 4.0 $DESCRIPTION: "boost protect" + gboolean diffusion_on; // $DEFAULT: FALSE $DESCRIPTION: "enable diffusion filter" + float diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "diffusion strength" + float diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "diffusion size" + float diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "diffusion halo warmth" + gboolean grain_on; // $DEFAULT: TRUE $DESCRIPTION: "enable grain" + float grain_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "grain" + float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" + float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "film format" +} dt_iop_spektrafilm_params_t; + +/* one discovered profile: stock (= file base name), display name, stage */ +typedef struct sf_prof_entry_t +{ + char stock[SF_NAME_LEN]; + char name[SF_NAME_LEN]; + char target_print[SF_NAME_LEN]; + gboolean printing; /* stage == "printing" */ + gboolean positive; /* info.type == "positive" (slide / reversal) */ + uint32_t hash; +} sf_prof_entry_t; + +typedef struct dt_iop_spektrafilm_gui_data_t +{ + GtkWidget *film, *paper; + GtkWidget *exposure_ev, *print_exposure_ev, *print_auto_exposure, *print_contrast, *filter_m, *filter_y; + GtkWidget *couplers_amount, *scan_film, *quality; + GtkWidget *halation_on, *halation_amount, *halation_scale; + GtkWidget *boost_ev, *boost_range, *protect_ev; + GtkWidget *diffusion_on, *diffusion_strength, *diffusion_scale, *diffusion_warmth; + GtkWidget *grain_on, *grain_amount, *grain_size, *film_format_mm; + sf_prof_entry_t entries[SF_MAX_PROFILES]; + int n_entries; + int film_entry[SF_MAX_PROFILES], n_films; /* indices into entries[] */ + int paper_entry[SF_MAX_PROFILES], n_papers; +} dt_iop_spektrafilm_gui_data_t; + +/* per-piece data: parameter snapshot + a lazily (re)built simulation. + The sim depends on the pipe's work profile, which is only reliably known in + process(), so the build happens there guarded by a mutex. */ +typedef struct dt_iop_spektrafilm_data_t +{ + dt_iop_spektrafilm_params_t p; + /* engine cache */ + dt_pthread_mutex_t lock; + sf_sim_t *sim; + sf_sim_gpu_t *gpu; /* float tables for process_cl; NULL for exact quality */ + uint64_t sim_key; /* hash of everything the sim build depends on */ + char sim_error[256]; +} dt_iop_spektrafilm_data_t; + +typedef struct dt_iop_spektrafilm_global_data_t +{ + int kernel_expose, kernel_lograw, kernel_develop_corr, kernel_develop; + int kernel_grain_gen, kernel_grain_add; + int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough; + int kernel_scatter_combine, kernel_accum, kernel_halation_apply; + int kernel_max_partials, kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix; +} dt_iop_spektrafilm_global_data_t; + +/* the data pack is large (spectra LUT ~12 MB) and shared by all pieces; + load it once per process (lazily, under _pack_lock), freed in + cleanup_global(). Kept in module-static storage rather than global_data so + every pipe sees the same pack. */ +static sf_pack_t *_pack = NULL; +static char _pack_error[256] = { 0 }; +static dt_pthread_mutex_t _pack_lock; + +void init_global(dt_iop_module_so_t *self) +{ + dt_pthread_mutex_init(&_pack_lock, NULL); + const int program = 42; /* spektrafilm.cl in data/kernels/programs.conf */ + dt_iop_spektrafilm_global_data_t *gd = malloc(sizeof(dt_iop_spektrafilm_global_data_t)); + self->data = gd; + gd->kernel_expose = dt_opencl_create_kernel(program, "spektrafilm_expose"); + gd->kernel_lograw = dt_opencl_create_kernel(program, "spektrafilm_lograw"); + gd->kernel_develop_corr = dt_opencl_create_kernel(program, "spektrafilm_develop_corr"); + gd->kernel_develop = dt_opencl_create_kernel(program, "spektrafilm_develop"); + gd->kernel_grain_gen = dt_opencl_create_kernel(program, "spektrafilm_grain_gen"); + gd->kernel_grain_add = dt_opencl_create_kernel(program, "spektrafilm_grain_add"); + gd->kernel_print_expose = dt_opencl_create_kernel(program, "spektrafilm_print_expose"); + gd->kernel_print_develop = dt_opencl_create_kernel(program, "spektrafilm_print_develop"); + gd->kernel_scan = dt_opencl_create_kernel(program, "spektrafilm_scan"); + gd->kernel_passthrough = dt_opencl_create_kernel(program, "spektrafilm_passthrough"); + gd->kernel_scatter_combine = dt_opencl_create_kernel(program, "spektrafilm_scatter_combine"); + gd->kernel_accum = dt_opencl_create_kernel(program, "spektrafilm_accum"); + gd->kernel_halation_apply = dt_opencl_create_kernel(program, "spektrafilm_halation_apply"); + gd->kernel_max_partials = dt_opencl_create_kernel(program, "spektrafilm_max_partials"); + gd->kernel_boost = dt_opencl_create_kernel(program, "spektrafilm_boost"); + gd->kernel_diffusion_accum = dt_opencl_create_kernel(program, "spektrafilm_diffusion_accum"); + gd->kernel_diffusion_mix = dt_opencl_create_kernel(program, "spektrafilm_diffusion_mix"); +} + +void cleanup_global(dt_iop_module_so_t *self) +{ + dt_iop_spektrafilm_global_data_t *gd = (dt_iop_spektrafilm_global_data_t *)self->data; + if(gd) + { + dt_opencl_free_kernel(gd->kernel_expose); + dt_opencl_free_kernel(gd->kernel_lograw); + dt_opencl_free_kernel(gd->kernel_develop_corr); + dt_opencl_free_kernel(gd->kernel_develop); + dt_opencl_free_kernel(gd->kernel_grain_gen); + dt_opencl_free_kernel(gd->kernel_grain_add); + dt_opencl_free_kernel(gd->kernel_print_expose); + dt_opencl_free_kernel(gd->kernel_print_develop); + dt_opencl_free_kernel(gd->kernel_scan); + dt_opencl_free_kernel(gd->kernel_passthrough); + dt_opencl_free_kernel(gd->kernel_scatter_combine); + dt_opencl_free_kernel(gd->kernel_accum); + dt_opencl_free_kernel(gd->kernel_halation_apply); + dt_opencl_free_kernel(gd->kernel_max_partials); + dt_opencl_free_kernel(gd->kernel_boost); + dt_opencl_free_kernel(gd->kernel_diffusion_accum); + dt_opencl_free_kernel(gd->kernel_diffusion_mix); + free(self->data); + self->data = NULL; + } + dt_pthread_mutex_lock(&_pack_lock); + if(_pack) + { + sf_pack_free(_pack); + _pack = NULL; + } + _pack_error[0] = 0; + dt_pthread_mutex_unlock(&_pack_lock); + dt_pthread_mutex_destroy(&_pack_lock); +} + +const char *name(void) +{ + return _("Spektrafilm"); +} +const char *aliases(void) +{ + return _("film simulation|analog|spectral|grain|halation|print"); +} +const char **description(dt_iop_module_t *self) +{ + return dt_iop_set_description( + self, _("spectral film + print simulation (spektrafilm)"), _("creative"), + _("linear, RGB, scene-referred"), _("non-linear, RGB"), _("non-linear, RGB, display-referred")); +} +int default_group(void) +{ + return IOP_GROUP_COLOR | IOP_GROUP_GRADING; +} +int flags(void) +{ + return IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_INCLUDE_IN_STYLES; +} +dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, dt_dev_pixelpipe_t *p, + dt_dev_pixelpipe_iop_t *pi) +{ + return IOP_CS_RGB; +} + +/* ---------------------------------------------------------------------- */ +/* profile discovery */ +/* ---------------------------------------------------------------------- */ + +/* stable string hash for profile identity in params (same as the LUT module + used for bundles, so behaviour across machines/rescans is order-free) */ +static uint32_t sf_name_hash(const char *s) +{ + uint32_t h = 2166136261u; /* FNV-1a */ + for(const unsigned char *p = (const unsigned char *)s; *p; p++) + { + h ^= *p; + h *= 16777619u; + } + return h ? h : 1; /* 0 is reserved for "first available" */ +} + +static void sf_pack_dir(char *dst, size_t dstsz) +{ + char cfg[SF_PATH_LEN]; + dt_loc_get_user_config_dir(cfg, sizeof cfg); + snprintf(dst, dstsz, "%s/spektrafilm", cfg); +} + +/* scan /spektrafilm/profiles/ (all .json files); reads only the info header of + each profile (stock / name / stage / target_print) */ +static int sf_scan_profiles(sf_prof_entry_t *out, int maxn) +{ + char dir[SF_PATH_LEN]; + sf_pack_dir(dir, sizeof dir); + char profdir[SF_PATH_LEN + 16]; + snprintf(profdir, sizeof profdir, "%s/profiles", dir); + + GDir *gd = g_dir_open(profdir, 0, NULL); + if(!gd) return 0; + int n = 0; + const char *fn; + while(n < maxn && (fn = g_dir_read_name(gd))) + { + if(!g_str_has_suffix(fn, ".json")) continue; + char path[SF_PATH_LEN + 300]; + snprintf(path, sizeof path, "%s/%s", profdir, fn); + char *err = NULL; + sf_profile_t *prof = sf_profile_load(path, &err); + if(!prof) + { + free(err); + continue; + } + sf_prof_entry_t *e = &out[n]; + memset(e, 0, sizeof(*e)); + g_strlcpy(e->stock, sf_profile_stock(prof) ? sf_profile_stock(prof) : fn, SF_NAME_LEN); + /* strip .json when falling back to the file name */ + char *dot = strstr(e->stock, ".json"); + if(dot) *dot = 0; + g_strlcpy(e->name, sf_profile_name(prof) ? sf_profile_name(prof) : e->stock, SF_NAME_LEN); + const char *stage = sf_profile_stage(prof); + e->printing = (stage && !strcmp(stage, "printing")); + const char *tp = sf_profile_target_print(prof); + if(tp) g_strlcpy(e->target_print, tp, SF_NAME_LEN); + const char *type = sf_profile_type(prof); + e->positive = (type && !strcmp(type, "positive")); + e->hash = sf_name_hash(e->stock); + sf_profile_free(prof); + n++; + } + g_dir_close(gd); + /* stable alphabetical order by display name */ + for(int i = 0; i < n; i++) + for(int j = i + 1; j < n; j++) + if(g_ascii_strcasecmp(out[j].name, out[i].name) < 0) + { + sf_prof_entry_t t = out[i]; + out[i] = out[j]; + out[j] = t; + } + return n; +} + +/* resolve a profile hash to its stock name. hash 0 -> default: + for films the first filming stock, for papers prefer the film's + target_print. Returns false when nothing matches. */ +static gboolean sf_resolve_stock(const sf_prof_entry_t *entries, int n, uint32_t hash, + gboolean want_printing, const char *prefer_stock, + char *dst, size_t dstsz) +{ + if(hash) + for(int i = 0; i < n; i++) + if(entries[i].hash == hash && entries[i].printing == want_printing) + { + g_strlcpy(dst, entries[i].stock, dstsz); + return TRUE; + } + if(prefer_stock && prefer_stock[0]) + for(int i = 0; i < n; i++) + if(entries[i].printing == want_printing && !strcmp(entries[i].stock, prefer_stock)) + { + g_strlcpy(dst, entries[i].stock, dstsz); + return TRUE; + } + for(int i = 0; i < n; i++) + if(entries[i].printing == want_printing) + { + g_strlcpy(dst, entries[i].stock, dstsz); + return TRUE; + } + return FALSE; +} + +/* ---------------------------------------------------------------------- */ +/* pipeline plumbing */ +/* ---------------------------------------------------------------------- */ + +void init_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece) +{ + dt_iop_spektrafilm_data_t *d = calloc(1, sizeof(dt_iop_spektrafilm_data_t)); + dt_pthread_mutex_init(&d->lock, NULL); + piece->data = d; +} +void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece) +{ + dt_iop_spektrafilm_data_t *d = (dt_iop_spektrafilm_data_t *)piece->data; + if(d) + { + if(d->gpu) sf_sim_gpu_free(d->gpu); + if(d->sim) sf_sim_free(d->sim); + dt_pthread_mutex_destroy(&d->lock); + } + free(piece->data); + piece->data = NULL; +} + +void commit_params(dt_iop_module_t *self, dt_iop_params_t *p1, dt_dev_pixelpipe_t *pipe, + dt_dev_pixelpipe_iop_t *piece) +{ + dt_iop_spektrafilm_data_t *d = (dt_iop_spektrafilm_data_t *)piece->data; + d->p = *(dt_iop_spektrafilm_params_t *)p1; + /* the sim itself is (re)built lazily in process(), where the pipe's work + profile is reliably known; a stale sim is detected via sim_key there. */ + /* exact-spectral quality has no GPU kernels: stay on the CPU path */ + if(d->p.quality == DT_SPEKTRAFILM_Q_EXACT) piece->process_cl_ready = FALSE; +} + +static uint64_t _mix64(uint64_t h, const void *data, size_t len) +{ + const unsigned char *p = data; + for(size_t i = 0; i < len; i++) + { + h ^= p[i]; + h *= 0x100000001b3ULL; /* FNV-1a 64 */ + } + return h; +} + +static int _quality_steps(dt_iop_spektrafilm_quality_t q) +{ + switch(q) + { + case DT_SPEKTRAFILM_Q_DRAFT: return 17; + case DT_SPEKTRAFILM_Q_HIGH: return 49; + case DT_SPEKTRAFILM_Q_EXACT: return 0; /* exact spectral, no table */ + case DT_SPEKTRAFILM_Q_STANDARD: + default: return 33; + } +} + +/* make sure d->sim matches the current params + work profile; returns the sim + or NULL (passthrough). Called from process() under no assumption of being + single-threaded (full/preview pipes run concurrently). */ +static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, + const dt_iop_order_iccprofile_info_t *work_profile) +{ + const dt_iop_spektrafilm_params_t *p = &d->p; + + /* the work profile's RGB<->XYZ matrices feed the engine; include them in + the cache key so a work-profile change rebuilds the sim */ + float m_in[9], m_out[9]; + for(int i = 0; i < 3; i++) + for(int j = 0; j < 3; j++) + { + /* dt_colormatrix_t, row-major: XYZ_i = sum_j matrix_in[i][j] * RGB_j */ + m_in[i * 3 + j] = work_profile->matrix_in[i][j]; + m_out[i * 3 + j] = work_profile->matrix_out[i][j]; + } + + uint64_t key = 0xcbf29ce484222325ULL; + key = _mix64(key, &p->film_hash, sizeof p->film_hash); + key = _mix64(key, &p->paper_hash, sizeof p->paper_hash); + key = _mix64(key, &p->exposure_ev, sizeof p->exposure_ev); + key = _mix64(key, &p->print_exposure_ev, sizeof p->print_exposure_ev); + key = _mix64(key, &p->print_auto_exposure, sizeof p->print_auto_exposure); + key = _mix64(key, &p->print_contrast, sizeof p->print_contrast); + key = _mix64(key, &p->filter_m, sizeof p->filter_m); + key = _mix64(key, &p->filter_y, sizeof p->filter_y); + key = _mix64(key, &p->couplers_amount, sizeof p->couplers_amount); + key = _mix64(key, &p->scan_film, sizeof p->scan_film); + key = _mix64(key, &p->quality, sizeof p->quality); + key = _mix64(key, m_in, sizeof m_in); + key = _mix64(key, m_out, sizeof m_out); + + dt_pthread_mutex_lock(&d->lock); + if(d->sim && d->sim_key == key) + { + sf_sim_t *s = d->sim; + dt_pthread_mutex_unlock(&d->lock); + return s; + } + + /* (re)build */ + if(d->gpu) + { + sf_sim_gpu_free(d->gpu); + d->gpu = NULL; + } + if(d->sim) + { + sf_sim_free(d->sim); + d->sim = NULL; + } + d->sim_key = key; + d->sim_error[0] = 0; + + /* global pack, loaded once */ + dt_pthread_mutex_lock(&_pack_lock); + if(!_pack && !_pack_error[0]) + { + char dir[SF_PATH_LEN]; + sf_pack_dir(dir, sizeof dir); + char *err = NULL; + _pack = sf_pack_load(dir, &err); + if(!_pack) + { + g_strlcpy(_pack_error, err ? err : "unknown", sizeof _pack_error); + dt_print(DT_DEBUG_DEV, "[spektrafilm] %s\n", _pack_error); + free(err); + } + else + dt_print(DT_DEBUG_DEV, "[spektrafilm] loaded data pack %s (spektrafilm %s)\n", dir, + sf_pack_version(_pack)); + } + sf_pack_t *pack = _pack; + dt_pthread_mutex_unlock(&_pack_lock); + if(!pack) + { + dt_pthread_mutex_unlock(&d->lock); + return NULL; + } + + /* resolve stocks */ + sf_prof_entry_t entries[SF_MAX_PROFILES]; + const int n = sf_scan_profiles(entries, SF_MAX_PROFILES); + char film_stock[SF_NAME_LEN] = { 0 }, paper_stock[SF_NAME_LEN] = { 0 }; + if(!sf_resolve_stock(entries, n, p->film_hash, FALSE, "kodak_portra_400", film_stock, + sizeof film_stock)) + { + g_strlcpy(d->sim_error, "no filming profiles found", sizeof d->sim_error); + dt_pthread_mutex_unlock(&d->lock); + return NULL; + } + const char *target_print = NULL; + for(int i = 0; i < n; i++) + if(!entries[i].printing && !strcmp(entries[i].stock, film_stock)) + target_print = entries[i].target_print; + if(!p->scan_film + && !sf_resolve_stock(entries, n, p->paper_hash, TRUE, target_print, paper_stock, + sizeof paper_stock)) + { + g_strlcpy(d->sim_error, "no printing profiles found", sizeof d->sim_error); + dt_pthread_mutex_unlock(&d->lock); + return NULL; + } + + char dir[SF_PATH_LEN], path[SF_PATH_LEN + 300]; + sf_pack_dir(dir, sizeof dir); + char *err = NULL; + snprintf(path, sizeof path, "%s/profiles/%s.json", dir, film_stock); + sf_profile_t *film = sf_profile_load(path, &err); + sf_profile_t *paper = NULL; + if(film && !p->scan_film) + { + snprintf(path, sizeof path, "%s/profiles/%s.json", dir, paper_stock); + paper = sf_profile_load(path, &err); + } + + if(film && (paper || p->scan_film)) + { + sf_sim_params_t sp; + sf_sim_params_defaults(&sp); + sp.exposure_comp_ev = p->exposure_ev; + sp.print_exposure = powf(2.0f, p->print_exposure_ev); + sp.print_exposure_compensation = p->print_auto_exposure; /* normalize_print_exposure + stays at sf_sim_params_defaults' true — that combination + is what gives f_mid (a fixed reference midgray density) + when this toggle is off, i.e. film exposure then has its + full, uncompensated effect on brightness; see + sf_sim_build's midgray_factor branches */ + sp.m_filter_shift = p->filter_m; + sp.y_filter_shift = p->filter_y; + sp.couplers_active = (p->couplers_amount > 0.0f); + sp.couplers_amount = p->couplers_amount; + sp.scan_film = p->scan_film; + sp.lut_steps = _quality_steps(p->quality); + if(p->print_contrast != 1.0f) + { + sp.morph_active = true; + sp.morph_gamma = p->print_contrast; + } + /* darktable pipeline XYZ is D50-relative; the work profile matrices map + work RGB <-> that XYZ, so both engine whites are D50 */ + static const double d50_xy[2] = { 0.3457, 0.3585 }; + for(int i = 0; i < 9; i++) + { + sp.input_rgb_to_xyz[i] = m_in[i]; + sp.output_rgb_to_xyz[i] = m_in[i]; + sp.output_xyz_to_rgb[i] = m_out[i]; + } + sp.input_white_xy[0] = sp.output_white_xy[0] = d50_xy[0]; + sp.input_white_xy[1] = sp.output_white_xy[1] = d50_xy[1]; + + d->sim = sf_sim_build(pack, film, paper, &sp, &err); + if(!d->sim && err) + { + g_strlcpy(d->sim_error, err, sizeof d->sim_error); + dt_print(DT_DEBUG_DEV, "[spektrafilm] %s\n", err); + } + else if(d->sim) + { + /* float tables for the GPU path (NULL for exact-spectral quality, + which stays CPU-only) */ + d->gpu = sf_sim_gpu_export(d->sim); + dt_print(DT_DEBUG_DEV, "[spektrafilm] built sim: %s -> %s (steps %d, gpu %s)\n", + film_stock, p->scan_film ? "(scan film)" : paper_stock, sp.lut_steps, + d->gpu ? "yes" : "no"); + } + } + free(err); + if(film) sf_profile_free(film); + if(paper) sf_profile_free(paper); + + sf_sim_t *s = d->sim; + dt_pthread_mutex_unlock(&d->lock); + return s; +} + +/* ---------------------------------------------------------------------- */ +/* ROI / tiling: expand the input by the spatial-effect halo */ +/* ---------------------------------------------------------------------- */ + +static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_um) +{ + const float inv_um = 1.0f / fmaxf(pixel_um, 1e-3f); + const float hal = (p->halation_on && p->halation_amount > 0.0f) + ? SF_HALATION_FIRST_SIGMA_UM * SF_HALATION_PSF_SIGMAS * inv_um + : 0.0f; + const float diff = p->diffusion_on ? SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM * 1.41421356f + * p->diffusion_scale * inv_um + : 0.0f; + const float grain = (p->grain_on && p->grain_amount > 0.0f) + ? SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM + * fmaxf(p->grain_size, SF_GRAIN_SIZE_MIN) * inv_um + : 0.0f; + /* coupler halo: gaussian core plus the widest exponential-tail component; + the per-film tail size is unknown before the sim exists, so assume the + stock value all current profiles use (200 um) whenever couplers are on */ + const float coupler = (p->couplers_amount > 0.0f) + ? fmaxf((float)SF_COUPLER_BLUR_UM, + (float)(SF_EXPTAIL_R2 * 200.0)) * inv_um + : 0.0f; + return fmaxf(fmaxf(hal, diff), fmaxf(grain, coupler)); +} + +void modify_roi_in(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, + const dt_iop_roi_t *roi_out, dt_iop_roi_t *roi_in) +{ + *roi_in = *roi_out; + const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data; + if(!d) return; + const float full_w = fmaxf((float)piece->buf_in.width * roi_out->scale, 1.0f); + const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; + const int halo = (int)ceilf(SF_HALO_SIGMAS * _max_halo_sigma(&d->p, pixel_um)); + if(halo <= 0) return; + const int img_w = (int)roundf((float)piece->buf_in.width * roi_out->scale); + const int img_h = (int)roundf((float)piece->buf_in.height * roi_out->scale); + int x0 = roi_out->x - halo, y0 = roi_out->y - halo; + int x1 = roi_out->x + roi_out->width + halo, y1 = roi_out->y + roi_out->height + halo; + if(x0 < 0) x0 = 0; + if(y0 < 0) y0 = 0; + if(img_w > 0 && x1 > img_w) x1 = img_w; + if(img_h > 0 && y1 > img_h) y1 = img_h; + roi_in->x = x0; + roi_in->y = y0; + roi_in->width = x1 - x0; + roi_in->height = y1 - y0; +} + +void tiling_callback(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, + const dt_iop_roi_t *roi_in, const dt_iop_roi_t *roi_out, + dt_develop_tiling_t *tiling) +{ + const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data; + const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f); + const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; + tiling->factor = 4.5f; /* in + out + 3ch working plane + grain scratch */ + tiling->factor_cl = 4.5f; + tiling->maxbuf = 1.0f; + tiling->maxbuf_cl = 1.0f; + tiling->overhead = 0; + tiling->overlap = (unsigned)ceilf(SF_HALO_SIGMAS * _max_halo_sigma(&d->p, pixel_um)); + tiling->align = 1; +} + +/* ---------------------------------------------------------------------- */ +/* process */ +/* ---------------------------------------------------------------------- */ + +static void _passthrough(const float *in, float *out, int w, int oh, int ow, int ox, int oy) +{ + for(int y = 0; y < oh; y++) + for(int x = 0; x < ow; x++) + { + const float *s = in + ((size_t)(y + oy) * w + (x + ox)) * 4; + float *o = out + ((size_t)y * ow + x) * 4; + o[0] = s[0]; + o[1] = s[1]; + o[2] = s[2]; + o[3] = s[3]; + } +} + +void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *const ivoid, + void *const ovoid, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) +{ + dt_iop_spektrafilm_data_t *const d = (dt_iop_spektrafilm_data_t *)piece->data; + /* process the FULL input ROI (expanded by modify_roi_in), then crop roi_out */ + const int w = roi_in->width, h = roi_in->height; + const int ow = roi_out->width, oh = roi_out->height; + const int ox = roi_out->x - roi_in->x, oy = roi_out->y - roi_in->y; + const size_t npix = (size_t)w * h; + const float *const in = (const float *)ivoid; + float *const out = (float *)ovoid; + + const dt_iop_order_iccprofile_info_t *const work_profile + = dt_ioppr_get_pipe_work_profile_info(piece->pipe); + sf_sim_t *sim = work_profile ? _ensure_sim(d, work_profile) : NULL; + if(!sim) + { + _passthrough(in, out, w, oh, ow, ox, oy); + return; + } + + /* physical micrometres per pixel at this pipe resolution */ + const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f); + const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; + + float *plane = dt_alloc_align_float(npix * 3); /* raw / lograw / cmy, in place */ + float *corr = dt_alloc_align_float(npix * 3); /* DIR coupler correction field */ + float *scratch = dt_alloc_align_float(npix); /* 1ch blur scratch */ + if(!plane || !corr || !scratch) + { + if(plane) dt_free_align(plane); + if(corr) dt_free_align(corr); + if(scratch) dt_free_align(scratch); + _passthrough(in, out, w, oh, ow, ox, oy); + return; + } + + /* 1) camera exposure: work RGB -> spectral upsampling -> film raw exposure + (includes the film-exposure EV) */ + sf_sim_expose(sim, in, plane, npix, 4, 3); + + /* 2) pre-film spatial effects on LINEAR exposure, spektrafilm's order: + highlight boost -> diffusion filter -> halation */ + sf_boost_highlights(plane, w, h, d->p.boost_ev, d->p.boost_range, d->p.protect_ev); + if(d->p.diffusion_on) + sf_diffusion_filter(plane, w, h, (double)pixel_um, d->p.diffusion_strength, + d->p.diffusion_scale, d->p.diffusion_warmth); + if(d->p.halation_on && d->p.halation_amount > 0.0f) + sf_halation(plane, w, h, (double)pixel_um, d->p.halation_amount, d->p.halation_scale); + + /* 3) film development: log exposure, DIR coupler inhibition (the correction + field diffuses in the emulsion: gaussian, sigma 20 um as in the + reference), density curves */ + sf_sim_lograw(plane, npix, 3); + const int couplers = (d->p.couplers_amount > 0.0f); + if(couplers) + { + sf_sim_develop_corr(sim, plane, corr, npix, 3); + double cdiff_um, ctail_um, ctail_w; + sf_sim_coupler_diffusion(sim, &cdiff_um, &ctail_um, &ctail_w); + const float csigma = (float)cdiff_um / fmaxf(pixel_um, 1e-3f); + if(ctail_w > 0.0) + { + /* corr = (1-w)*gauss(corr) + w*exptail(corr); exptail is upstream's + 3-gaussian mixture surrogate (fast_exponential_filter, n=3) */ + const float amp[3] = { SF_EXPTAIL_A0, SF_EXPTAIL_A1, SF_EXPTAIL_A2 }; + const float rat[3] = { SF_EXPTAIL_R0, SF_EXPTAIL_R1, SF_EXPTAIL_R2 }; + const float tail_px = (float)ctail_um / fmaxf(pixel_um, 1e-3f); + float *mix = dt_alloc_align_float(npix * 3); + float *tmp = dt_alloc_align_float(npix * 3); + if(mix && tmp) + { + const float wbase = 1.0f - (float)ctail_w; + memcpy(tmp, corr, sizeof(float) * npix * 3); + if(csigma > 0.1f) sf_blur_plane3(tmp, w, h, csigma, scratch); + for(size_t i = 0; i < npix * 3; i++) mix[i] = wbase * tmp[i]; + for(int g3 = 0; g3 < 3; g3++) + { + memcpy(tmp, corr, sizeof(float) * npix * 3); + const float ts = rat[g3] * tail_px; + if(ts > 0.1f) sf_blur_plane3(tmp, w, h, ts, scratch); + const float wk = (float)ctail_w * amp[g3]; + for(size_t i = 0; i < npix * 3; i++) mix[i] += wk * tmp[i]; + } + memcpy(corr, mix, sizeof(float) * npix * 3); + } + else if(csigma > 0.1f) + sf_blur_plane3(corr, w, h, csigma, scratch); /* alloc failed: core only */ + dt_free_align(mix); + dt_free_align(tmp); + } + else if(csigma > 0.1f) + sf_blur_plane3(corr, w, h, csigma, scratch); + } + sf_sim_develop(sim, plane, couplers ? corr : NULL, plane, npix, 3, 3); + + /* 4) grain on the developed CMY film density (delta model + clump blur, + renormalised so strength is stable across clump sizes) */ + if(d->p.grain_on && d->p.grain_amount > 0.0f) + { + float *gbuf = corr; /* corr is free now — reuse as the grain delta buffer */ + const int roi_x = roi_in->x, roi_y = roi_in->y; + const float amount = d->p.grain_amount; + const int mono = sf_sim_film_bw(sim); /* B&W: achromatic grain */ + float gdmax[3], grms[3], gunif[3], gdmin[3]; + sf_sim_film_dmax3(sim, gdmax); /* the emulsion's own D-max: slide film + exceeds the colour-negative 2.2 default, + which would bias (tint) dense areas */ + sf_sim_film_grain3(sim, grms, gunif, gdmin); /* per-film catalogue grain + (rms-granularity, uniformity, density + floor) — Portra 400 no longer shares + Tri-X's grain signature */ +#ifdef _OPENMP +#pragma omp parallel for default(none) shared(plane, gbuf, gdmax, grms, gunif, gdmin) \ + firstprivate(w, npix, roi_x, roi_y, amount, mono) schedule(static) +#endif + for(size_t k = 0; k < npix; k++) + { + const int x = (int)(k % (size_t)w), y = (int)(k / (size_t)w); + sf_grain_delta_dmax(plane + k * 3, amount, gbuf + k * 3, (uint32_t)(x + roi_x), + (uint32_t)(y + roi_y), mono, gdmax, gdmin, grms, gunif); + } + const float sigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM + * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); + sf_blur_plane3(gbuf, w, h, sigma, scratch); + const float renorm = sqrtf(2.0f * sqrtf((float)M_PI) * fmaxf(sigma, 0.3f)); +#ifdef _OPENMP +#pragma omp parallel for default(none) shared(plane, gbuf) firstprivate(npix, renorm) \ + schedule(static) +#endif + for(size_t k = 0; k < npix * 3; k++) plane[k] += gbuf[k] * renorm; + } + + /* 5) print exposure + development (skipped in scan-film mode) */ + if(!d->p.scan_film) + { + sf_sim_print_expose(sim, plane, plane, npix, 3, 3); + sf_sim_print_develop(sim, plane, plane, npix, 3, 3); + } + + /* 6) scanning: viewing light through the print/film -> XYZ -> work RGB with + OkLCh gamut compression. Write RGBA + carried alpha, then crop. */ + sf_sim_scan(sim, plane, plane, npix, 3, 3); + +#ifdef _OPENMP +#pragma omp parallel for default(none) shared(plane) firstprivate(out, in, w, ow, oh, ox, oy) \ + schedule(static) +#endif + for(int y = 0; y < oh; y++) + for(int x = 0; x < ow; x++) + { + const size_t ks = (size_t)(y + oy) * w + (x + ox); + const float *pl = plane + ks * 3; + float *o = out + ((size_t)y * ow + x) * 4; + o[0] = pl[0]; + o[1] = pl[1]; + o[2] = pl[2]; + o[3] = in[ks * 4 + 3]; + } + + dt_free_align(plane); + dt_free_align(corr); + dt_free_align(scratch); +} + +#ifdef HAVE_OPENCL +/* GPU path: mirrors process(). Per-pixel stages run as kernels on the + validated float tables from sf_sim_gpu_export() (POCL-checked to ~1e-6 vs + the CPU engine); the Gaussian blurs (diffusion bank, halation bounces, + coupler correction diffusion, grain clumps) use dt_gaussian on the float4 + buffers, exactly as the CPU path uses sf_blur_plane3. */ +int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_in, + cl_mem dev_out, const dt_iop_roi_t *const roi_in, + const dt_iop_roi_t *const roi_out) +{ + dt_iop_spektrafilm_data_t *const d = (dt_iop_spektrafilm_data_t *)piece->data; + dt_iop_spektrafilm_global_data_t *gd = (dt_iop_spektrafilm_global_data_t *)self->global_data; + const int devid = piece->pipe->devid; + const int w = roi_in->width, h = roi_in->height; + const int ow = roi_out->width, oh = roi_out->height; + const int ox = roi_out->x - roi_in->x, oy = roi_out->y - roi_in->y; + const size_t npix = (size_t)w * h; + cl_int err = DT_OPENCL_DEFAULT_ERROR; +#define SF_CL_STEP(label) \ + do \ + { \ + if(err != CL_SUCCESS) \ + { \ + dt_print(DT_DEBUG_OPENCL, "[spektrafilm] GPU step FAILED: %s (err=%d)\n", (label), \ + (int)err); \ + goto cleanup; \ + } \ + } while(0) + + const dt_iop_order_iccprofile_info_t *const work_profile + = dt_ioppr_get_pipe_work_profile_info(piece->pipe); + sf_sim_t *sim = work_profile ? _ensure_sim(d, work_profile) : NULL; + const sf_sim_gpu_t *g = d->gpu; + + if(!sim) /* no data pack / profiles: crop passthrough */ + return dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_passthrough, ow, oh, + CLARG(dev_in), CLARG(dev_out), CLARG(ow), + CLARG(oh), CLARG(ox), CLARG(oy)); + if(!g) return DT_OPENCL_DEFAULT_ERROR; /* exact quality etc. -> CPU fallback */ + + const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f); + const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; + + /* ---- table uploads (read-only buffers) -------------------------------- */ + /* packed matrix block: layout must match the SF_M_* offsets in the .cl */ + float mats[93]; /* SF_M_* layout in spektrafilm.cl */ + memcpy(mats + 0, g->m_in, 9 * sizeof(float)); + memcpy(mats + 9, g->m_out, 9 * sizeof(float)); + memcpy(mats + 18, g->couplers_M, 9 * sizeof(float)); + memcpy(mats + 27, g->out_rgb2xyz, 9 * sizeof(float)); + memcpy(mats + 36, g->out_xyz2rgb, 9 * sizeof(float)); + memcpy(mats + 45, g->oklab_m1, 9 * sizeof(float)); + memcpy(mats + 54, g->oklab_m2, 9 * sizeof(float)); + memcpy(mats + 63, g->oklab_m1inv, 9 * sizeof(float)); + memcpy(mats + 72, g->oklab_m2inv, 9 * sizeof(float)); + memcpy(mats + 81, g->couplers_donor_K, 3 * sizeof(float)); + memcpy(mats + 84, g->couplers_donor_Dref, 3 * sizeof(float)); + memcpy(mats + 87, g->couplers_recv_Kr, 3 * sizeof(float)); + memcpy(mats + 90, g->couplers_recv_cref, 3 * sizeof(float)); + + const int steps = g->steps; + const size_t n3 = (size_t)steps * steps * steps * 3; + const size_t m3 = (size_t)(steps - 1) * (steps - 1) * (steps - 1) * 3; + const size_t f = sizeof(float); + cl_mem mats_cl = dt_opencl_copy_host_to_device_constant(devid, 93 * f, mats); + cl_mem tc_cl = dt_opencl_copy_host_to_device_constant( + devid, (size_t)g->tc_n * g->tc_n * 3 * f, g->tc_lut); + cl_mem cn_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f, g->curves_norm); + cl_mem cb_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f, + g->couplers_active ? g->curves_before + : g->curves_norm); + cl_mem el_cl = NULL, ex_cl = NULL, ey_cl = NULL, ez_cl = NULL, en_cl = NULL, em_cl = NULL; + cl_mem pc_cl = NULL; + if(g->has_print) + { + el_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_lut); + ex_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sx); + ey_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sy); + ez_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sz); + en_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->enl_cmin); + em_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->enl_cmax); + pc_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f, g->print_curves); + } + cl_mem sl_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_lut); + cl_mem sx_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sx); + cl_mem sy_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sy); + cl_mem sz_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sz); + cl_mem sn_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->scan_cmin); + cl_mem sm_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->scan_cmax); + /* cmax_table is only used in oklch mode but the kernel arg must be valid */ + cl_mem cm_cl = dt_opencl_copy_host_to_device_constant( + devid, (g->cmax_table ? (size_t)g->cmax_nl * g->cmax_nh : 1) * f, + g->cmax_table ? (void *)g->cmax_table : (void *)mats); + + cl_mem plane = dt_opencl_alloc_device_buffer(devid, npix * f * 4); + cl_mem plane2 = dt_opencl_alloc_device_buffer(devid, npix * f * 4); + cl_mem tmpa = dt_opencl_alloc_device_buffer(devid, npix * f * 4); + cl_mem tmpb = dt_opencl_alloc_device_buffer(devid, npix * f * 4); + cl_mem acc = dt_opencl_alloc_device_buffer(devid, npix * f * 4); + if(!mats_cl || !tc_cl || !cn_cl || !cb_cl || !sl_cl || !sx_cl || !sy_cl || !sz_cl || !sn_cl + || !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !tmpb || !acc + || (g->has_print && (!el_cl || !ex_cl || !ey_cl || !ez_cl || !en_cl || !em_cl || !pc_cl))) + { + err = CL_MEM_OBJECT_ALLOCATION_FAILURE; + goto cleanup; + } + + /* ---- 1) expose: input image -> linear film raw exposure ---------------- */ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_expose, w, h, CLARG(dev_in), + CLARG(plane), CLARG(w), CLARG(h), CLARG(mats_cl), + CLARG(tc_cl), CLARG(g->tc_n), CLARG(g->ev_scale)); + SF_CL_STEP("expose"); + + /* ---- 2) pre-film spatial effects on linear exposure -------------------- */ + if(d->p.boost_ev > 0.0f) + { + const int npartials = 256; + cl_mem partials = dt_opencl_alloc_device_buffer(devid, npartials * sizeof(float)); + if(partials) + { + const int npix_i = (int)npix; + err = dt_opencl_enqueue_kernel_1d_args(devid, gd->kernel_max_partials, npartials, + CLARG(plane), CLARG(npix_i), CLARG(partials), + CLARG(npartials)); + if(err == CL_SUCCESS) + { + float hp[256]; + if(dt_opencl_read_buffer_from_device(devid, hp, partials, 0, npartials * sizeof(float), + CL_TRUE) + == CL_SUCCESS) + { + float maxv = 0.0f; + for(int i = 0; i < npartials; i++) maxv = fmaxf(maxv, hp[i]); + const float b_ev = d->p.boost_ev, b_rng = d->p.boost_range, b_prot = d->p.protect_ev; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_boost, w, h, CLARG(plane), + CLARG(w), CLARG(h), CLARG(b_ev), CLARG(b_rng), + CLARG(b_prot), CLARG(maxv)); + } + } + dt_opencl_release_mem_object(partials); + SF_CL_STEP("boost"); + } + } + + if(d->p.diffusion_on) + { + sf_diffusion_plan_t plan; + if(sf_diffusion_build_plan(d->p.diffusion_strength, d->p.diffusion_warmth, &plan) + && plan.p_s > 0.0f) + { + const float dsc = fmaxf(d->p.diffusion_scale, 1e-6f); + for(int j = 0; j < plan.n; j++) + { + const float sigma = fmaxf(plan.sigma_um[j] * dsc / pixel_um, 1e-3f); + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4); + if(err != CL_SUCCESS) break; + err = dt_gaussian_fast_blur_cl_buffer(devid, tmpa, tmpa, w, h, sigma, 4, -1e9f, 1e9f); + if(err != CL_SUCCESS) break; + const int reset = (j == 0); + const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j]; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h, + CLARG(tmpa), CLARG(acc), CLARG(w), CLARG(h), + CLARG(wr), CLARG(wg), CLARG(wb), CLARG(reset)); + if(err != CL_SUCCESS) break; + } + if(err == CL_SUCCESS) + { + const float ps = plan.p_s; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_mix, w, h, + CLARG(plane), CLARG(acc), CLARG(w), CLARG(h), + CLARG(ps)); + } + SF_CL_STEP("diffusion"); + } + } + + if(d->p.halation_on && d->p.halation_amount > 0.0f) + { + const float hscl = fmaxf(d->p.halation_scale, 1e-3f); + const float core_um = (2.2f + 2.0f + 1.6f) / 3.0f, tail_um = (9.3f + 9.7f + 9.1f) / 3.0f; + const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f }; + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4); + SF_CL_STEP("halation core copy"); + err = dt_gaussian_fast_blur_cl_buffer(devid, tmpa, tmpa, w, h, + fmaxf(core_um * hscl / pixel_um, 1e-3f), 4, -1e9f, 1e9f); + SF_CL_STEP("halation core blur"); + for(int g3 = 0; g3 < 3; g3++) + { + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpb, 0, 0, npix * f * 4); + SF_CL_STEP("halation tail copy"); + err = dt_gaussian_fast_blur_cl_buffer(devid, tmpb, tmpb, w, h, + fmaxf(rat[g3] * tail_um * hscl / pixel_um, 1e-3f), 4, + -1e9f, 1e9f); + SF_CL_STEP("halation tail blur"); + const int reset = (g3 == 0); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(tmpb), + CLARG(acc), CLARG(w), CLARG(h), CLARG(amp[g3]), + CLARG(reset)); + SF_CL_STEP("halation tail accum"); + } + const float ws_r = 0.78f, ws_g = 0.65f, ws_b = 0.67f; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scatter_combine, w, h, CLARG(tmpa), + CLARG(acc), CLARG(plane), CLARG(w), CLARG(h), + CLARG(ws_r), CLARG(ws_g), CLARG(ws_b)); + SF_CL_STEP("scatter combine"); + + const float first_sigma = SF_HALATION_FIRST_SIGMA_UM; + const int N = 3; + const float rho = 0.5f; + float dsum = 0.f, dec[3]; + for(int k = 1; k <= N; k++) + { + dec[k - 1] = powf(rho, (float)(k - 1)); + dsum += dec[k - 1]; + } + for(int k = 0; k < N; k++) dec[k] /= dsum; + for(int k = 1; k <= N; k++) + { + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpb, 0, 0, npix * f * 4); + SF_CL_STEP("halation bounce copy"); + err = dt_gaussian_fast_blur_cl_buffer( + devid, tmpb, tmpb, w, h, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f), + 4, -1e9f, 1e9f); + SF_CL_STEP("halation bounce blur"); + const int reset = (k == 1); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(tmpb), + CLARG(acc), CLARG(w), CLARG(h), CLARG(dec[k - 1]), + CLARG(reset)); + SF_CL_STEP("halation bounce accum"); + } + const float h_eff = powf(d->p.halation_amount, 1.3f); + const float a_r = 0.05f * h_eff, a_g = 0.015f * h_eff, a_b = 0.0f; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_halation_apply, w, h, CLARG(plane), + CLARG(acc), CLARG(w), CLARG(h), CLARG(a_r), + CLARG(a_g), CLARG(a_b)); + SF_CL_STEP("halation apply"); + } + + /* ---- 3) film development ------------------------------------------------ */ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_lograw, w, h, CLARG(plane), CLARG(w), + CLARG(h)); + SF_CL_STEP("lograw"); + + const int use_corr = g->couplers_active; + if(use_corr) + { + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_develop_corr, w, h, CLARG(plane), CLARG(acc), CLARG(w), CLARG(h), + CLARG(cn_cl), CLARG(mats_cl), CLARG(g->gamma[0]), CLARG(g->gamma[1]), CLARG(g->gamma[2]), + CLARG(g->le0), CLARG(g->le_step), CLARG(g->film_dmax[0]), CLARG(g->film_dmax[1]), + CLARG(g->film_dmax[2]), CLARG(g->film_positive)); + SF_CL_STEP("develop_corr"); + /* DIR coupler inhibitor diffusion, gaussian sigma 20 um (reference value) */ + const float csigma = g->coupler_diff_um / fmaxf(pixel_um, 1e-3f); + if(g->coupler_tail_w > 0.0f) + { + /* corr = (1-w)*gauss + w*3-gaussian exponential-tail surrogate; + accumulate the weighted passes into tmpa, then copy back to acc */ + const float amp[4] = { 1.0f - g->coupler_tail_w, g->coupler_tail_w * SF_EXPTAIL_A0, + g->coupler_tail_w * SF_EXPTAIL_A1, g->coupler_tail_w * SF_EXPTAIL_A2 }; + const float sig[4] = { csigma, SF_EXPTAIL_R0 * g->coupler_tail_um / fmaxf(pixel_um, 1e-3f), + SF_EXPTAIL_R1 * g->coupler_tail_um / fmaxf(pixel_um, 1e-3f), + SF_EXPTAIL_R2 * g->coupler_tail_um / fmaxf(pixel_um, 1e-3f) }; + for(int g3 = 0; g3 < 4; g3++) + { + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, acc, tmpb, 0, 0, npix * f * 4); + SF_CL_STEP("coupler tail copy"); + if(sig[g3] > 0.1f) + { + err = dt_gaussian_fast_blur_cl_buffer(devid, tmpb, tmpb, w, h, sig[g3], 4, -1e9f, 1e9f); + SF_CL_STEP("coupler tail blur"); + } + const int reset = (g3 == 0); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h, + CLARG(tmpb), CLARG(tmpa), CLARG(w), CLARG(h), + CLARG(amp[g3]), CLARG(amp[g3]), CLARG(amp[g3]), + CLARG(reset)); + SF_CL_STEP("coupler tail accum"); + } + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, tmpa, acc, 0, 0, npix * f * 4); + SF_CL_STEP("coupler tail writeback"); + } + else if(csigma > 0.1f) + { + err = dt_gaussian_fast_blur_cl_buffer(devid, acc, acc, w, h, csigma, 4, -1e9f, 1e9f); + SF_CL_STEP("coupler blur"); + } + } + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_develop, w, h, CLARG(plane), + CLARG(acc), CLARG(use_corr), CLARG(plane2), CLARG(w), + CLARG(h), CLARG(cb_cl), CLARG(mats_cl), CLARG(g->gamma[0]), + CLARG(g->gamma[1]), CLARG(g->gamma[2]), CLARG(g->le0), + CLARG(g->le_step)); + SF_CL_STEP("develop"); + + /* ---- 4) grain on the developed CMY density ----------------------------- */ + if(d->p.grain_on && d->p.grain_amount > 0.0f) + { + const int roi_x = roi_in->x, roi_y = roi_in->y; + const float amount = d->p.grain_amount; + const int mono = g->film_bw; /* B&W: achromatic grain */ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_gen, w, h, CLARG(plane2), + CLARG(tmpa), CLARG(w), CLARG(h), CLARG(amount), + CLARG(roi_x), CLARG(roi_y), CLARG(mono), + CLARG(g->film_dmax[0]), CLARG(g->film_dmax[1]), + CLARG(g->film_dmax[2]), CLARG(g->grain_dmin[0]), + CLARG(g->grain_dmin[1]), CLARG(g->grain_dmin[2]), + CLARG(g->grain_rms[0]), CLARG(g->grain_rms[1]), + CLARG(g->grain_rms[2]), CLARG(g->grain_uniformity[0]), + CLARG(g->grain_uniformity[1]), + CLARG(g->grain_uniformity[2])); + SF_CL_STEP("grain gen"); + const float gsigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM + * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); + err = dt_gaussian_fast_blur_cl_buffer(devid, tmpa, tmpa, w, h, gsigma, 4, -1000.0f, 1000.0f); + SF_CL_STEP("grain blur"); + const float grenorm = sqrtf(2.0f * sqrtf((float)M_PI) * fmaxf(gsigma, 0.3f)); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2), + CLARG(tmpa), CLARG(w), CLARG(h), CLARG(grenorm)); + SF_CL_STEP("grain add"); + } + + /* ---- 5) print ----------------------------------------------------------- */ + if(g->has_print) + { + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_print_expose, w, h, CLARG(plane2), CLARG(plane), CLARG(w), CLARG(h), + CLARG(el_cl), CLARG(ex_cl), CLARG(ey_cl), CLARG(ez_cl), CLARG(en_cl), CLARG(em_cl), + CLARG(steps), CLARG(g->enl_lo[0]), CLARG(g->enl_lo[1]), CLARG(g->enl_lo[2]), + CLARG(g->enl_hi[0]), CLARG(g->enl_hi[1]), CLARG(g->enl_hi[2]), CLARG(g->print_exposure)); + SF_CL_STEP("print_expose"); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_print_develop, w, h, CLARG(plane), + CLARG(plane2), CLARG(w), CLARG(h), CLARG(pc_cl), + CLARG(g->le0), CLARG(g->le_step)); + SF_CL_STEP("print_develop"); + } + + /* ---- 6) scan: crop the roi_out window straight into dev_out ------------- */ + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_scan, ow, oh, CLARG(plane2), CLARG(dev_in), CLARG(dev_out), CLARG(w), + CLARG(ow), CLARG(oh), CLARG(ox), CLARG(oy), CLARG(sl_cl), CLARG(sx_cl), CLARG(sy_cl), + CLARG(sz_cl), CLARG(sn_cl), CLARG(sm_cl), CLARG(steps), CLARG(g->scan_lo[0]), + CLARG(g->scan_lo[1]), CLARG(g->scan_lo[2]), CLARG(g->scan_hi[0]), CLARG(g->scan_hi[1]), + CLARG(g->scan_hi[2]), CLARG(mats_cl), CLARG(cm_cl), CLARG(g->cmax_nl), CLARG(g->cmax_nh), + CLARG(g->out_compress), CLARG(g->scan_bw_on), CLARG(g->scan_bw_m), + CLARG(g->scan_bw_q)); + SF_CL_STEP("scan"); + +cleanup: + dt_opencl_release_mem_object(mats_cl); + dt_opencl_release_mem_object(tc_cl); + dt_opencl_release_mem_object(cn_cl); + dt_opencl_release_mem_object(cb_cl); + dt_opencl_release_mem_object(el_cl); + dt_opencl_release_mem_object(ex_cl); + dt_opencl_release_mem_object(ey_cl); + dt_opencl_release_mem_object(ez_cl); + dt_opencl_release_mem_object(en_cl); + dt_opencl_release_mem_object(em_cl); + dt_opencl_release_mem_object(pc_cl); + dt_opencl_release_mem_object(sl_cl); + dt_opencl_release_mem_object(sx_cl); + dt_opencl_release_mem_object(sy_cl); + dt_opencl_release_mem_object(sz_cl); + dt_opencl_release_mem_object(sn_cl); + dt_opencl_release_mem_object(sm_cl); + dt_opencl_release_mem_object(cm_cl); + dt_opencl_release_mem_object(plane); + dt_opencl_release_mem_object(plane2); + dt_opencl_release_mem_object(tmpa); + dt_opencl_release_mem_object(tmpb); + dt_opencl_release_mem_object(acc); + return err; +} +#endif /* HAVE_OPENCL */ + +/* ---------------------------------------------------------------------- */ +/* GUI */ +/* ---------------------------------------------------------------------- */ + +static void _rescan(dt_iop_module_t *self) +{ + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + g->n_entries = sf_scan_profiles(g->entries, SF_MAX_PROFILES); + g->n_films = g->n_papers = 0; + for(int i = 0; i < g->n_entries; i++) + { + if(g->entries[i].printing) + g->paper_entry[g->n_papers++] = i; + else + g->film_entry[g->n_films++] = i; + } +} + +static void _update_print_sensitivity(dt_iop_module_t *self); + +static void _film_changed(GtkWidget *w, dt_iop_module_t *self) +{ + if(darktable.gui->reset) return; + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; + const int fi = dt_bauhaus_combobox_get(g->film); + if(fi < 0 || fi >= g->n_films) return; + const sf_prof_entry_t *e = &g->entries[g->film_entry[fi]]; + p->film_hash = e->hash; + /* scan-film follows the film's natural mode on a film switch: slides and + reversal stocks are viewed directly (scan), negatives go through the + print stage. The user can still toggle freely afterwards -- this only + re-baselines when the film itself changes, like the paper auto-follow. */ + if(p->scan_film != e->positive) + { + p->scan_film = e->positive; + ++darktable.gui->reset; + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->scan_film), p->scan_film); + --darktable.gui->reset; + _update_print_sensitivity(self); + } + /* if the paper is still on "auto" (hash 0) keep it following the film's + target print; otherwise leave the explicit user choice alone */ + if(p->paper_hash == 0 && e->target_print[0]) + for(int k = 0; k < g->n_papers; k++) + if(!strcmp(g->entries[g->paper_entry[k]].stock, e->target_print)) + { + ++darktable.gui->reset; + dt_bauhaus_combobox_set(g->paper, k); + --darktable.gui->reset; + break; + } + dt_dev_add_history_item(darktable.develop, self, TRUE); +} + +static void _paper_changed(GtkWidget *w, dt_iop_module_t *self) +{ + if(darktable.gui->reset) return; + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; + const int pi = dt_bauhaus_combobox_get(g->paper); + if(pi < 0 || pi >= g->n_papers) return; + p->paper_hash = g->entries[g->paper_entry[pi]].hash; + dt_dev_add_history_item(darktable.develop, self, TRUE); +} + +static void _update_print_sensitivity(dt_iop_module_t *self) +{ + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; + const gboolean printing = !p->scan_film; + gtk_widget_set_sensitive(g->paper, printing); + gtk_widget_set_sensitive(g->print_exposure_ev, printing); + gtk_widget_set_sensitive(g->print_auto_exposure, printing); + gtk_widget_set_sensitive(g->print_contrast, printing); + gtk_widget_set_sensitive(g->filter_m, printing); + gtk_widget_set_sensitive(g->filter_y, printing); +} + +/* called by the core whenever a params-linked widget changed */ +void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous) +{ + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + if(!w || w == g->scan_film) _update_print_sensitivity(self); +} + +void gui_update(dt_iop_module_t *self) +{ + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; + + _rescan(self); + dt_bauhaus_combobox_clear(g->film); + if(g->n_films == 0) + dt_bauhaus_combobox_add(g->film, _("(no profiles found)")); + else + for(int f = 0; f < g->n_films; f++) + dt_bauhaus_combobox_add(g->film, g->entries[g->film_entry[f]].name); + dt_bauhaus_combobox_clear(g->paper); + if(g->n_papers == 0) + dt_bauhaus_combobox_add(g->paper, _("(none)")); + else + for(int k = 0; k < g->n_papers; k++) + dt_bauhaus_combobox_add(g->paper, g->entries[g->paper_entry[k]].name); + + int fi = 0; + for(int f = 0; f < g->n_films; f++) + if(g->entries[g->film_entry[f]].hash == p->film_hash) fi = f; + dt_bauhaus_combobox_set(g->film, fi); + int pi = 0; + const char *target = (fi < g->n_films) ? g->entries[g->film_entry[fi]].target_print : NULL; + for(int k = 0; k < g->n_papers; k++) + { + const sf_prof_entry_t *e = &g->entries[g->paper_entry[k]]; + if(p->paper_hash ? (e->hash == p->paper_hash) + : (target && !strcmp(e->stock, target))) + pi = k; + } + dt_bauhaus_combobox_set(g->paper, pi); + + /* toggle_from_params check buttons are NOT auto-synced by + dt_bauhaus_update_from_field (it only handles sliders/combos), so set + them here or they drift from the params: a stale box makes the first + click a no-op (field already has that value -> no history item) and + module reset never updates them. */ + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->scan_film), p->scan_film); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_auto_exposure), p->print_auto_exposure); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->halation_on), p->halation_on); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->diffusion_on), p->diffusion_on); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->grain_on), p->grain_on); + _update_print_sensitivity(self); +} + +void gui_init(dt_iop_module_t *self) +{ + dt_iop_spektrafilm_gui_data_t *g = IOP_GUI_ALLOC(spektrafilm); + self->widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + g->film = dt_bauhaus_combobox_new(self); + dt_bauhaus_widget_set_label(g->film, NULL, N_("film stock")); + gtk_widget_set_tooltip_text(g->film, _("film emulsion (spektrafilm filming profile)")); + g_signal_connect(G_OBJECT(g->film), "value-changed", G_CALLBACK(_film_changed), self); + gtk_box_pack_start(GTK_BOX(self->widget), g->film, TRUE, TRUE, 0); + + g->paper = dt_bauhaus_combobox_new(self); + dt_bauhaus_widget_set_label(g->paper, NULL, N_("print paper")); + gtk_widget_set_tooltip_text(g->paper, + _("print/paper stock; defaults to the film's target print")); + g_signal_connect(G_OBJECT(g->paper), "value-changed", G_CALLBACK(_paper_changed), self); + gtk_box_pack_start(GTK_BOX(self->widget), g->paper, TRUE, TRUE, 0); + + g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film"); + gtk_widget_set_tooltip_text(g->scan_film, + _("view the developed film directly (no print stage)")); + + g->exposure_ev = dt_bauhaus_slider_from_params(self, "exposure_ev"); + dt_bauhaus_slider_set_format(g->exposure_ev, _(" EV")); + gtk_widget_set_tooltip_text( + g->exposure_ev, _("film exposure compensation; with auto print exposure enabled, print" + " exposure follows automatically so this has no net brightness effect" + " (except on positive/reversal film, which has no print stage)")); + g->print_exposure_ev = dt_bauhaus_slider_from_params(self, "print_exposure_ev"); + dt_bauhaus_slider_set_format(g->print_exposure_ev, _(" EV")); + gtk_widget_set_tooltip_text(g->print_exposure_ev, _("print brightness (enlarger exposure)")); + g->print_auto_exposure = dt_bauhaus_toggle_from_params(self, "print_auto_exposure"); + gtk_widget_set_tooltip_text( + g->print_auto_exposure, + _("automatically compensate print exposure for film exposure changes, as a real" + " printer would print to a fixed density; disable for film exposure to affect" + " brightness directly, same as a fixed enlarger exposure time")); + g->print_contrast = dt_bauhaus_slider_from_params(self, "print_contrast"); + gtk_widget_set_tooltip_text(g->print_contrast, + _("print contrast (morphs the paper's density curves)")); + g->filter_m = dt_bauhaus_slider_from_params(self, "filter_m"); + dt_bauhaus_slider_set_format(g->filter_m, _(" CC")); + gtk_widget_set_tooltip_text(g->filter_m, + _("magenta enlarger filtration, Kodak CC units from neutral")); + g->filter_y = dt_bauhaus_slider_from_params(self, "filter_y"); + dt_bauhaus_slider_set_format(g->filter_y, _(" CC")); + gtk_widget_set_tooltip_text(g->filter_y, + _("yellow enlarger filtration, Kodak CC units from neutral")); + g->couplers_amount = dt_bauhaus_slider_from_params(self, "couplers_amount"); + gtk_widget_set_tooltip_text(g->couplers_amount, + _("DIR coupler strength: inter-layer inhibition drives saturation" + " and edge effects (1.0 = film-accurate, 0 = off)")); + g->quality = dt_bauhaus_combobox_from_params(self, "quality"); + gtk_widget_set_tooltip_text(g->quality, + _("spectral accuracy vs speed; the tables are PCHIP-interpolated" + " and validated against the reference")); + + g->grain_on = dt_bauhaus_toggle_from_params(self, "grain_on"); + g->grain_amount = dt_bauhaus_slider_from_params(self, "grain_amount"); + g->grain_size = dt_bauhaus_slider_from_params(self, "grain_size"); + gtk_widget_set_tooltip_text(g->grain_size, + _("grain particle size (1.0 = film default; higher = coarser)")); + + g->halation_on = dt_bauhaus_toggle_from_params(self, "halation_on"); + g->halation_amount = dt_bauhaus_slider_from_params(self, "halation_amount"); + dt_bauhaus_slider_set_soft_range(g->halation_amount, 0.0f, 2.0f); + gtk_widget_set_tooltip_text(g->halation_amount, + _("halation strength (1.0 = film-accurate; drag up to 2," + " right-click to enter higher values)")); + g->halation_scale = dt_bauhaus_slider_from_params(self, "halation_scale"); + gtk_widget_set_tooltip_text(g->halation_scale, + _("halation size: scales the glow radius (1.0 = film-accurate)")); + g->boost_ev = dt_bauhaus_slider_from_params(self, "boost_ev"); + dt_bauhaus_slider_set_format(g->boost_ev, _(" EV")); + gtk_widget_set_tooltip_text(g->boost_ev, + _("highlight boost: reconstructs clipped highlights so they bloom" + " into halation/diffusion (0 = off)")); + g->boost_range = dt_bauhaus_slider_from_params(self, "boost_range"); + gtk_widget_set_tooltip_text(g->boost_range, _("range of the highlight boost curve")); + g->protect_ev = dt_bauhaus_slider_from_params(self, "protect_ev"); + dt_bauhaus_slider_set_format(g->protect_ev, _(" EV")); + gtk_widget_set_tooltip_text(g->protect_ev, + _("protect tones below this many stops over mid-grey from the boost")); + g->diffusion_on = dt_bauhaus_toggle_from_params(self, "diffusion_on"); + g->diffusion_strength = dt_bauhaus_slider_from_params(self, "diffusion_strength"); + gtk_widget_set_tooltip_text(g->diffusion_strength, + _("diffusion (Black Pro-Mist) filter strength")); + g->diffusion_scale = dt_bauhaus_slider_from_params(self, "diffusion_scale"); + gtk_widget_set_tooltip_text(g->diffusion_scale, _("diffusion halo/bloom size")); + g->diffusion_warmth = dt_bauhaus_slider_from_params(self, "diffusion_warmth"); + gtk_widget_set_tooltip_text(g->diffusion_warmth, + _("diffusion halo warmth: >0 warm outer halo, <0 cool")); + g->film_format_mm = dt_bauhaus_slider_from_params(self, "film_format_mm"); + dt_bauhaus_slider_set_format(g->film_format_mm, _(" mm")); + gtk_widget_set_tooltip_text(g->film_format_mm, + _("physical film width; sets the scale of grain and halation")); +} + +// clang-format off +// modelines +// vim: shiftwidth=2 expandtab tabstop=2 cindent +// clang-format on From 6ac31941e0a12be5d337661ccc8063707aba3042 Mon Sep 17 00:00:00 2001 From: Benjamin Grimm-Lebsanft Date: Sun, 12 Jul 2026 10:04:31 +0200 Subject: [PATCH 02/56] Remove superflous mentions of the old module --- src/iop/spektrafilm.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 02f710185ee9..0cb9559ddea4 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -21,8 +21,7 @@ * Film modeling powered by spektrafilm (https://github.com/andreavolpato/spektrafilm), * GPLv3, © Andrea Volpato. Film/paper profile data CC BY-SA 4.0. * - * Unlike the earlier LUT-bundle module, this module computes the full - * spektrafilm colour pipeline natively per pixel: + * This module computes the full spektrafilm colour pipeline natively per pixel: * * scene-linear work RGB * -> CAT16 to the film's reference illuminant, xy -> spectral upsampling From 6cd405b5d4c3efa31073ea9267164701d3fa96cc Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 12 Jul 2026 17:27:12 +0200 Subject: [PATCH 03/56] fix film selection dropdown default --- src/iop/spektrafilm.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 0cb9559ddea4..ebecabee1f12 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -1361,8 +1361,20 @@ void gui_update(dt_iop_module_t *self) dt_bauhaus_combobox_add(g->paper, g->entries[g->paper_entry[k]].name); int fi = 0; + gboolean film_matched = FALSE; for(int f = 0; f < g->n_films; f++) - if(g->entries[g->film_entry[f]].hash == p->film_hash) fi = f; + if(g->entries[g->film_entry[f]].hash == p->film_hash) { fi = f; film_matched = TRUE; } + if(!film_matched) + { + /* no hash match (fresh param with film_hash==0, or the saved stock + vanished from the pack) -- mirror sf_resolve_stock's fallback so the + combobox agrees with what the pixel pipeline actually renders, instead + of silently landing on whatever sorts first (e.g. "Fujifilm C200" + alphabetically before "Kodak Portra 400") while the pipe renders the + real default. */ + for(int f = 0; f < g->n_films; f++) + if(!strcmp(g->entries[g->film_entry[f]].stock, "kodak_portra_400")) fi = f; + } dt_bauhaus_combobox_set(g->film, fi); int pi = 0; const char *target = (fi < g->n_films) ? g->entries[g->film_entry[fi]].target_print : NULL; From bcf5a0ea07dbc039d73b93e2108241de90cd0b5b Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 12 Jul 2026 21:45:49 +0200 Subject: [PATCH 04/56] fix for kodak_2302 negative bw paper --- src/iop/spektra_sim.c | 46 +++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/iop/spektra_sim.c b/src/iop/spektra_sim.c index 50d80b545ac2..9bf7d8aed390 100644 --- a/src/iop/spektra_sim.c +++ b/src/iop/spektra_sim.c @@ -682,29 +682,45 @@ sf_profile_t *sf_profile_load(const char *path, char **errmsg) JsonNode *cnode = (m && json_object_has_member(m, "centers")) ? json_object_get_member(m, "centers") : NULL; JsonArray *centers = (cnode && JSON_NODE_HOLDS_ARRAY(cnode)) ? json_node_get_array(cnode) : NULL; - if(centers && json_array_get_length(centers) == 3) + /* The reference package's own DensityCurvesModel stores centers/ + amplitudes/sigmas as (n_channels=3, n_layers) -- confirmed directly + against its bundled kodak_portra_endura.json, byte-identical to our + copy, and against apply_print_curves_morph's own + "n_channels = model.centers.shape[0]". That's the normal, + channel-major case (outer array length 3) and every profile checked + against the reference package matches it. + kodak_2302.json (not part of the reference package's own bundled + profiles -- a separately-authored addition) stores it transposed: + (n_layers=5, n_channels=3), outer length 5. Rather than assume one + convention universally (an earlier version of this fix did, and + broke every normal profile by mis-transposing correctly-shaped + data), detect orientation per-profile from the one invariant that + always holds: there are exactly 3 channels. */ + const int outer_len = centers ? MIN((int)json_array_get_length(centers), 8) : 0; + JsonArray *row0 = (outer_len > 0) ? json_array_get_array_element(centers, 0) : NULL; + const int inner_len = row0 ? (int)json_array_get_length(row0) : 0; + const gboolean channel_major = (outer_len == 3); /* centers[channel][layer]: normal */ + const gboolean layer_major = (!channel_major && inner_len == 3); /* centers[layer][channel]: e.g. kodak_2302 */ + if(channel_major || layer_major) { - JsonArray *row0 = json_array_get_array_element(centers, 0); - const int nl = MIN((int)json_array_get_length(row0), 8); + const int nl = channel_major ? inner_len : outer_len; p->curves_model.n_layers = nl; - json_read_dmatrix(m, "centers", &p->curves_model.centers[0][0], 3, nl); - json_read_dmatrix(m, "amplitudes", &p->curves_model.amplitudes[0][0], 3, nl); - json_read_dmatrix(m, "sigmas", &p->curves_model.sigmas[0][0], 3, nl); - /* json_read_dmatrix packed rows tightly into an 3×8 array — repack */ - if(nl != 8) + double c[24], a[24], s[24]; + if(json_read_dmatrix(m, "centers", c, outer_len, inner_len) + && json_read_dmatrix(m, "amplitudes", a, outer_len, inner_len) + && json_read_dmatrix(m, "sigmas", s, outer_len, inner_len)) { - double c[24], a[24], s[24]; - json_read_dmatrix(m, "centers", c, 3, nl); - json_read_dmatrix(m, "amplitudes", a, 3, nl); - json_read_dmatrix(m, "sigmas", s, 3, nl); for(int ch = 0; ch < 3; ch++) for(int l = 0; l < nl; l++) { - p->curves_model.centers[ch][l] = c[ch * nl + l]; - p->curves_model.amplitudes[ch][l] = a[ch * nl + l]; - p->curves_model.sigmas[ch][l] = s[ch * nl + l]; + const int idx = channel_major ? (ch * nl + l) : (l * 3 + ch); + p->curves_model.centers[ch][l] = c[idx]; + p->curves_model.amplitudes[ch][l] = a[idx]; + p->curves_model.sigmas[ch][l] = s[idx]; } } + else + p->curves_model.n_layers = 0; /* malformed data: don't leave partial state */ } } From 521a762e17a82cfb2512f76a00ad4e8694173a99 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 12 Jul 2026 22:06:49 +0200 Subject: [PATCH 05/56] reorg files to /common --- src/CMakeLists.txt | 2 ++ src/{iop => common}/spektra_core.c | 0 src/{iop => common}/spektra_core.h | 0 src/{iop => common}/spektra_sim.c | 0 src/{iop => common}/spektra_sim.h | 0 src/iop/CMakeLists.txt | 2 +- src/iop/spektrafilm.c | 4 ++-- 7 files changed, 5 insertions(+), 3 deletions(-) rename src/{iop => common}/spektra_core.c (100%) rename src/{iop => common}/spektra_core.h (100%) rename src/{iop => common}/spektra_sim.c (100%) rename src/{iop => common}/spektra_sim.h (100%) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 40edc6397a3c..5c047ab10578 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -88,6 +88,8 @@ FILE(GLOB SOURCE_FILES "common/ratings.c" "common/resource_limits.c" "common/selection.c" + "common/spektra_core.c" + "common/spektra_sim.c" "common/splines.cpp" "common/styles.c" "common/system_signal_handling.c" diff --git a/src/iop/spektra_core.c b/src/common/spektra_core.c similarity index 100% rename from src/iop/spektra_core.c rename to src/common/spektra_core.c diff --git a/src/iop/spektra_core.h b/src/common/spektra_core.h similarity index 100% rename from src/iop/spektra_core.h rename to src/common/spektra_core.h diff --git a/src/iop/spektra_sim.c b/src/common/spektra_sim.c similarity index 100% rename from src/iop/spektra_sim.c rename to src/common/spektra_sim.c diff --git a/src/iop/spektra_sim.h b/src/common/spektra_sim.h similarity index 100% rename from src/iop/spektra_sim.h rename to src/common/spektra_sim.h diff --git a/src/iop/CMakeLists.txt b/src/iop/CMakeLists.txt index e011ffea0884..48bd2ce94eef 100644 --- a/src/iop/CMakeLists.txt +++ b/src/iop/CMakeLists.txt @@ -98,7 +98,7 @@ add_iop(rawoverexposed "rawoverexposed.c") add_iop(velvia "velvia.c") add_iop(vignette "vignette.c") add_iop(splittoning "splittoning.c") -add_iop(spektrafilm "spektrafilm.c" "spektra_sim.c" "spektra_core.c") +add_iop(spektrafilm "spektrafilm.c") add_iop(grain "grain.c") add_iop(clahe "clahe.c") add_iop(bilateral "bilateral.cc") diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index ebecabee1f12..f1ccfe2051c0 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -81,8 +81,8 @@ #define SF_FREE_FILE(buf) g_free(buf) #define SF_DIAG_LOG(...) dt_print(DT_DEBUG_DEV, __VA_ARGS__) #define SF_STRTOD(s, end) g_ascii_strtod((s), (end)) -#include "spektra_core.h" -#include "spektra_sim.h" +#include "common/spektra_core.h" +#include "common/spektra_sim.h" DT_MODULE_INTROSPECTION(1, dt_iop_spektrafilm_params_t) From 727c138a9d7c5ba9883eb2dd1ebba9abaaa8cee6 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Mon, 13 Jul 2026 09:51:20 +0200 Subject: [PATCH 06/56] Use dt_gaussian_mean_blur_cl instead of fast to avoid border artifacts --- src/iop/spektrafilm.c | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index f1ccfe2051c0..71b3a2197358 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -1035,7 +1035,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ const float sigma = fmaxf(plan.sigma_um[j] * dsc / pixel_um, 1e-3f); err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4); if(err != CL_SUCCESS) break; - err = dt_gaussian_fast_blur_cl_buffer(devid, tmpa, tmpa, w, h, sigma, 4, -1e9f, 1e9f); + err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, sigma); if(err != CL_SUCCESS) break; const int reset = (j == 0); const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j]; @@ -1062,16 +1062,15 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f }; err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4); SF_CL_STEP("halation core copy"); - err = dt_gaussian_fast_blur_cl_buffer(devid, tmpa, tmpa, w, h, - fmaxf(core_um * hscl / pixel_um, 1e-3f), 4, -1e9f, 1e9f); + err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, + fmaxf(core_um * hscl / pixel_um, 1e-3f)); SF_CL_STEP("halation core blur"); for(int g3 = 0; g3 < 3; g3++) { err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpb, 0, 0, npix * f * 4); SF_CL_STEP("halation tail copy"); - err = dt_gaussian_fast_blur_cl_buffer(devid, tmpb, tmpb, w, h, - fmaxf(rat[g3] * tail_um * hscl / pixel_um, 1e-3f), 4, - -1e9f, 1e9f); + err = dt_gaussian_mean_blur_cl(devid, tmpb, w, h, 4, + fmaxf(rat[g3] * tail_um * hscl / pixel_um, 1e-3f)); SF_CL_STEP("halation tail blur"); const int reset = (g3 == 0); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(tmpb), @@ -1099,9 +1098,8 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ { err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpb, 0, 0, npix * f * 4); SF_CL_STEP("halation bounce copy"); - err = dt_gaussian_fast_blur_cl_buffer( - devid, tmpb, tmpb, w, h, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f), - 4, -1e9f, 1e9f); + err = dt_gaussian_mean_blur_cl( + devid, tmpb, w, h, 4, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f)); SF_CL_STEP("halation bounce blur"); const int reset = (k == 1); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(tmpb), @@ -1148,7 +1146,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_CL_STEP("coupler tail copy"); if(sig[g3] > 0.1f) { - err = dt_gaussian_fast_blur_cl_buffer(devid, tmpb, tmpb, w, h, sig[g3], 4, -1e9f, 1e9f); + err = dt_gaussian_mean_blur_cl(devid, tmpb, w, h, 4, sig[g3]); SF_CL_STEP("coupler tail blur"); } const int reset = (g3 == 0); @@ -1163,7 +1161,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ } else if(csigma > 0.1f) { - err = dt_gaussian_fast_blur_cl_buffer(devid, acc, acc, w, h, csigma, 4, -1e9f, 1e9f); + err = dt_gaussian_mean_blur_cl(devid, acc, w, h, 4, csigma); SF_CL_STEP("coupler blur"); } } @@ -1193,7 +1191,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_CL_STEP("grain gen"); const float gsigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); - err = dt_gaussian_fast_blur_cl_buffer(devid, tmpa, tmpa, w, h, gsigma, 4, -1000.0f, 1000.0f); + err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, gsigma); SF_CL_STEP("grain blur"); const float grenorm = sqrtf(2.0f * sqrtf((float)M_PI) * fmaxf(gsigma, 0.3f)); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2), From 1692465a303fb02aa5e8acf417574a1e576cd53f Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Mon, 13 Jul 2026 11:03:27 +0200 Subject: [PATCH 07/56] Add advanced options drop down menu to reduce default height of module --- src/iop/spektrafilm.c | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 71b3a2197358..c715bd4d963b 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -163,6 +163,7 @@ typedef struct dt_iop_spektrafilm_gui_data_t int n_entries; int film_entry[SF_MAX_PROFILES], n_films; /* indices into entries[] */ int paper_entry[SF_MAX_PROFILES], n_papers; + dt_gui_collapsible_section_t cs; } dt_iop_spektrafilm_gui_data_t; /* per-piece data: parameter snapshot + a lazily (re)built simulation. @@ -1343,6 +1344,7 @@ void gui_update(dt_iop_module_t *self) { dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; + dt_gui_update_collapsible_section(&g->cs); _rescan(self); dt_bauhaus_combobox_clear(g->film); @@ -1435,6 +1437,13 @@ void gui_init(dt_iop_module_t *self) _("automatically compensate print exposure for film exposure changes, as a real" " printer would print to a fixed density; disable for film exposure to affect" " brightness directly, same as a fixed enlarger exposure time")); + g->grain_on = dt_bauhaus_toggle_from_params(self, "grain_on"); + g->halation_on = dt_bauhaus_toggle_from_params(self, "halation_on"); + dt_gui_new_collapsible_section(&g->cs, "plugins/darkroom/spektrafilm/expand_advanced", + _("advanced"), GTK_BOX(self->widget), DT_ACTION(self)); + GtkWidget *sf_main_box = self->widget; /* restored after the advanced widgets below */ + self->widget = GTK_WIDGET(g->cs.container); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print"))); g->print_contrast = dt_bauhaus_slider_from_params(self, "print_contrast"); gtk_widget_set_tooltip_text(g->print_contrast, _("print contrast (morphs the paper's density curves)")); @@ -1450,18 +1459,14 @@ void gui_init(dt_iop_module_t *self) gtk_widget_set_tooltip_text(g->couplers_amount, _("DIR coupler strength: inter-layer inhibition drives saturation" " and edge effects (1.0 = film-accurate, 0 = off)")); - g->quality = dt_bauhaus_combobox_from_params(self, "quality"); - gtk_widget_set_tooltip_text(g->quality, - _("spectral accuracy vs speed; the tables are PCHIP-interpolated" - " and validated against the reference")); - g->grain_on = dt_bauhaus_toggle_from_params(self, "grain_on"); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "grain"))); g->grain_amount = dt_bauhaus_slider_from_params(self, "grain_amount"); g->grain_size = dt_bauhaus_slider_from_params(self, "grain_size"); gtk_widget_set_tooltip_text(g->grain_size, _("grain particle size (1.0 = film default; higher = coarser)")); - g->halation_on = dt_bauhaus_toggle_from_params(self, "halation_on"); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "halation"))); g->halation_amount = dt_bauhaus_slider_from_params(self, "halation_amount"); dt_bauhaus_slider_set_soft_range(g->halation_amount, 0.0f, 2.0f); gtk_widget_set_tooltip_text(g->halation_amount, @@ -1481,6 +1486,7 @@ void gui_init(dt_iop_module_t *self) dt_bauhaus_slider_set_format(g->protect_ev, _(" EV")); gtk_widget_set_tooltip_text(g->protect_ev, _("protect tones below this many stops over mid-grey from the boost")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "diffusion"))); g->diffusion_on = dt_bauhaus_toggle_from_params(self, "diffusion_on"); g->diffusion_strength = dt_bauhaus_slider_from_params(self, "diffusion_strength"); gtk_widget_set_tooltip_text(g->diffusion_strength, @@ -1490,10 +1496,16 @@ void gui_init(dt_iop_module_t *self) g->diffusion_warmth = dt_bauhaus_slider_from_params(self, "diffusion_warmth"); gtk_widget_set_tooltip_text(g->diffusion_warmth, _("diffusion halo warmth: >0 warm outer halo, <0 cool")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "settings"))); + g->quality = dt_bauhaus_combobox_from_params(self, "quality"); + gtk_widget_set_tooltip_text(g->quality, + _("spectral accuracy vs speed; the tables are PCHIP-interpolated" + " and validated against the reference")); g->film_format_mm = dt_bauhaus_slider_from_params(self, "film_format_mm"); dt_bauhaus_slider_set_format(g->film_format_mm, _(" mm")); gtk_widget_set_tooltip_text(g->film_format_mm, _("physical film width; sets the scale of grain and halation")); + self->widget = sf_main_box; } // clang-format off From b7bb339cf8d2e768f9e19df28caad96ed42634c4 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Mon, 13 Jul 2026 11:13:49 +0200 Subject: [PATCH 08/56] reset print exposure to 0 EV when enabling auto print exposure --- src/iop/spektrafilm.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index c715bd4d963b..91330cf63dc1 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -1337,7 +1337,21 @@ static void _update_print_sensitivity(dt_iop_module_t *self) void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous) { dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; if(!w || w == g->scan_film) _update_print_sensitivity(self); + if(w == g->print_auto_exposure && !*(gboolean *)previous && p->print_auto_exposure) + { + /* print_exposure_ev (manual) and print_auto_exposure (automatic) are + independent, always-additive factors -- matching the reference app's + own architecture (raw *= exposure_factor; raw *= enlarger.print_exposure, + two separate multiplications) rather than a mutually-exclusive pair. + Left alone, re-enabling auto stacks on top of whatever manual EV was + dialed in while it was off, which reads as "auto exposure is now + offset by the old manual value". Reset the manual slider on OFF->ON + so re-enabling auto gives a clean auto result to fine-tune from. */ + p->print_exposure_ev = 0.0f; + dt_bauhaus_slider_set(g->print_exposure_ev, 0.0f); + } } void gui_update(dt_iop_module_t *self) From 4ca3ae062d19b70511af03ad7ff00e437b3a98c8 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Mon, 13 Jul 2026 11:37:26 +0200 Subject: [PATCH 09/56] move scan film option to the advanced section as it's a rather automatic setting --- src/iop/spektrafilm.c | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 91330cf63dc1..efccc618437a 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -1432,10 +1432,6 @@ void gui_init(dt_iop_module_t *self) g_signal_connect(G_OBJECT(g->paper), "value-changed", G_CALLBACK(_paper_changed), self); gtk_box_pack_start(GTK_BOX(self->widget), g->paper, TRUE, TRUE, 0); - g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film"); - gtk_widget_set_tooltip_text(g->scan_film, - _("view the developed film directly (no print stage)")); - g->exposure_ev = dt_bauhaus_slider_from_params(self, "exposure_ev"); dt_bauhaus_slider_set_format(g->exposure_ev, _(" EV")); gtk_widget_set_tooltip_text( @@ -1458,6 +1454,9 @@ void gui_init(dt_iop_module_t *self) GtkWidget *sf_main_box = self->widget; /* restored after the advanced widgets below */ self->widget = GTK_WIDGET(g->cs.container); dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print"))); + g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film"); + gtk_widget_set_tooltip_text(g->scan_film, + _("view the developed film directly (no print stage)")); g->print_contrast = dt_bauhaus_slider_from_params(self, "print_contrast"); gtk_widget_set_tooltip_text(g->print_contrast, _("print contrast (morphs the paper's density curves)")); From fbfe4dec421e861fb0409f3d89e594ca2de0bbbe Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Mon, 13 Jul 2026 12:14:28 +0200 Subject: [PATCH 10/56] remove tick from disabled auto print exposure toggle for positive films --- src/iop/spektrafilm.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index efccc618437a..f867204ea278 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -1331,6 +1331,18 @@ static void _update_print_sensitivity(dt_iop_module_t *self) gtk_widget_set_sensitive(g->print_contrast, printing); gtk_widget_set_sensitive(g->filter_m, printing); gtk_widget_set_sensitive(g->filter_y, printing); + /* toggle_from_params checkboxes keep showing their tick even when made + insensitive -- GTK just dims the whole widget, so a checked-but-grayed + box can read as "this is still on" when it has no effect at all (no + print stage on positive/reversal film). Blank the tick while + insensitive and restore the real value once re-enabled. Wrapped in + DT_ENTER/LEAVE_GUI_UPDATE -- the same guard dt_iop_gui_update's own + programmatic widget syncs rely on -- so this is purely visual and + never writes back into the param. */ + DT_ENTER_GUI_UPDATE(); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_auto_exposure), + printing && p->print_auto_exposure); + DT_LEAVE_GUI_UPDATE(); } /* called by the core whenever a params-linked widget changed */ From 0bac56ee6a431b1933abd4427d13c02b683eea3a Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Tue, 14 Jul 2026 14:15:38 +0200 Subject: [PATCH 11/56] Change module to lowercase and write better description --- src/iop/spektrafilm.c | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index f867204ea278..371b491620f0 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -260,7 +260,7 @@ void cleanup_global(dt_iop_module_so_t *self) const char *name(void) { - return _("Spektrafilm"); + return _("spektrafilm"); } const char *aliases(void) { @@ -269,8 +269,11 @@ const char *aliases(void) const char **description(dt_iop_module_t *self) { return dt_iop_set_description( - self, _("spectral film + print simulation (spektrafilm)"), _("creative"), - _("linear, RGB, scene-referred"), _("non-linear, RGB"), _("non-linear, RGB, display-referred")); + self, + _("simulates the physical process of developing and printing analog film,\n" + "using spectral emulsion and paper data from the spektrafilm project"), + _("creative"), _("linear, RGB, scene-referred"), _("non-linear, RGB"), + _("non-linear, RGB, display-referred")); } int default_group(void) { From 2d6311ac1724d4cbd9a8fe93da6c50083490d71b Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Tue, 14 Jul 2026 14:24:42 +0200 Subject: [PATCH 12/56] register spektrafilm in the legacy iop-order migration path --- src/common/iop_order.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/common/iop_order.c b/src/common/iop_order.c index db120a66cd10..fa1703ae2e3d 100644 --- a/src/common/iop_order.c +++ b/src/common/iop_order.c @@ -740,6 +740,7 @@ void dt_ioppr_migrate_legacy_iop_order_list(GList *iop_order_list) _insert_before(iop_order_list, "nlmeans", "blurs"); _insert_before(iop_order_list, "filmicrgb", "sigmoid"); _insert_before(iop_order_list, "filmicrgb", "agx"); + _insert_before(iop_order_list, "colisa", "spektrafilm"); _insert_before(iop_order_list, "colorbalancergb", "colorequal"); _insert_before(iop_order_list, "highlights", "rasterfile"); _insert_before(iop_order_list, "colorbalance", "colorharmonizer"); From ce335f29f2b7d12e840119adc05ae99adbf25740 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Wed, 15 Jul 2026 07:12:20 +0200 Subject: [PATCH 13/56] Add tabbed interface to prepare for more options --- src/iop/spektrafilm.c | 52 ++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 371b491620f0..6fdce78bf1fc 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -65,6 +65,7 @@ #include "common/iop_profile.h" #include "common/opencl.h" #include "common/gaussian.h" +#include "gui/accelerators.h" #include "gui/gtk.h" #include "iop/iop_api.h" @@ -163,7 +164,7 @@ typedef struct dt_iop_spektrafilm_gui_data_t int n_entries; int film_entry[SF_MAX_PROFILES], n_films; /* indices into entries[] */ int paper_entry[SF_MAX_PROFILES], n_papers; - dt_gui_collapsible_section_t cs; + GtkNotebook *notebook; } dt_iop_spektrafilm_gui_data_t; /* per-piece data: parameter snapshot + a lazily (re)built simulation. @@ -1373,7 +1374,6 @@ void gui_update(dt_iop_module_t *self) { dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; - dt_gui_update_collapsible_section(&g->cs); _rescan(self); dt_bauhaus_combobox_clear(g->film); @@ -1447,12 +1447,25 @@ void gui_init(dt_iop_module_t *self) g_signal_connect(G_OBJECT(g->paper), "value-changed", G_CALLBACK(_paper_changed), self); gtk_box_pack_start(GTK_BOX(self->widget), g->paper, TRUE, TRUE, 0); + /* filmic-style tabbed notebook: everything else lives in tabs instead of a + single flat, ever-growing list of sliders. */ + GtkWidget *sf_main_box = self->widget; /* restored after all tab pages below */ + static struct dt_action_def_t notebook_def = { }; + g->notebook = dt_ui_notebook_new(¬ebook_def); + dt_action_define_iop(self, NULL, N_("page"), GTK_WIDGET(g->notebook), ¬ebook_def); + dt_gui_box_add(sf_main_box, GTK_WIDGET(g->notebook)); + + /* ---- tab: film and print ---- */ + self->widget = dt_ui_notebook_page(g->notebook, N_("film and print"), NULL); g->exposure_ev = dt_bauhaus_slider_from_params(self, "exposure_ev"); dt_bauhaus_slider_set_format(g->exposure_ev, _(" EV")); gtk_widget_set_tooltip_text( g->exposure_ev, _("film exposure compensation; with auto print exposure enabled, print" " exposure follows automatically so this has no net brightness effect" " (except on positive/reversal film, which has no print stage)")); + g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film"); + gtk_widget_set_tooltip_text(g->scan_film, + _("view the developed film directly (no print stage)")); g->print_exposure_ev = dt_bauhaus_slider_from_params(self, "print_exposure_ev"); dt_bauhaus_slider_set_format(g->print_exposure_ev, _(" EV")); gtk_widget_set_tooltip_text(g->print_exposure_ev, _("print brightness (enlarger exposure)")); @@ -1462,16 +1475,6 @@ void gui_init(dt_iop_module_t *self) _("automatically compensate print exposure for film exposure changes, as a real" " printer would print to a fixed density; disable for film exposure to affect" " brightness directly, same as a fixed enlarger exposure time")); - g->grain_on = dt_bauhaus_toggle_from_params(self, "grain_on"); - g->halation_on = dt_bauhaus_toggle_from_params(self, "halation_on"); - dt_gui_new_collapsible_section(&g->cs, "plugins/darkroom/spektrafilm/expand_advanced", - _("advanced"), GTK_BOX(self->widget), DT_ACTION(self)); - GtkWidget *sf_main_box = self->widget; /* restored after the advanced widgets below */ - self->widget = GTK_WIDGET(g->cs.container); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print"))); - g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film"); - gtk_widget_set_tooltip_text(g->scan_film, - _("view the developed film directly (no print stage)")); g->print_contrast = dt_bauhaus_slider_from_params(self, "print_contrast"); gtk_widget_set_tooltip_text(g->print_contrast, _("print contrast (morphs the paper's density curves)")); @@ -1487,14 +1490,22 @@ void gui_init(dt_iop_module_t *self) gtk_widget_set_tooltip_text(g->couplers_amount, _("DIR coupler strength: inter-layer inhibition drives saturation" " and edge effects (1.0 = film-accurate, 0 = off)")); + g->film_format_mm = dt_bauhaus_slider_from_params(self, "film_format_mm"); + dt_bauhaus_slider_set_format(g->film_format_mm, _(" mm")); + gtk_widget_set_tooltip_text(g->film_format_mm, + _("physical film width; sets the scale of grain and halation")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "grain"))); + /* ---- tab: grain ---- */ + self->widget = dt_ui_notebook_page(g->notebook, N_("grain"), NULL); + g->grain_on = dt_bauhaus_toggle_from_params(self, "grain_on"); g->grain_amount = dt_bauhaus_slider_from_params(self, "grain_amount"); g->grain_size = dt_bauhaus_slider_from_params(self, "grain_size"); gtk_widget_set_tooltip_text(g->grain_size, _("grain particle size (1.0 = film default; higher = coarser)")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "halation"))); + /* ---- tab: halation ---- */ + self->widget = dt_ui_notebook_page(g->notebook, N_("halation"), NULL); + g->halation_on = dt_bauhaus_toggle_from_params(self, "halation_on"); g->halation_amount = dt_bauhaus_slider_from_params(self, "halation_amount"); dt_bauhaus_slider_set_soft_range(g->halation_amount, 0.0f, 2.0f); gtk_widget_set_tooltip_text(g->halation_amount, @@ -1514,7 +1525,9 @@ void gui_init(dt_iop_module_t *self) dt_bauhaus_slider_set_format(g->protect_ev, _(" EV")); gtk_widget_set_tooltip_text(g->protect_ev, _("protect tones below this many stops over mid-grey from the boost")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "diffusion"))); + + /* ---- tab: diffusion ---- */ + self->widget = dt_ui_notebook_page(g->notebook, N_("diffusion"), NULL); g->diffusion_on = dt_bauhaus_toggle_from_params(self, "diffusion_on"); g->diffusion_strength = dt_bauhaus_slider_from_params(self, "diffusion_strength"); gtk_widget_set_tooltip_text(g->diffusion_strength, @@ -1524,15 +1537,14 @@ void gui_init(dt_iop_module_t *self) g->diffusion_warmth = dt_bauhaus_slider_from_params(self, "diffusion_warmth"); gtk_widget_set_tooltip_text(g->diffusion_warmth, _("diffusion halo warmth: >0 warm outer halo, <0 cool")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "settings"))); + + /* ---- tab: settings ---- */ + self->widget = dt_ui_notebook_page(g->notebook, N_("settings"), NULL); g->quality = dt_bauhaus_combobox_from_params(self, "quality"); gtk_widget_set_tooltip_text(g->quality, _("spectral accuracy vs speed; the tables are PCHIP-interpolated" " and validated against the reference")); - g->film_format_mm = dt_bauhaus_slider_from_params(self, "film_format_mm"); - dt_bauhaus_slider_set_format(g->film_format_mm, _(" mm")); - gtk_widget_set_tooltip_text(g->film_format_mm, - _("physical film width; sets the scale of grain and halation")); + self->widget = sf_main_box; } From 061e5513fc3bb1d14e20e45c4b74b0cd64e6fc6a Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Wed, 15 Jul 2026 15:57:50 +0200 Subject: [PATCH 14/56] add remaining spektrafilm diffusion filters, add preflash, add pre compression boost option by @Arecsu, better organize print and film tab --- data/kernels/spektrafilm.cl | 2 + src/common/spektra_core.c | 39 ++++++-- src/common/spektra_core.h | 4 +- src/common/spektra_sim.c | 6 ++ src/common/spektra_sim.h | 2 + src/iop/spektrafilm.c | 177 +++++++++++++++++++++++++++++++++--- 6 files changed, 210 insertions(+), 20 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 2d014c2f8033..94685e77880b 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -522,6 +522,7 @@ __kernel void spektrafilm_scan(__global const float4 *cmy, __read_only image2d_t const float hi2, __constant float *mats, __global const float *cmax_table, const int cmax_nl, const int cmax_nh, const int compress_mode, + const float out_luminance_boost, const int bw_on, const float bw_m, const float bw_q) { const int x = get_global_id(0), y = get_global_id(1); @@ -534,6 +535,7 @@ __kernel void spektrafilm_scan(__global const float4 *cmy, __read_only image2d_t const float b = (c4.z - lo2) / (hi2 - lo2) * scale; float3 lx = sf_pchip3d(lut, sx, sy, sz, cmn, cmx, steps, r, g, b); float3 xyz = (float3)(exp10(lx.x), exp10(lx.y), exp10(lx.z)); + if(out_luminance_boost != 1.0f) xyz *= out_luminance_boost; if(bw_on) /* scanner black/white point (positive film scans) */ { const float yc = sf_clampf(bw_m * xyz.y + bw_q, 0.0f, 1.0f); diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index beae981cf687..70354902d0ce 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -258,12 +258,35 @@ typedef struct sf_diff_family_t sf_diff_group_t core, halo, bloom; double w_c, w_h, w_b; double total_gain; /* family scatter gain in strength->p_s */ + double halo_warmth_base; /* per-family halo warmth bias, added to the + user's own warmth slider before redistribution + (spektrafilm's DIFFUSION_FILTER_SHAPES + halo_warmth_base) */ } sf_diff_family_t; +/* All four families spektrafilm ships, values ported exactly from + model/diffusion.py's _DIFFUSION_FILTER_SHAPES / _DIFFUSION_FAMILY_TOTAL_GAIN. */ +static const sf_diff_family_t SF_FAMILY_GLIMMERGLASS = { + { 10.0, 1.5, 2, 0.0 }, { 50.0, 2.0, 3, 0.0 }, { 260.0, 2.5, 4, 3.2 }, + 0.60, 0.30, 0.10, 0.65, 0.0 +}; /* Black Pro-Mist (the app default family). */ static const sf_diff_family_t SF_FAMILY_BPM = { { 16.0, 1.5, 2, 0.0 }, { 95.0, 2.0, 3, 0.0 }, { 380.0, 2.5, 4, 3.5 }, - 0.40, 0.47, 0.13, 0.75 + 0.40, 0.47, 0.13, 0.75, 0.65 +}; +/* Classic Pro-Mist. */ +static const sf_diff_family_t SF_FAMILY_PRO_MIST = { + { 14.0, 1.5, 2, 0.0 }, { 150.0, 2.0, 3, 0.0 }, { 650.0, 2.5, 4, 2.9 }, + 0.28, 0.42, 0.30, 1.05, 0.40 +}; +static const sf_diff_family_t SF_FAMILY_CINEBLOOM = { + { 20.0, 1.5, 2, 0.0 }, { 200.0, 2.0, 3, 0.0 }, { 1000.0, 2.5, 4, 2.5 }, + 0.22, 0.30, 0.48, 1.00, 0.85 +}; +/* Index order must match dt_iop_spektrafilm_diffusion_family_t in spektrafilm.c. */ +static const sf_diff_family_t *const SF_DIFF_FAMILIES[4] = { + &SF_FAMILY_BPM, &SF_FAMILY_GLIMMERGLASS, &SF_FAMILY_PRO_MIST, &SF_FAMILY_CINEBLOOM }; static const double SF_DIFF_BREAKS[5] = { 0.125, 0.25, 0.5, 1.0, 2.0 }; @@ -351,11 +374,12 @@ static void sf_diff_halo_warmth(const double *wgt, int n, double warmth, double } /* Build the shared Gaussian bank (used by both CPU and GPU). */ -int sf_diffusion_build_plan(float strength, float halo_warmth, sf_diffusion_plan_t *plan) +int sf_diffusion_build_plan(int family, float strength, float halo_warmth, sf_diffusion_plan_t *plan) { plan->n = 0; plan->p_s = 0.0f; - const sf_diff_family_t *fam = &SF_FAMILY_BPM; + const int nfam = (int)(sizeof(SF_DIFF_FAMILIES) / sizeof(SF_DIFF_FAMILIES[0])); + const sf_diff_family_t *fam = SF_DIFF_FAMILIES[(family >= 0 && family < nfam) ? family : 0]; const double p_s = sf_diff_strength_to_ps((double)strength, fam); if(p_s <= 0.0) return 0; @@ -366,7 +390,9 @@ int sf_diffusion_build_plan(float strength, float halo_warmth, sf_diffusion_plan const int nh = sf_diff_expand(&fam->halo, 0, hlam, hw); const int nb = sf_diff_expand(&fam->bloom, 1, blam, bw); double hch[3][SF_DIFFUSION_MAX_COMP]; - sf_diff_halo_warmth(hw, nh, (double)halo_warmth, hch); + /* effective_warmth = family base + user knob, matching + diffusion_filter_radial_profile()'s own "cfg base + halo_warmth". */ + sf_diff_halo_warmth(hw, nh, fam->halo_warmth_base + (double)halo_warmth, hch); const double L2 = 1.4142135623730951; /* exp(-r/lambda) ~ Gaussian sigma=lambda*sqrt(2) */ int idx = 0; @@ -397,11 +423,12 @@ int sf_diffusion_build_plan(float strength, float halo_warmth, sf_diffusion_plan /* Apply the diffusion filter in place on a linear w*h*3 plane. */ void sf_diffusion_filter(float *const raw, const int w, const int h, const double pixel_um, - const float strength, const float spatial_scale, const float halo_warmth) + const int family, const float strength, const float spatial_scale, + const float halo_warmth) { if(strength <= 0.0f || spatial_scale <= 0.0f) return; sf_diffusion_plan_t plan; - if(!sf_diffusion_build_plan(strength, halo_warmth, &plan) || plan.p_s <= 0.0f) return; + if(!sf_diffusion_build_plan(family, strength, halo_warmth, &plan) || plan.p_s <= 0.0f) return; const double sc = fmax((double)spatial_scale, 1e-6); const size_t npix = (size_t)w * h, nn = npix * 3; diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index f0c8fefb7416..90c477d0718f 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -34,7 +34,7 @@ void sf_blur_plane3(float *buf, int w, int h, float sigma, float *plane); void sf_halation(float *raw, int w, int h, double pixel_um, float amount, float spatial_scale); void sf_boost_highlights(float *raw, int w, int h, float boost_ev, float boost_range, float protect_ev); -void sf_diffusion_filter(float *raw, int w, int h, double pixel_um, float strength, +void sf_diffusion_filter(float *raw, int w, int h, double pixel_um, int family, float strength, float spatial_scale, float halo_warmth); /* Diffusion-filter Gaussian bank, built host-side and consumed by the GPU path @@ -55,7 +55,7 @@ typedef struct sf_diffusion_plan_t /* Fill `plan` for the given strength/warmth. Returns 0 and sets plan->p_s=0 when the filter is a no-op. spatial_scale/pixel are applied by the caller (sigma_px = sigma_um * spatial_scale / pixel_um). */ -int sf_diffusion_build_plan(float strength, float halo_warmth, sf_diffusion_plan_t *plan); +int sf_diffusion_build_plan(int family, float strength, float halo_warmth, sf_diffusion_plan_t *plan); /* Whole-file reader for the bundle loader (bundle.json and the .cube LUTs are diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 9bf7d8aed390..cd4d12d8a37a 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -271,6 +271,7 @@ struct sf_sim_t /* output gamut compression */ sf_output_compress_t out_compress; + double out_luminance_boost; double out_rgb2xyz[9], out_xyz2rgb[9]; double oklab_m1inv[9], oklab_m2inv[9]; float *cmax; /* SF_CMAX_NL × SF_CMAX_NH */ @@ -789,6 +790,7 @@ void sf_sim_params_defaults(sf_sim_params_t *p) p->lut_steps = 0; p->input_gamut_compress = true; p->output_compress = SF_OUTPUT_COMPRESS_OKLCH; + p->out_luminance_boost = 1.0; sf_sim_params_set_input_prophoto(p); /* reference IOParams default */ sf_sim_params_set_output_srgb(p); } @@ -1589,6 +1591,7 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, s->print_positive = (print && print->type && strcmp(print->type, "positive") == 0); s->has_print = !p->scan_film; s->out_compress = p->output_compress; + s->out_luminance_boost = p->out_luminance_boost; s->print_exposure = p->print_exposure; s->lut_steps = p->lut_steps; if(s->lut_steps == 1) s->lut_steps = 0; @@ -2260,6 +2263,8 @@ void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, size_t n } double xyz[3], rgb[3]; for(int m = 0; m < 3; m++) xyz[m] = pow(10.0, lx[m]); + if(sim->out_luminance_boost != 1.0) + for(int m = 0; m < 3; m++) xyz[m] *= sim->out_luminance_boost; if(sim->scan_bw_on) { /* scanner black/white point (positive film): scale toward Y in [0,1] */ @@ -2411,6 +2416,7 @@ sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *s) } g->out_compress = s->out_compress; + g->out_luminance_boost = (float)s->out_luminance_boost; cp9f(g->out_rgb2xyz, s->out_rgb2xyz); cp9f(g->out_xyz2rgb, s->out_xyz2rgb); cp9f(g->oklab_m1, SF_OKLAB_M1); diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index f5769f0b08c9..20ea9222eb35 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -155,6 +155,7 @@ typedef struct sf_sim_gpu_t float grain_rms[3], grain_uniformity[3], grain_dmin[3]; /* output gamut compression */ int out_compress; /* sf_output_compress_t */ + float out_luminance_boost; float out_rgb2xyz[9], out_xyz2rgb[9]; float oklab_m1[9], oklab_m2[9], oklab_m1inv[9], oklab_m2inv[9]; const float *cmax_table; /* cmax_nl * cmax_nh, borrowed from the sim */ @@ -258,6 +259,7 @@ typedef struct sf_sim_params_t double output_xyz_to_rgb[9]; double output_white_xy[2]; sf_output_compress_t output_compress; /* SF_OUTPUT_COMPRESS_OKLCH */ + double out_luminance_boost; /* 1.0 = pre-gamut XYZ multiplier before OkLCh compressor */ } sf_sim_params_t; void sf_sim_params_defaults(sf_sim_params_t *p); diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 6fdce78bf1fc..163cad2b5774 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -85,7 +85,7 @@ #include "common/spektra_core.h" #include "common/spektra_sim.h" -DT_MODULE_INTROSPECTION(1, dt_iop_spektrafilm_params_t) +DT_MODULE_INTROSPECTION(2, dt_iop_spektrafilm_params_t) /* Spatial-scale constants, micrometres on film unless noted (see the LUT module for the full rationale; these are shared with modify_roi_in() and @@ -111,6 +111,15 @@ typedef enum dt_iop_spektrafilm_quality_t DT_SPEKTRAFILM_Q_EXACT = 3, // $DESCRIPTION: "exact spectral (very slow)" } dt_iop_spektrafilm_quality_t; +/* order must match SF_DIFF_FAMILIES[] in spektra_core.c */ +typedef enum dt_iop_spektrafilm_diffusion_family_t +{ + DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST = 0, // $DESCRIPTION: "black pro-mist" + DT_SPEKTRAFILM_DIFF_GLIMMERGLASS = 1, // $DESCRIPTION: "glimmerglass" + DT_SPEKTRAFILM_DIFF_PRO_MIST = 2, // $DESCRIPTION: "pro-mist" + DT_SPEKTRAFILM_DIFF_CINEBLOOM = 3, // $DESCRIPTION: "cinebloom" +} dt_iop_spektrafilm_diffusion_family_t; + typedef struct dt_iop_spektrafilm_params_t { uint32_t film_hash; // $DEFAULT: 0 (0 = first available filming stock) @@ -122,6 +131,9 @@ typedef struct dt_iop_spektrafilm_params_t float filter_m; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration M" float filter_y; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration Y" float couplers_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "DIR couplers" + float preflash_exposure; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash exposure" + float preflash_m_shift; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash M filter shift" + float preflash_y_shift; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash Y filter shift" gboolean scan_film; // $DEFAULT: FALSE $DESCRIPTION: "scan the film (skip print)" dt_iop_spektrafilm_quality_t quality; // $DEFAULT: DT_SPEKTRAFILM_Q_STANDARD $DESCRIPTION: "quality" gboolean halation_on; // $DEFAULT: TRUE $DESCRIPTION: "enable halation" @@ -131,6 +143,7 @@ typedef struct dt_iop_spektrafilm_params_t float boost_range; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.3 $DESCRIPTION: "boost range" float protect_ev; // $MIN: 0.0 $MAX: 6.0 $DEFAULT: 4.0 $DESCRIPTION: "boost protect" gboolean diffusion_on; // $DEFAULT: FALSE $DESCRIPTION: "enable diffusion filter" + dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; // $DEFAULT: DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST $DESCRIPTION: "diffusion filter type" float diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "diffusion strength" float diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "diffusion size" float diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "diffusion halo warmth" @@ -138,6 +151,7 @@ typedef struct dt_iop_spektrafilm_params_t float grain_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "grain" float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "film format" + float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost" } dt_iop_spektrafilm_params_t; /* one discovered profile: stock (= file base name), display name, stage */ @@ -156,10 +170,11 @@ typedef struct dt_iop_spektrafilm_gui_data_t GtkWidget *film, *paper; GtkWidget *exposure_ev, *print_exposure_ev, *print_auto_exposure, *print_contrast, *filter_m, *filter_y; GtkWidget *couplers_amount, *scan_film, *quality; + GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift; GtkWidget *halation_on, *halation_amount, *halation_scale; GtkWidget *boost_ev, *boost_range, *protect_ev; - GtkWidget *diffusion_on, *diffusion_strength, *diffusion_scale, *diffusion_warmth; - GtkWidget *grain_on, *grain_amount, *grain_size, *film_format_mm; + GtkWidget *diffusion_on, *diffusion_filter_family, *diffusion_strength, *diffusion_scale, *diffusion_warmth; + GtkWidget *grain_on, *grain_amount, *grain_size, *film_format_mm, *output_luminance_boost; sf_prof_entry_t entries[SF_MAX_PROFILES]; int n_entries; int film_entry[SF_MAX_PROFILES], n_films; /* indices into entries[] */ @@ -290,6 +305,101 @@ dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, dt_dev_pixelp return IOP_CS_RGB; } +int legacy_params(dt_iop_module_t *self, const void *const old_params, const int old_version, + void **new_params, int32_t *new_params_size, int *new_version) +{ + /* v1 -> v2: added preflash_exposure/preflash_m_shift/preflash_y_shift, + diffusion_filter_family, and output_luminance_boost (Arecsu's + pre-compression boost patch). v1 here is the module's true original params + shape -- confirmed directly against the live, unmodified upstream + source, not reconstructed from memory -- covering every params layout + this module has shipped with so far: the introspection version was + never bumped through several earlier field additions (print_auto_exposure + among them) during this module's fast-moving initial development, so + this migration also recovers any history saved against those earlier + shapes, same reasoning as the previous v1->v2 migration this one + replaces: the struct has only ever grown by appending fields, so an + old, smaller saved blob still matches the leading fields of the + current struct, and the trailing fields (this migration's job) are + exactly what's missing. From this version onward, any further params + struct change should bump the version and add another case here rather + than silently drift again. */ + typedef struct dt_iop_spektrafilm_params_v1_t + { + uint32_t film_hash; + uint32_t paper_hash; + float exposure_ev; + float print_exposure_ev; + gboolean print_auto_exposure; + float print_contrast; + float filter_m; + float filter_y; + float couplers_amount; + gboolean scan_film; + dt_iop_spektrafilm_quality_t quality; + gboolean halation_on; + float halation_amount; + float halation_scale; + float boost_ev; + float boost_range; + float protect_ev; + gboolean diffusion_on; + float diffusion_strength; + float diffusion_scale; + float diffusion_warmth; + gboolean grain_on; + float grain_amount; + float grain_size; + float film_format_mm; + } dt_iop_spektrafilm_params_v1_t; + + if(old_version == 1) + { + const dt_iop_spektrafilm_params_v1_t *o = (dt_iop_spektrafilm_params_v1_t *)old_params; + dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); + + n->film_hash = o->film_hash; + n->paper_hash = o->paper_hash; + n->exposure_ev = o->exposure_ev; + n->print_exposure_ev = o->print_exposure_ev; + n->print_auto_exposure = o->print_auto_exposure; + n->print_contrast = o->print_contrast; + n->filter_m = o->filter_m; + n->filter_y = o->filter_y; + n->couplers_amount = o->couplers_amount; + n->preflash_exposure = 0.0f; /* new field: neutral default, no-op (matches upstream) */ + n->preflash_m_shift = 0.0f; /* new field: neutral default, no-op */ + n->preflash_y_shift = 0.0f; /* new field: neutral default, no-op */ + n->scan_film = o->scan_film; + n->quality = o->quality; + n->halation_on = o->halation_on; + n->halation_amount = o->halation_amount; + n->halation_scale = o->halation_scale; + n->boost_ev = o->boost_ev; + n->boost_range = o->boost_range; + n->protect_ev = o->protect_ev; + n->diffusion_on = o->diffusion_on; + /* new field: the engine was hardcoded to Black Pro-Mist before the + family selector existed, so this exactly reproduces old saved + diffusion settings rather than just picking a neutral default. */ + n->diffusion_filter_family = DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST; + n->diffusion_strength = o->diffusion_strength; + n->diffusion_scale = o->diffusion_scale; + n->diffusion_warmth = o->diffusion_warmth; + n->grain_on = o->grain_on; + n->grain_amount = o->grain_amount; + n->grain_size = o->grain_size; + n->film_format_mm = o->film_format_mm; + n->output_luminance_boost = 1.0f; /* new field: neutral default, no-op (matches upstream) */ + + *new_params = n; + *new_params_size = sizeof(dt_iop_spektrafilm_params_t); + *new_version = 2; + return 0; + } + return 1; +} + /* ---------------------------------------------------------------------- */ /* profile discovery */ /* ---------------------------------------------------------------------- */ @@ -485,8 +595,12 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, key = _mix64(key, &p->filter_m, sizeof p->filter_m); key = _mix64(key, &p->filter_y, sizeof p->filter_y); key = _mix64(key, &p->couplers_amount, sizeof p->couplers_amount); + key = _mix64(key, &p->preflash_exposure, sizeof p->preflash_exposure); + key = _mix64(key, &p->preflash_m_shift, sizeof p->preflash_m_shift); + key = _mix64(key, &p->preflash_y_shift, sizeof p->preflash_y_shift); key = _mix64(key, &p->scan_film, sizeof p->scan_film); key = _mix64(key, &p->quality, sizeof p->quality); + key = _mix64(key, &p->output_luminance_boost, sizeof p->output_luminance_boost); key = _mix64(key, m_in, sizeof m_in); key = _mix64(key, m_out, sizeof m_out); @@ -590,8 +704,12 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, sp.y_filter_shift = p->filter_y; sp.couplers_active = (p->couplers_amount > 0.0f); sp.couplers_amount = p->couplers_amount; + sp.preflash_exposure = p->preflash_exposure; + sp.preflash_m_shift = p->preflash_m_shift; + sp.preflash_y_shift = p->preflash_y_shift; sp.scan_film = p->scan_film; sp.lut_steps = _quality_steps(p->quality); + sp.out_luminance_boost = p->output_luminance_boost; if(p->print_contrast != 1.0f) { sp.morph_active = true; @@ -764,8 +882,8 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c highlight boost -> diffusion filter -> halation */ sf_boost_highlights(plane, w, h, d->p.boost_ev, d->p.boost_range, d->p.protect_ev); if(d->p.diffusion_on) - sf_diffusion_filter(plane, w, h, (double)pixel_um, d->p.diffusion_strength, - d->p.diffusion_scale, d->p.diffusion_warmth); + sf_diffusion_filter(plane, w, h, (double)pixel_um, (int)d->p.diffusion_filter_family, + d->p.diffusion_strength, d->p.diffusion_scale, d->p.diffusion_warmth); if(d->p.halation_on && d->p.halation_amount > 0.0f) sf_halation(plane, w, h, (double)pixel_um, d->p.halation_amount, d->p.halation_scale); @@ -1031,7 +1149,8 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ if(d->p.diffusion_on) { sf_diffusion_plan_t plan; - if(sf_diffusion_build_plan(d->p.diffusion_strength, d->p.diffusion_warmth, &plan) + if(sf_diffusion_build_plan((int)d->p.diffusion_filter_family, d->p.diffusion_strength, + d->p.diffusion_warmth, &plan) && plan.p_s > 0.0f) { const float dsc = fmaxf(d->p.diffusion_scale, 1e-6f); @@ -1226,7 +1345,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(sz_cl), CLARG(sn_cl), CLARG(sm_cl), CLARG(steps), CLARG(g->scan_lo[0]), CLARG(g->scan_lo[1]), CLARG(g->scan_lo[2]), CLARG(g->scan_hi[0]), CLARG(g->scan_hi[1]), CLARG(g->scan_hi[2]), CLARG(mats_cl), CLARG(cm_cl), CLARG(g->cmax_nl), CLARG(g->cmax_nh), - CLARG(g->out_compress), CLARG(g->scan_bw_on), CLARG(g->scan_bw_m), + CLARG(g->out_compress), CLARG(g->out_luminance_boost), CLARG(g->scan_bw_on), CLARG(g->scan_bw_m), CLARG(g->scan_bw_q)); SF_CL_STEP("scan"); @@ -1457,6 +1576,7 @@ void gui_init(dt_iop_module_t *self) /* ---- tab: film and print ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("film and print"), NULL); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "film"))); g->exposure_ev = dt_bauhaus_slider_from_params(self, "exposure_ev"); dt_bauhaus_slider_set_format(g->exposure_ev, _(" EV")); gtk_widget_set_tooltip_text( @@ -1466,6 +1586,7 @@ void gui_init(dt_iop_module_t *self) g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film"); gtk_widget_set_tooltip_text(g->scan_film, _("view the developed film directly (no print stage)")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print"))); g->print_exposure_ev = dt_bauhaus_slider_from_params(self, "print_exposure_ev"); dt_bauhaus_slider_set_format(g->print_exposure_ev, _(" EV")); gtk_widget_set_tooltip_text(g->print_exposure_ev, _("print brightness (enlarger exposure)")); @@ -1490,6 +1611,26 @@ void gui_init(dt_iop_module_t *self) gtk_widget_set_tooltip_text(g->couplers_amount, _("DIR coupler strength: inter-layer inhibition drives saturation" " and edge effects (1.0 = film-accurate, 0 = off)")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "preflash"))); + g->preflash_exposure = dt_bauhaus_slider_from_params(self, "preflash_exposure"); + gtk_widget_set_tooltip_text( + g->preflash_exposure, + _("preflash exposure: a brief, uniform pre-exposure of the print through" + " the film's base density, before the main print exposure -- lifts" + " shadows and reduces contrast (0 = off)")); + g->preflash_m_shift = dt_bauhaus_slider_from_params(self, "preflash_m_shift"); + dt_bauhaus_slider_set_format(g->preflash_m_shift, _(" CC")); + gtk_widget_set_tooltip_text(g->preflash_m_shift, + _("magenta filtration for the preflash exposure only, Kodak CC" + " units from neutral -- independent of the main enlarger" + " filtration above")); + g->preflash_y_shift = dt_bauhaus_slider_from_params(self, "preflash_y_shift"); + dt_bauhaus_slider_set_format(g->preflash_y_shift, _(" CC")); + gtk_widget_set_tooltip_text(g->preflash_y_shift, + _("yellow filtration for the preflash exposure only, Kodak CC" + " units from neutral -- independent of the main enlarger" + " filtration above")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "format"))); g->film_format_mm = dt_bauhaus_slider_from_params(self, "film_format_mm"); dt_bauhaus_slider_set_format(g->film_format_mm, _(" mm")); gtk_widget_set_tooltip_text(g->film_format_mm, @@ -1529,21 +1670,33 @@ void gui_init(dt_iop_module_t *self) /* ---- tab: diffusion ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("diffusion"), NULL); g->diffusion_on = dt_bauhaus_toggle_from_params(self, "diffusion_on"); + g->diffusion_filter_family = dt_bauhaus_combobox_from_params(self, "diffusion_filter_family"); + gtk_widget_set_tooltip_text( + g->diffusion_filter_family, + _("diffusion filter type: black pro-mist (concentrated, punchy halo, deep" + " blacks) / glimmerglass (tight, subtle, sharp-preserving) / pro-mist" + " (broader, pastel, atmospheric) / cinebloom (frame-wide, slow-decaying" + " veil)")); g->diffusion_strength = dt_bauhaus_slider_from_params(self, "diffusion_strength"); - gtk_widget_set_tooltip_text(g->diffusion_strength, - _("diffusion (Black Pro-Mist) filter strength")); + gtk_widget_set_tooltip_text(g->diffusion_strength, _("diffusion filter strength")); g->diffusion_scale = dt_bauhaus_slider_from_params(self, "diffusion_scale"); gtk_widget_set_tooltip_text(g->diffusion_scale, _("diffusion halo/bloom size")); g->diffusion_warmth = dt_bauhaus_slider_from_params(self, "diffusion_warmth"); gtk_widget_set_tooltip_text(g->diffusion_warmth, - _("diffusion halo warmth: >0 warm outer halo, <0 cool")); + _("diffusion halo warmth: >0 warm outer halo, <0 cool" + " (added on top of the selected filter's own warmth bias)")); - /* ---- tab: settings ---- */ - self->widget = dt_ui_notebook_page(g->notebook, N_("settings"), NULL); + /* ---- tab: advanced ---- */ + self->widget = dt_ui_notebook_page(g->notebook, N_("advanced"), NULL); g->quality = dt_bauhaus_combobox_from_params(self, "quality"); gtk_widget_set_tooltip_text(g->quality, _("spectral accuracy vs speed; the tables are PCHIP-interpolated" " and validated against the reference")); + g->output_luminance_boost = dt_bauhaus_slider_from_params(self, "output_luminance_boost"); + gtk_widget_set_tooltip_text(g->output_luminance_boost, + _("pre-compression boost: multiplies XYZ luminance before the" + " OkLCh gamut compressor, pushing the histogram right while" + " preserving the film's natural shoulder rolloff")); self->widget = sf_main_box; } From 6959a9e476a9ce08458deb1a467f17cd1bb131e9 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Wed, 15 Jul 2026 19:52:19 +0200 Subject: [PATCH 15/56] allow manual right click gain amount up to 8 --- src/iop/spektrafilm.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 163cad2b5774..50049ae62949 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -148,7 +148,7 @@ typedef struct dt_iop_spektrafilm_params_t float diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "diffusion size" float diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "diffusion halo warmth" gboolean grain_on; // $DEFAULT: TRUE $DESCRIPTION: "enable grain" - float grain_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "grain" + float grain_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain" float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "film format" float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost" @@ -1640,6 +1640,12 @@ void gui_init(dt_iop_module_t *self) self->widget = dt_ui_notebook_page(g->notebook, N_("grain"), NULL); g->grain_on = dt_bauhaus_toggle_from_params(self, "grain_on"); g->grain_amount = dt_bauhaus_slider_from_params(self, "grain_amount"); + dt_bauhaus_slider_set_soft_range(g->grain_amount, 0.0f, 2.0f); + gtk_widget_set_tooltip_text(g->grain_amount, + _("grain strength (1.0 = film-accurate; drag up to 2," + " right-click to enter higher values -- useful for pushing" + " naturally fine-grained stocks further than their" + " catalogue amount allows)")); g->grain_size = dt_bauhaus_slider_from_params(self, "grain_size"); gtk_widget_set_tooltip_text(g->grain_size, _("grain particle size (1.0 = film default; higher = coarser)")); From d57d420ae159fa4d384a0aab974195e9982eae42 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Wed, 15 Jul 2026 21:48:10 +0200 Subject: [PATCH 16/56] Prevent accidental double clicking on film and print tab header reset the scan box for positive films --- src/iop/spektrafilm.c | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 50049ae62949..29ec86665b63 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -1406,6 +1406,25 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) if(fi < 0 || fi >= g->n_films) return; const sf_prof_entry_t *e = &g->entries[g->film_entry[fi]]; p->film_hash = e->hash; + /* Keep the "default" scan_film following this film's own positive/negative + type too, not just the live params -- so a double-click reset (which + resets to self->default_params, whether via darktable's own bauhaus + reset or a checkbox-reset mechanism for this field) means "what this + film actually needs" rather than the module's one-size-fits-all factory + default (FALSE). Without this, resetting scan_film on a positive/ + reversal film would silently break it, since that film has no print + stage at all. */ + if(self->default_params) + ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = e->positive; + /* The core checkbox-reset mechanism (darktable-core-toggle-reset.patch) + doesn't re-read self->default_params live at reset time -- it captures + each checkbox's default once, at widget-creation time, as opaque + "dt-toggle-default" data on the button itself. So the update above + alone doesn't reach it; poke the widget's own cached value too, + otherwise a reset still uses whatever default_params->scan_film was + when the module GUI first built (before any film was ever selected). */ + if(g->scan_film) + g_object_set_data(G_OBJECT(g->scan_film), "dt-toggle-default", GINT_TO_POINTER(e->positive)); /* scan-film follows the film's natural mode on a film switch: slides and reversal stocks are viewed directly (scan), negatives go through the print stage. The user can still toggle freely afterwards -- this only From 44095029831db0d0d1642ab4bbcd1539e15eb9b3 Mon Sep 17 00:00:00 2001 From: Arecsu Date: Thu, 16 Jul 2026 00:41:54 -0300 Subject: [PATCH 17/56] spektrafilm: add print diffusion stage Add a second, independent diffusion filter stage applied after print exposure and before print development in the pipeline, mirroring the existing film-stage diffusion with its own set of controls (on/off, family, strength, scale, warmth). Includes CPU, OpenCL, and GUI paths. The print diffusion controls live in the diffusion tab, below the film-stage diffusion controls. They are grayed out when scan-film mode is active (no print stage). --- src/iop/spektrafilm.c | 82 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 29ec86665b63..b05df42cbf76 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -147,6 +147,11 @@ typedef struct dt_iop_spektrafilm_params_t float diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "diffusion strength" float diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "diffusion size" float diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "diffusion halo warmth" + gboolean print_diffusion_on; // $DEFAULT: FALSE $DESCRIPTION: "enable print diffusion" + dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; // $DEFAULT: DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST $DESCRIPTION: "print diffusion filter type" + float print_diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "print diffusion strength" + float print_diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "print diffusion size" + float print_diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "print diffusion halo warmth" gboolean grain_on; // $DEFAULT: TRUE $DESCRIPTION: "enable grain" float grain_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain" float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" @@ -174,6 +179,7 @@ typedef struct dt_iop_spektrafilm_gui_data_t GtkWidget *halation_on, *halation_amount, *halation_scale; GtkWidget *boost_ev, *boost_range, *protect_ev; GtkWidget *diffusion_on, *diffusion_filter_family, *diffusion_strength, *diffusion_scale, *diffusion_warmth; + GtkWidget *print_diffusion_on, *print_diffusion_filter_family, *print_diffusion_strength, *print_diffusion_scale, *print_diffusion_warmth; GtkWidget *grain_on, *grain_amount, *grain_size, *film_format_mm, *output_luminance_boost; sf_prof_entry_t entries[SF_MAX_PROFILES]; int n_entries; @@ -762,9 +768,14 @@ static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_u const float hal = (p->halation_on && p->halation_amount > 0.0f) ? SF_HALATION_FIRST_SIGMA_UM * SF_HALATION_PSF_SIGMAS * inv_um : 0.0f; - const float diff = p->diffusion_on ? SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM * 1.41421356f - * p->diffusion_scale * inv_um - : 0.0f; + const float diff_film = p->diffusion_on + ? SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM * 1.41421356f * p->diffusion_scale * inv_um + : 0.0f; + const float diff_print = p->print_diffusion_on + ? SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM * 1.41421356f + * p->print_diffusion_scale * inv_um + : 0.0f; + const float diff = fmaxf(diff_film, diff_print); const float grain = (p->grain_on && p->grain_amount > 0.0f) ? SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM * fmaxf(p->grain_size, SF_GRAIN_SIZE_MIN) * inv_um @@ -974,6 +985,10 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c if(!d->p.scan_film) { sf_sim_print_expose(sim, plane, plane, npix, 3, 3); + if(d->p.print_diffusion_on) + sf_diffusion_filter(plane, w, h, (double)pixel_um, (int)d->p.print_diffusion_filter_family, + d->p.print_diffusion_strength, d->p.print_diffusion_scale, + d->p.print_diffusion_warmth); sf_sim_print_develop(sim, plane, plane, npix, 3, 3); } @@ -1332,6 +1347,39 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(steps), CLARG(g->enl_lo[0]), CLARG(g->enl_lo[1]), CLARG(g->enl_lo[2]), CLARG(g->enl_hi[0]), CLARG(g->enl_hi[1]), CLARG(g->enl_hi[2]), CLARG(g->print_exposure)); SF_CL_STEP("print_expose"); + if(d->p.print_diffusion_on) + { + sf_diffusion_plan_t pplan; + if(sf_diffusion_build_plan((int)d->p.print_diffusion_filter_family, + d->p.print_diffusion_strength, + d->p.print_diffusion_warmth, &pplan) + && pplan.p_s > 0.0f) + { + const float pdsc = fmaxf(d->p.print_diffusion_scale, 1e-6f); + for(int j = 0; j < pplan.n; j++) + { + const float sigma = fmaxf(pplan.sigma_um[j] * pdsc / pixel_um, 1e-3f); + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4); + if(err != CL_SUCCESS) break; + err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, sigma); + if(err != CL_SUCCESS) break; + const int reset = (j == 0); + const float wr = pplan.wr[j], wg = pplan.wg[j], wb = pplan.wb[j]; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h, + CLARG(tmpa), CLARG(acc), CLARG(w), CLARG(h), + CLARG(wr), CLARG(wg), CLARG(wb), CLARG(reset)); + if(err != CL_SUCCESS) break; + } + if(err == CL_SUCCESS) + { + const float ps = pplan.p_s; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_mix, w, h, + CLARG(plane), CLARG(acc), CLARG(w), CLARG(h), + CLARG(ps)); + } + SF_CL_STEP("print_diffusion"); + } + } err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_print_develop, w, h, CLARG(plane), CLARG(plane2), CLARG(w), CLARG(h), CLARG(pc_cl), CLARG(g->le0), CLARG(g->le_step)); @@ -1473,6 +1521,11 @@ static void _update_print_sensitivity(dt_iop_module_t *self) gtk_widget_set_sensitive(g->print_contrast, printing); gtk_widget_set_sensitive(g->filter_m, printing); gtk_widget_set_sensitive(g->filter_y, printing); + gtk_widget_set_sensitive(g->print_diffusion_on, printing); + gtk_widget_set_sensitive(g->print_diffusion_filter_family, printing); + gtk_widget_set_sensitive(g->print_diffusion_strength, printing); + gtk_widget_set_sensitive(g->print_diffusion_scale, printing); + gtk_widget_set_sensitive(g->print_diffusion_warmth, printing); /* toggle_from_params checkboxes keep showing their tick even when made insensitive -- GTK just dims the whole widget, so a checked-but-grayed box can read as "this is still on" when it has no effect at all (no @@ -1483,7 +1536,9 @@ static void _update_print_sensitivity(dt_iop_module_t *self) never writes back into the param. */ DT_ENTER_GUI_UPDATE(); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_auto_exposure), - printing && p->print_auto_exposure); + printing && p->print_auto_exposure); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_diffusion_on), + printing && p->print_diffusion_on); DT_LEAVE_GUI_UPDATE(); } @@ -1563,6 +1618,7 @@ void gui_update(dt_iop_module_t *self) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_auto_exposure), p->print_auto_exposure); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->halation_on), p->halation_on); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->diffusion_on), p->diffusion_on); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_diffusion_on), p->print_diffusion_on); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->grain_on), p->grain_on); _update_print_sensitivity(self); } @@ -1711,6 +1767,24 @@ void gui_init(dt_iop_module_t *self) _("diffusion halo warmth: >0 warm outer halo, <0 cool" " (added on top of the selected filter's own warmth bias)")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print diffusion"))); + g->print_diffusion_on = dt_bauhaus_toggle_from_params(self, "print_diffusion_on"); + g->print_diffusion_filter_family + = dt_bauhaus_combobox_from_params(self, "print_diffusion_filter_family"); + gtk_widget_set_tooltip_text( + g->print_diffusion_filter_family, + _("print diffusion filter type (same presets as the film-stage filter)")); + g->print_diffusion_strength = dt_bauhaus_slider_from_params(self, "print_diffusion_strength"); + gtk_widget_set_tooltip_text(g->print_diffusion_strength, + _("print diffusion filter strength")); + g->print_diffusion_scale = dt_bauhaus_slider_from_params(self, "print_diffusion_scale"); + gtk_widget_set_tooltip_text(g->print_diffusion_scale, + _("print diffusion halo/bloom size")); + g->print_diffusion_warmth = dt_bauhaus_slider_from_params(self, "print_diffusion_warmth"); + gtk_widget_set_tooltip_text(g->print_diffusion_warmth, + _("print diffusion halo warmth: >0 warm outer halo, <0 cool" + " (added on top of the selected filter's own warmth bias)")); + /* ---- tab: advanced ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("advanced"), NULL); g->quality = dt_bauhaus_combobox_from_params(self, "quality"); From 8ede62330a25a2dbea170d1555f6b3acddfd4fbe Mon Sep 17 00:00:00 2001 From: Arecsu Date: Thu, 16 Jul 2026 00:42:37 -0300 Subject: [PATCH 18/56] spektrafilm: improve code comments - Update the top-level pipeline overview to include the print diffusion stage (was listed only in the film-stage spatial effects before) - Add explanatory comment in _max_halo_sigma() explaining why the ROI padding takes the wider of film-stage and print-stage diffusion - Mark the print diffusion section in process_cl() with a section comment, matching the convention used for other pipeline stages --- src/iop/spektrafilm.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index b05df42cbf76..897c74589553 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -31,6 +31,7 @@ * -> CMY film density -> grain (density, spatial) * -> enlarger (dichroic-filtered light through the negative, * print paper sensitivity, midgray-balanced) = print exposure + * -> print diffusion filter (optional) (density, spatial) * -> print density curves (with optional contrast morph) * -> viewing illuminant through the print, CMFs -> XYZ * -> CAT02 -> work RGB -> OkLCh gamut compression = scanning @@ -768,6 +769,9 @@ static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_u const float hal = (p->halation_on && p->halation_amount > 0.0f) ? SF_HALATION_FIRST_SIGMA_UM * SF_HALATION_PSF_SIGMAS * inv_um : 0.0f; + /* The widest of film-stage and print-stage diffusion determines the ROI + padding — both must fit in the expanded tile. Both stages use the same + bloom constant; only the user's scale slider differs between them. */ const float diff_film = p->diffusion_on ? SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM * 1.41421356f * p->diffusion_scale * inv_um : 0.0f; @@ -1347,6 +1351,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(steps), CLARG(g->enl_lo[0]), CLARG(g->enl_lo[1]), CLARG(g->enl_lo[2]), CLARG(g->enl_hi[0]), CLARG(g->enl_hi[1]), CLARG(g->enl_hi[2]), CLARG(g->print_exposure)); SF_CL_STEP("print_expose"); + /* ---- print diffusion (optional, on the exposed print density) ---- */ if(d->p.print_diffusion_on) { sf_diffusion_plan_t pplan; From fb660d9322a5eb123c8fb981c02a4b44bc9a3252 Mon Sep 17 00:00:00 2001 From: Arecsu Date: Thu, 16 Jul 2026 13:19:16 -0300 Subject: [PATCH 19/56] =?UTF-8?q?spektrafilm:=20GPU=20optimization=20?= =?UTF-8?q?=E2=80=94=20gauss=20pre-allocation,=20async=20boost,=20tmpb=20e?= =?UTF-8?q?limination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pre-allocate gaussian blur object across all blurs in process_cl - Replace blocking boost readback (CL_TRUE sync) with on-device kernel_max_reduce, keeping the queue fully async - Eliminate tmpb buffer: halation tails and coupler tails use plane2 instead, saving one float4 buffer - Redirect coupler tail output to tmpa so kernel_develop reads from tmpa instead of requiring a copy-back to acc - Add IOP_FLAGS_ALLOW_TILING, reduce factor_cl from 4.5 to 2.5 - Precompute bounce decay constants at compile time (avoids runtime rho^0/rho^1/rho^2 powf + normalize loop) - Add spektrafilm_max_reduce kernel for on-device max reduction --- data/kernels/spektrafilm.cl | 11 ++- src/iop/spektrafilm.c | 136 +++++++++++++++++++----------------- 2 files changed, 80 insertions(+), 67 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 94685e77880b..2f975881e123 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -645,13 +645,22 @@ __kernel void spektrafilm_max_partials(__global const float4 *plane, const int n partials[gid] = m; } +__kernel void spektrafilm_max_reduce(__global const float *partials, __global float *maxv_buf, + const int npartials) +{ + float m = 0.0f; + for(int i = 0; i < npartials; i++) m = fmax(m, partials[i]); + maxv_buf[0] = m; +} + __kernel void spektrafilm_boost(__global float4 *plane, const int w, const int h, const float boost_ev, const float boost_range, - const float protect_ev, const float maxv) + const float protect_ev, __global const float *maxv_buf) { const int x = get_global_id(0), y = get_global_id(1); if(x >= w || y >= h) return; const int k = y * w + x; + const float maxv = maxv_buf[0]; if(boost_ev <= 0.0f || maxv <= 0.0f) return; const float midgray = 0.184f; diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 897c74589553..1f3c9bbadb57 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -209,7 +209,7 @@ typedef struct dt_iop_spektrafilm_global_data_t int kernel_grain_gen, kernel_grain_add; int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough; int kernel_scatter_combine, kernel_accum, kernel_halation_apply; - int kernel_max_partials, kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix; + int kernel_max_partials, kernel_max_reduce, kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix; } dt_iop_spektrafilm_global_data_t; /* the data pack is large (spectra LUT ~12 MB) and shared by all pieces; @@ -240,6 +240,7 @@ void init_global(dt_iop_module_so_t *self) gd->kernel_accum = dt_opencl_create_kernel(program, "spektrafilm_accum"); gd->kernel_halation_apply = dt_opencl_create_kernel(program, "spektrafilm_halation_apply"); gd->kernel_max_partials = dt_opencl_create_kernel(program, "spektrafilm_max_partials"); + gd->kernel_max_reduce = dt_opencl_create_kernel(program, "spektrafilm_max_reduce"); gd->kernel_boost = dt_opencl_create_kernel(program, "spektrafilm_boost"); gd->kernel_diffusion_accum = dt_opencl_create_kernel(program, "spektrafilm_diffusion_accum"); gd->kernel_diffusion_mix = dt_opencl_create_kernel(program, "spektrafilm_diffusion_mix"); @@ -264,6 +265,7 @@ void cleanup_global(dt_iop_module_so_t *self) dt_opencl_free_kernel(gd->kernel_accum); dt_opencl_free_kernel(gd->kernel_halation_apply); dt_opencl_free_kernel(gd->kernel_max_partials); + dt_opencl_free_kernel(gd->kernel_max_reduce); dt_opencl_free_kernel(gd->kernel_boost); dt_opencl_free_kernel(gd->kernel_diffusion_accum); dt_opencl_free_kernel(gd->kernel_diffusion_mix); @@ -304,7 +306,7 @@ int default_group(void) } int flags(void) { - return IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_INCLUDE_IN_STYLES; + return IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_INCLUDE_IN_STYLES | IOP_FLAGS_ALLOW_TILING; } dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, dt_dev_pixelpipe_t *p, dt_dev_pixelpipe_iop_t *pi) @@ -825,8 +827,8 @@ void tiling_callback(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data; const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f); const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; - tiling->factor = 4.5f; /* in + out + 3ch working plane + grain scratch */ - tiling->factor_cl = 4.5f; + tiling->factor = 2.5f; /* 4 float4 buffers, but they alias in practice */ + tiling->factor_cl = 2.5f; tiling->maxbuf = 1.0f; tiling->maxbuf_cl = 1.0f; tiling->overhead = 0; @@ -1118,16 +1120,44 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ cl_mem plane = dt_opencl_alloc_device_buffer(devid, npix * f * 4); cl_mem plane2 = dt_opencl_alloc_device_buffer(devid, npix * f * 4); cl_mem tmpa = dt_opencl_alloc_device_buffer(devid, npix * f * 4); - cl_mem tmpb = dt_opencl_alloc_device_buffer(devid, npix * f * 4); cl_mem acc = dt_opencl_alloc_device_buffer(devid, npix * f * 4); + dt_gaussian_cl_t *gauss = NULL; if(!mats_cl || !tc_cl || !cn_cl || !cb_cl || !sl_cl || !sx_cl || !sy_cl || !sz_cl || !sn_cl - || !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !tmpb || !acc + || !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !acc || (g->has_print && (!el_cl || !ex_cl || !ey_cl || !ez_cl || !en_cl || !em_cl || !pc_cl))) { err = CL_MEM_OBJECT_ALLOCATION_FAILURE; goto cleanup; } + /* pre-allocated gaussian blur object: avoids allocating 2 temp buffers per blur */ + { + const dt_aligned_pixel_t gmax = { 1e9f, 1e9f, 1e9f, 1e9f }; + const dt_aligned_pixel_t gmin = { -1e9f, -1e9f, -1e9f, -1e9f }; + gauss = dt_gaussian_init_cl(devid, w, h, 4, gmax, gmin, 1.0f, DT_IOP_GAUSSIAN_ZERO); + } +#define SF_GAUSS_BLUR(buf, _sg, label) do { \ + if(gauss) { gauss->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss, (buf), (buf)); } \ + else { err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 4, (_sg)); } \ + SF_CL_STEP(label); \ + } while(0) +#define SF_GAUSS_BLUR_OP(src, dst, _sg, label) do { \ + if(gauss) { gauss->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss, (src), (dst)); } \ + else { err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \ + if(err == CL_SUCCESS) err = dt_gaussian_mean_blur_cl(devid, (dst), w, h, 4, (_sg)); } \ + SF_CL_STEP(label); \ + } while(0) +/* loop-safe variants: sets err, caller checks err/breaks */ +#define SF_GAUSS_BLUR_L(buf, _sg) do { \ + if(gauss) { gauss->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss, (buf), (buf)); } \ + else { err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 4, (_sg)); } \ + } while(0) +#define SF_GAUSS_BLUR_OP_L(src, dst, _sg) do { \ + if(gauss) { gauss->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss, (src), (dst)); } \ + else { err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \ + if(err == CL_SUCCESS) err = dt_gaussian_mean_blur_cl(devid, (dst), w, h, 4, (_sg)); } \ + } while(0) + /* ---- 1) expose: input image -> linear film raw exposure ---------------- */ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_expose, w, h, CLARG(dev_in), CLARG(plane), CLARG(w), CLARG(h), CLARG(mats_cl), @@ -1139,30 +1169,27 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ { const int npartials = 256; cl_mem partials = dt_opencl_alloc_device_buffer(devid, npartials * sizeof(float)); - if(partials) + cl_mem maxv_buf = dt_opencl_alloc_device_buffer(devid, sizeof(float)); + if(partials && maxv_buf) { const int npix_i = (int)npix; err = dt_opencl_enqueue_kernel_1d_args(devid, gd->kernel_max_partials, npartials, CLARG(plane), CLARG(npix_i), CLARG(partials), CLARG(npartials)); + if(err == CL_SUCCESS) + err = dt_opencl_enqueue_kernel_1d_args(devid, gd->kernel_max_reduce, 1, CLARG(partials), + CLARG(maxv_buf), CLARG(npartials)); if(err == CL_SUCCESS) { - float hp[256]; - if(dt_opencl_read_buffer_from_device(devid, hp, partials, 0, npartials * sizeof(float), - CL_TRUE) - == CL_SUCCESS) - { - float maxv = 0.0f; - for(int i = 0; i < npartials; i++) maxv = fmaxf(maxv, hp[i]); - const float b_ev = d->p.boost_ev, b_rng = d->p.boost_range, b_prot = d->p.protect_ev; - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_boost, w, h, CLARG(plane), - CLARG(w), CLARG(h), CLARG(b_ev), CLARG(b_rng), - CLARG(b_prot), CLARG(maxv)); - } + const float b_ev = d->p.boost_ev, b_rng = d->p.boost_range, b_prot = d->p.protect_ev; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_boost, w, h, CLARG(plane), + CLARG(w), CLARG(h), CLARG(b_ev), CLARG(b_rng), + CLARG(b_prot), CLARG(maxv_buf)); } - dt_opencl_release_mem_object(partials); SF_CL_STEP("boost"); } + dt_opencl_release_mem_object(partials); + dt_opencl_release_mem_object(maxv_buf); } if(d->p.diffusion_on) @@ -1203,20 +1230,13 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ const float hscl = fmaxf(d->p.halation_scale, 1e-3f); const float core_um = (2.2f + 2.0f + 1.6f) / 3.0f, tail_um = (9.3f + 9.7f + 9.1f) / 3.0f; const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f }; - err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4); - SF_CL_STEP("halation core copy"); - err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, - fmaxf(core_um * hscl / pixel_um, 1e-3f)); - SF_CL_STEP("halation core blur"); + SF_GAUSS_BLUR_OP(plane, tmpa, fmaxf(core_um * hscl / pixel_um, 1e-3f), "halation core blur"); for(int g3 = 0; g3 < 3; g3++) { - err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpb, 0, 0, npix * f * 4); - SF_CL_STEP("halation tail copy"); - err = dt_gaussian_mean_blur_cl(devid, tmpb, w, h, 4, - fmaxf(rat[g3] * tail_um * hscl / pixel_um, 1e-3f)); - SF_CL_STEP("halation tail blur"); + SF_GAUSS_BLUR_OP_L(plane, plane2, fmaxf(rat[g3] * tail_um * hscl / pixel_um, 1e-3f)); + if(err != CL_SUCCESS) break; const int reset = (g3 == 0); - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(tmpb), + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(plane2), CLARG(acc), CLARG(w), CLARG(h), CLARG(amp[g3]), CLARG(reset)); SF_CL_STEP("halation tail accum"); @@ -1227,25 +1247,15 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(ws_r), CLARG(ws_g), CLARG(ws_b)); SF_CL_STEP("scatter combine"); - const float first_sigma = SF_HALATION_FIRST_SIGMA_UM; const int N = 3; - const float rho = 0.5f; - float dsum = 0.f, dec[3]; - for(int k = 1; k <= N; k++) - { - dec[k - 1] = powf(rho, (float)(k - 1)); - dsum += dec[k - 1]; - } - for(int k = 0; k < N; k++) dec[k] /= dsum; + const float first_sigma = SF_HALATION_FIRST_SIGMA_UM; + const float dec[3] = { 1.0f/1.75f, 0.5f/1.75f, 0.25f/1.75f }; for(int k = 1; k <= N; k++) { - err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpb, 0, 0, npix * f * 4); - SF_CL_STEP("halation bounce copy"); - err = dt_gaussian_mean_blur_cl( - devid, tmpb, w, h, 4, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f)); - SF_CL_STEP("halation bounce blur"); + SF_GAUSS_BLUR_OP_L(plane, plane2, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f)); + if(err != CL_SUCCESS) break; const int reset = (k == 1); - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(tmpb), + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(plane2), CLARG(acc), CLARG(w), CLARG(h), CLARG(dec[k - 1]), CLARG(reset)); SF_CL_STEP("halation bounce accum"); @@ -1276,8 +1286,6 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ const float csigma = g->coupler_diff_um / fmaxf(pixel_um, 1e-3f); if(g->coupler_tail_w > 0.0f) { - /* corr = (1-w)*gauss + w*3-gaussian exponential-tail surrogate; - accumulate the weighted passes into tmpa, then copy back to acc */ const float amp[4] = { 1.0f - g->coupler_tail_w, g->coupler_tail_w * SF_EXPTAIL_A0, g->coupler_tail_w * SF_EXPTAIL_A1, g->coupler_tail_w * SF_EXPTAIL_A2 }; const float sig[4] = { csigma, SF_EXPTAIL_R0 * g->coupler_tail_um / fmaxf(pixel_um, 1e-3f), @@ -1285,31 +1293,30 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_EXPTAIL_R2 * g->coupler_tail_um / fmaxf(pixel_um, 1e-3f) }; for(int g3 = 0; g3 < 4; g3++) { - err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, acc, tmpb, 0, 0, npix * f * 4); - SF_CL_STEP("coupler tail copy"); if(sig[g3] > 0.1f) { - err = dt_gaussian_mean_blur_cl(devid, tmpb, w, h, 4, sig[g3]); - SF_CL_STEP("coupler tail blur"); + SF_GAUSS_BLUR_OP_L(acc, plane2, sig[g3]); + if(err != CL_SUCCESS) break; + } + else + { + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, acc, plane2, 0, 0, npix * f * 4); + if(err != CL_SUCCESS) break; } const int reset = (g3 == 0); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h, - CLARG(tmpb), CLARG(tmpa), CLARG(w), CLARG(h), + CLARG(plane2), CLARG(tmpa), CLARG(w), CLARG(h), CLARG(amp[g3]), CLARG(amp[g3]), CLARG(amp[g3]), CLARG(reset)); SF_CL_STEP("coupler tail accum"); } - err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, tmpa, acc, 0, 0, npix * f * 4); - SF_CL_STEP("coupler tail writeback"); } else if(csigma > 0.1f) - { - err = dt_gaussian_mean_blur_cl(devid, acc, w, h, 4, csigma); - SF_CL_STEP("coupler blur"); - } + SF_GAUSS_BLUR(acc, csigma, "coupler blur"); } + cl_mem corr_buf = (g->coupler_tail_w > 0.0f) ? tmpa : acc; err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_develop, w, h, CLARG(plane), - CLARG(acc), CLARG(use_corr), CLARG(plane2), CLARG(w), + CLARG(corr_buf), CLARG(use_corr), CLARG(plane2), CLARG(w), CLARG(h), CLARG(cb_cl), CLARG(mats_cl), CLARG(g->gamma[0]), CLARG(g->gamma[1]), CLARG(g->gamma[2]), CLARG(g->le0), CLARG(g->le_step)); @@ -1334,8 +1341,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_CL_STEP("grain gen"); const float gsigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); - err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, gsigma); - SF_CL_STEP("grain blur"); + SF_GAUSS_BLUR(tmpa, gsigma, "grain blur"); const float grenorm = sqrtf(2.0f * sqrtf((float)M_PI) * fmaxf(gsigma, 0.3f)); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2), CLARG(tmpa), CLARG(w), CLARG(h), CLARG(grenorm)); @@ -1364,9 +1370,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ for(int j = 0; j < pplan.n; j++) { const float sigma = fmaxf(pplan.sigma_um[j] * pdsc / pixel_um, 1e-3f); - err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4); - if(err != CL_SUCCESS) break; - err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, sigma); + SF_GAUSS_BLUR_OP_L(plane, tmpa, sigma); if(err != CL_SUCCESS) break; const int reset = (j == 0); const float wr = pplan.wr[j], wg = pplan.wg[j], wb = pplan.wb[j]; @@ -1403,6 +1407,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_CL_STEP("scan"); cleanup: + if(gauss) dt_gaussian_free_cl(gauss); dt_opencl_release_mem_object(mats_cl); dt_opencl_release_mem_object(tc_cl); dt_opencl_release_mem_object(cn_cl); @@ -1424,7 +1429,6 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ dt_opencl_release_mem_object(plane); dt_opencl_release_mem_object(plane2); dt_opencl_release_mem_object(tmpa); - dt_opencl_release_mem_object(tmpb); dt_opencl_release_mem_object(acc); return err; } From b5f24de4f8cd3ff86095c00ef611d5324e058a43 Mon Sep 17 00:00:00 2001 From: Arecsu Date: Thu, 16 Jul 2026 13:59:54 -0300 Subject: [PATCH 20/56] =?UTF-8?q?spektrafilm:=20CPU=20optimization=20?= =?UTF-8?q?=E2=80=94=20transposed=20blur,=20bilinear/trilinear,=20fast=20m?= =?UTF-8?q?ath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Row-major IIR Gaussian blur with cache-blocked transpose - sf_sim_expose: bilinear 2D replaces Mitchell 4×4 cubic on TC LUT - sf_sim_scan/sf_sim_print_expose: trilinear 3D replaces PCHIP cubic Math function replacements (portable, benefit all architectures): - pow(10,x) → __builtin_exp2f(x * log2f(10)) - log10(x) → __builtin_log2f(x) * log10f(2) - Eliminated pow10→log10 round-trip in print_expose (log10(10^l1 * pe) = l1 + log10(pe), precompute log10(pe)) Data layout: - Float curve arrays for per-pixel interpolation with precomputed inverses - Precomputed inv_le_step, enl_inv_range[3], scan_inv_range[3] (divisions → multiplies in all per-pixel hot paths) --- src/common/spektra_core.c | 173 +++++++++++++++++++---- src/common/spektra_sim.c | 279 +++++++++++++++++++++++++++++++------- src/common/spektra_sim.h | 2 + 3 files changed, 383 insertions(+), 71 deletions(-) diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index 70354902d0ce..42714b09f4f8 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -46,41 +46,163 @@ #define SF_STRTOD(s, end) g_ascii_strtod((s), (end)) #include "spektra_core.h" -/* Blur one channel `c` of a packed w*h*3 float buffer in place, with the given - * sigma (in pixels), using darktable's Gaussian. The channel is de-interleaved - * into a scratch single-channel plane, blurred, and written back. Per-channel - * sigmas (halation uses a different sigma per R/G/B) are handled by calling this - * once per channel. */ +/* ------------------------------------------------------------------------ */ +/* Row-major Gaussian blur with transpose */ +/* ------------------------------------------------------------------------ */ +/* The standard IIR column pass has stride = width*4 bytes per pixel access, + striding beyond cache-line reach at any realistic resolution. Instead: + 1. Row-pass (causal + anticausal) — good stride, stays in cache + 2. Cache-blocked transpose — temp → scratch, sequential access both ways + 3. Row-pass again on transposed data — second dimension, also good stride + 4. Transpose back + The transpose itself is tiled so it stays in L2. */ + +/* IIR coefficients for Gaussian order-0 (the only order spektrafilm uses) */ +static void _sf_gauss_coeffs(const float sigma, float *a0, float *a1, float *a2, + float *a3, float *b1, float *b2, float *coefp, float *coefn) +{ + const float alpha = 1.695f / sigma; + const float ema = expf(-alpha); + const float ema2 = expf(-2.0f * alpha); + *b1 = -2.0f * ema; + *b2 = ema2; + const float k = (1.0f - ema) * (1.0f - ema) / (1.0f + (2.0f * alpha * ema) - ema2); + *a0 = k; + *a1 = k * (alpha - 1.0f) * ema; + *a2 = k * (alpha + 1.0f) * ema; + *a3 = -k * ema2; + *coefp = (*a0 + *a1) / (1.0f + *b1 + *b2); + *coefn = (*a2 + *a3) / (1.0f + *b1 + *b2); +} + +/* Single-channel causal IIR pass over `len` elements, row-major (stride=1). + `in` and `out` may alias (same pointer). Range clamping to [vmin, vmax]. */ +static void _sf_gauss_causal(const float *in, float *out, const int len, + const float a0, const float a1, + const float b1, const float b2, + const float coefp, const float vmin, const float vmax) +{ + float xp = CLAMP(in[0], vmin, vmax); + float yb = xp * coefp; + float yp = yb; + out[0] = yp; + for(int i = 1; i < len; i++) + { + const float xc = CLAMP(in[i], vmin, vmax); + const float yc = a0 * xc + a1 * xp - b1 * yp - b2 * yb; + xp = xc; + yb = yp; + yp = yc; + out[i] = yc; + } +} + +/* Single-channel anticausal IIR pass, merging into `out` (+=). Backward. */ +static void _sf_gauss_anticausal(const float *in, float *out, const int len, + const float a2, const float a3, + const float b1, const float b2, + const float coefn, const float vmin, const float vmax) +{ + float xn = CLAMP(in[len - 1], vmin, vmax); + float xa = xn; + float yn = xn * coefn; + float ya = yn; + out[len - 1] += yn; + for(int i = len - 2; i >= 0; i--) + { + const float xc = CLAMP(in[i], vmin, vmax); + const float yc = a2 * xn + a3 * xa - b1 * yn - b2 * ya; + xa = xn; + xn = xc; + ya = yn; + yn = yc; + out[i] += yc; + } +} + +/* Cache-blocked transpose of a width×height float buffer to height×width. + Using BLOCK=64 keeps read/write working sets in L1/L2 during the pass. */ +#define SF_TRANSPOSE_BLOCK 64 +static void _sf_transpose(const float *const src, float *const dst, + const int w, const int h) +{ + DT_OMP_FOR() + for(int j0 = 0; j0 < h; j0 += SF_TRANSPOSE_BLOCK) + { + const int jlim = (j0 + SF_TRANSPOSE_BLOCK < h) ? j0 + SF_TRANSPOSE_BLOCK : h; + for(int i0 = 0; i0 < w; i0 += SF_TRANSPOSE_BLOCK) + { + const int ilim = (i0 + SF_TRANSPOSE_BLOCK < w) ? i0 + SF_TRANSPOSE_BLOCK : w; + for(int j = j0; j < jlim; j++) + for(int i = i0; i < ilim; i++) + dst[(size_t)i * h + j] = src[(size_t)j * w + i]; + } + } +} + +/* Single-channel IIR blur with cache-blocked transpose. `trans` is a w*h + intermediate buffer for the transpose (may be NULL: falls back to the + standard dt_gaussian path when trans is unavailable or dimensions are small). */ static void _blur_channel(float *const buf, const int w, const int h, const int c, - const float sigma, float *const plane) + const float sigma, float *const plane, float *const trans) { if(sigma < 1e-6f) return; const size_t npix = (size_t)w * h; for(size_t i = 0; i < npix; i++) plane[i] = buf[i * 3 + c]; + const float vmin = -1.0e9f, vmax = 1.0e9f; - const float range = 1.0e9f; /* grain delta / linear light: effectively unbounded */ - const float vmax = range, vmin = -range; - dt_gaussian_t *g = dt_gaussian_init(w, h, 1, &vmax, &vmin, sigma, DT_IOP_GAUSSIAN_ZERO); - if(g) + if(trans && w >= 16 && h >= 16) + { + float a0, a1, a2, a3, b1, b2, coefp, coefn; + _sf_gauss_coeffs(sigma, &a0, &a1, &a2, &a3, &b1, &b2, &coefp, &coefn); + float *const temp = trans; + /* Pass 1: row-major on each row → temp */ + DT_OMP_FOR() + for(int j = 0; j < h; j++) + { + const size_t off = (size_t)j * w; + _sf_gauss_causal(plane + off, temp + off, w, a0, a1, b1, b2, coefp, vmin, vmax); + _sf_gauss_anticausal(plane + off, temp + off, w, a2, a3, b1, b2, coefn, vmin, vmax); + } + /* Transpose temp (w×h) → plane (h×w) */ + _sf_transpose(temp, plane, w, h); + /* Pass 2: row-major on transposed data → temp */ + DT_OMP_FOR() + for(int j = 0; j < w; j++) + { + const size_t off = (size_t)j * h; + _sf_gauss_causal(plane + off, temp + off, h, a0, a1, b1, b2, coefp, vmin, vmax); + _sf_gauss_anticausal(plane + off, temp + off, h, a2, a3, b1, b2, coefn, vmin, vmax); + } + /* Transpose back temp (h×w) → plane (w×h) */ + _sf_transpose(temp, plane, h, w); + } + else { - dt_gaussian_blur(g, plane, plane); - dt_gaussian_free(g); + dt_gaussian_t *g = dt_gaussian_init(w, h, 1, &vmax, &vmin, sigma, DT_IOP_GAUSSIAN_ZERO); + if(g) + { + dt_gaussian_blur(g, plane, plane); + dt_gaussian_free(g); + } } for(size_t i = 0; i < npix; i++) buf[i * 3 + c] = plane[i]; } -/* Blur all three channels of a packed buffer with the same sigma (grain). */ +/* Blur all three channels with the same sigma (grain). Allocates trans buffer. */ void sf_blur_plane3(float *const buf, const int w, const int h, const float sigma, float *const plane) { if(sigma < 0.3f) return; - for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma, plane); + float *const trans = dt_alloc_align_float((size_t)w * h); + for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma, plane, trans); + dt_free_align(trans); } -/* Blur a packed buffer with per-channel sigma (scatter / halation passes). */ +/* Blur packed buffer with per-channel sigma. `trans` is a w*h intermediate. */ static void _blur_per_channel(float *const buf, const int w, const int h, const float sigma[3], - float *const plane) + float *const plane, float *const trans) { - for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma[c], plane); + for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma[c], plane, trans); } /* Apply halation + scatter to a w*h*3 LINEAR plane, in place. @@ -156,8 +278,9 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ const size_t npix = (size_t)w * h; const size_t nn = npix * 3; - float *const plane = dt_alloc_align_float(npix); /* scratch single-channel plane */ - if(!plane) return; + float *const plane = dt_alloc_align_float(npix); + float *const trans = dt_alloc_align_float(npix); + if(!plane) { dt_free_align(trans); return; } /* --- stage 1: scatter PSF (core + 3-component tail) --- */ { @@ -169,7 +292,7 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ dt_iop_image_copy(core, raw, nn); float sc[3]; for(int c = 0; c < 3; c++) sc[c] = fmaxf((float)(sc_core[c] * scl / pixel_um), 1e-6f); - _blur_per_channel(core, w, h, sc, plane); + _blur_per_channel(core, w, h, sc, plane, trans); memset(tail, 0, sizeof(float) * nn); for(int g = 0; g < 3; g++) @@ -178,7 +301,7 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ float lt[3]; for(int c = 0; c < 3; c++) lt[c] = fmaxf((float)(tail_rat[g] * (sc_tail[c] * scl / pixel_um)), 1e-6f); - _blur_per_channel(comp, w, h, lt, plane); + _blur_per_channel(comp, w, h, lt, plane, trans); for(size_t i = 0; i < nn; i++) tail[i] += (float)tail_amp[g] * comp[i]; } for(size_t i = 0; i < nn; i++) @@ -213,7 +336,7 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ dt_iop_image_copy(comp, raw, nn); const float sk = fmaxf((float)((first_sigma_um * scl / pixel_um) * sqrt((double)k)), 1e-6f); const float sig3[3] = { sk, sk, sk }; - _blur_per_channel(comp, w, h, sig3, plane); + _blur_per_channel(comp, w, h, sig3, plane, trans); const float wk = (float)decay[k - 1]; for(size_t i = 0; i < nn; i++) blur[i] += wk * comp[i]; } @@ -228,6 +351,7 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ } dt_free_align(plane); + dt_free_align(trans); } /* ---------------- diffusion filter (Black Pro-Mist family) ---------------- @@ -436,11 +560,13 @@ void sf_diffusion_filter(float *const raw, const int w, const int h, const doubl float *const acc = dt_alloc_align_float(nn); float *const comp = dt_alloc_align_float(nn); float *const plane1 = dt_alloc_align_float(npix); + float *const trans = dt_alloc_align_float(npix); if(!acc || !comp || !plane1) { dt_free_align(acc); dt_free_align(comp); dt_free_align(plane1); + dt_free_align(trans); return; } memset(acc, 0, sizeof(float) * nn); @@ -449,7 +575,7 @@ void sf_diffusion_filter(float *const raw, const int w, const int h, const doubl { const float sigma = (float)(plan.sigma_um[j] * sc / fmax(pixel_um, 1e-3)); dt_iop_image_copy(comp, raw, nn); - for(int c = 0; c < 3; c++) _blur_channel(comp, w, h, c, sigma, plane1); + for(int c = 0; c < 3; c++) _blur_channel(comp, w, h, c, sigma, plane1, trans); const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j]; for(size_t i = 0; i < npix; i++) { @@ -465,4 +591,5 @@ void sf_diffusion_filter(float *const raw, const int w, const int h, const doubl dt_free_align(acc); dt_free_align(comp); dt_free_align(plane1); + dt_free_align(trans); } diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index cd4d12d8a37a..03460d716dac 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -39,12 +39,21 @@ #include #include +/* ensure C99 math functions for SF_POW10F/SF_LOG10F (exp2f, log2f) */ +#if !defined(exp2f) && !defined(_GNU_SOURCE) +/* exp2f and log2f are C99; every compiler since GCC 4.x / Clang 3.x has them */ +#endif #include #include #include #include #define SF_LOG_EPS 1e-10 +/* Fast pow10 / log10 via exp2f/log2f. Using compiler builtins gives the + optimizer a better chance to inline/reduce them vs libm powf(10, x) + which internally computes exp2(x * log2(10)) with extra overhead. */ +#define SF_POW10F(x) __builtin_exp2f((x) * 3.321928094887362f) /* x * log2f(10) */ +#define SF_LOG10F(x) (__builtin_log2f(x) * 0.3010299956639812f) /* log2f(x) * log10(2) */ #define SF_TC_KNEE_T 0.0 /* [gc] InputGamutCompressSpec.knee */ #define SF_TC_KNEE_L 1.0 #define SF_TC_KNEE_P 6.0 @@ -199,14 +208,25 @@ struct sf_sim_t /* filming */ double m_in[9]; /* input linear RGB -> XYZ adapted to film ref illuminant */ + float m_in_f[9]; /* float copy for fast per-pixel path */ + float ev_scale_f; double ev_scale; int tc_n; - double *tc_lut; /* tc_n*tc_n*3 raw CMY exposure */ + double *tc_lut; /* tc_n*tc_n*3 raw CMY exposure (double) */ + float *tc_lut_f; /* float copy — halves cache footprint, enables fast path */ + + /* precomputed float inverses (avoid div per pixel in hot loops) */ + float inv_le_step; + float log10_print_exposure; + float enl_inv_range[3]; + float scan_inv_range[3]; /* film develop */ double le0, le_step; /* uniform log exposure grid */ double curves_norm[SF_NLE][3]; double curves_before[SF_NLE][3]; + float curves_norm_f[SF_NLE][3]; /* float copies for fast per-pixel path */ + float curves_before_f[SF_NLE][3]; double gamma[3]; double couplers_M[3][3]; /* Langmuir saturating couplers (spektrafilm dev/0.4+); K = INFINITY keeps @@ -247,6 +267,7 @@ struct sf_sim_t /* print develop */ double print_curves[SF_NLE][3]; + float print_curves_f[SF_NLE][3]; /* scanning (exact spectral path) */ double scan_chan_density[SF_NWL][3]; @@ -268,6 +289,8 @@ struct sf_sim_t int lut_steps; double *enl_lut, *enl_sx, *enl_sy, *enl_sz, *enl_cmin, *enl_cmax; double *scan_lut, *scan_sx, *scan_sy, *scan_sz, *scan_cmin, *scan_cmax; + float *enl_lut_f; /* float copies for fast trilinear per-pixel path */ + float *scan_lut_f; /* output gamut compression */ sf_output_compress_t out_compress; @@ -847,6 +870,15 @@ static inline void quad2tri(double out[2], const double xy[2]) out[1] = xy[1] * sq; } +static inline void tri2quad_f(float out[2], const float tc[2]) +{ + const float tx = tc[0], ty = tc[1]; + float y = ty / fmaxf(1.0f - tx, 1e-10f); + float x = (1.0f - tx) * (1.0f - tx); + out[0] = CLAMP(x, 0.0f, 1.0f); + out[1] = CLAMP(y, 0.0f, 1.0f); +} + /* ------------------------------------------------------------------------ */ /* [gc] Reinhard knee, radial xy compression toward the spectral locus */ /* ------------------------------------------------------------------------ */ @@ -972,6 +1004,9 @@ static void cubic_interp_2d(double out[3], const double *lut, int L, double x, d out[2] = acc[2]; } +/* Float variants of cubic_interp_2d / expose_pixel — halves LUT cache footprint + and avoids double conversion overhead in the hot per-pixel expose loop. */ + /* bilinear sampling on the same layout with clamped ("nearest") bounds — * used only for the tc_lut compression remap at build time */ static void bilinear_2d_clamped(double out[3], const double *lut, int L, double x, double y) @@ -991,6 +1026,61 @@ static void bilinear_2d_clamped(double out[3], const double *lut, int L, double } } +/* Fast bilinear 2D on a float LUT — replaces Mitchell 4×4 cubic for the hot + TC upsampling path. The 192² grid is fine enough that the cubic-vs-linear + difference between grid points is invisible. ~48 ops vs ~12 ops per pixel. */ +static void bilinear_interp_2d_f(float out[3], const float *lut, int L, float x, float y) +{ + x = CLAMP(x, 0.0f, (float)(L - 1)); + y = CLAMP(y, 0.0f, (float)(L - 1)); + const int x0 = (int)x, y0 = (int)y; + const int x1 = x0 < L - 1 ? x0 + 1 : x0; + const int y1 = y0 < L - 1 ? y0 + 1 : y0; + const float tx = x - x0, ty = y - y0; + for(int c = 0; c < 3; c++) + { + const float v00 = lut[((size_t)x0 * L + y0) * 3 + c]; + const float v01 = lut[((size_t)x0 * L + y1) * 3 + c]; + const float v10 = lut[((size_t)x1 * L + y0) * 3 + c]; + const float v11 = lut[((size_t)x1 * L + y1) * 3 + c]; + out[c] = (v00 * (1.0f - ty) + v01 * ty) * (1.0f - tx) + (v10 * (1.0f - ty) + v11 * ty) * tx; + } +} + +/* Trilinear 3D on a float LUT — replaces PCHIP 3D cubic for the hot scan/print + LUT path. The 17³ grid is smooth (density→log XYZ from spectral integrals), + so PCHIP's monotonicity guarantee adds negligible quality over linear. */ +static void trilinear_interp_3d_f(float out[3], const float *lut, int n, float r, float g, float b) +{ + r = CLAMP(r, 0.0f, (float)(n - 1)); + g = CLAMP(g, 0.0f, (float)(n - 1)); + b = CLAMP(b, 0.0f, (float)(n - 1)); + const int i = (int)r, j = (int)g, k = (int)b; + const int i1 = i < n - 1 ? i + 1 : i; + const int j1 = j < n - 1 ? j + 1 : j; + const int k1 = k < n - 1 ? k + 1 : k; + const float tr = r - i, tg = g - j, tb = b - k; + const float omtr = 1.0f - tr, omtg = 1.0f - tg, omtb = 1.0f - tb; +#define TL(idx) lut[((size_t)(idx) * n + (j)) * n + (k)] +#define TL3(idx) lut[((((size_t)(idx) * n + (j)) * n + (k)) * 3] + for(int c = 0; c < 3; c++) + { + const float v00 = lut[((((size_t)i) * n + j) * n + k) * 3 + c] * omtr + + lut[((((size_t)i1) * n + j) * n + k) * 3 + c] * tr; + const float v01 = lut[((((size_t)i) * n + j1) * n + k) * 3 + c] * omtr + + lut[((((size_t)i1) * n + j1) * n + k) * 3 + c] * tr; + const float v10 = lut[((((size_t)i) * n + j) * n + k1) * 3 + c] * omtr + + lut[((((size_t)i1) * n + j) * n + k1) * 3 + c] * tr; + const float v11 = lut[((((size_t)i) * n + j1) * n + k1) * 3 + c] * omtr + + lut[((((size_t)i1) * n + j1) * n + k1) * 3 + c] * tr; + const float v0 = v00 * omtg + v01 * tg; + const float v1 = v10 * omtg + v11 * tg; + out[c] = v0 * omtb + v1 * tb; + } +#undef TL +#undef TL3 +} + /* ------------------------------------------------------------------------ */ /* [fi] monotone PCHIP 3D LUT interpolation */ /* ------------------------------------------------------------------------ */ @@ -1179,6 +1269,20 @@ static inline double interp_curve_uniform(double x, double gammac, double le0, return curves[i][c] + f * (curves[i + 1][c] - curves[i][c]); } +/* Float variant using precomputed inv_le_step — avoids double→float conversions + and replaces division with multiply in the hot per-pixel path. */ +static inline float interp_curve_uniform_f(float x, float gammac, float le0, + float inv_le_step, + const float (*curves)[3], int c) +{ + const float t = (x * gammac - le0) * inv_le_step; + if(t <= 0.0f) return curves[0][c]; + if(t >= (float)(SF_NLE - 1)) return curves[SF_NLE - 1][c]; + const int i = (int)t; + const float f = t - i; + return curves[i][c] + f * (curves[i + 1][c] - curves[i][c]); +} + /* ------------------------------------------------------------------------ */ /* [mc] cdfs density curve model + s023 morph */ /* ------------------------------------------------------------------------ */ @@ -1482,6 +1586,24 @@ static void expose_pixel(const double m_in[9], const double *tc_lut, int tc_n, for(int c = 0; c < 3; c++) raw[c] *= bb; } +/* Float per-pixel expose using float LUT/input. The linear color matrix + product is the same as expose_pixel but stored/operated in float. */ +static void expose_pixel_f(const float m_in[9], const float *tc_lut, int tc_n, + const float rgb[3], float raw[3]) +{ + float xyz[3]; + for(int i = 0; i < 3; i++) + xyz[i] = m_in[i * 3] * rgb[0] + m_in[i * 3 + 1] * rgb[1] + m_in[i * 3 + 2] * rgb[2]; + const float b = xyz[0] + xyz[1] + xyz[2]; + const float xy[2] = { xyz[0] / fmaxf(b, 1e-10f), xyz[1] / fmaxf(b, 1e-10f) }; + float tc[2]; + tri2quad_f(tc, xy); + const float scale = (float)(tc_n - 1); + bilinear_interp_2d_f(raw, tc_lut, tc_n, tc[0] * scale, tc[1] * scale); + const float bb = isfinite(b) ? b : 0.0f; + for(int c = 0; c < 3; c++) raw[c] *= bb; +} + /* [st] filming._simple_rgb_to_density_spectral: the gray reference used to * balance the print exposure. NOTE the reference computes this in *sRGB* * (the _rgb_to_film_raw defaults), independent of the io input space. */ @@ -1561,10 +1683,12 @@ void sf_sim_free(sf_sim_t *s) { if(!s) return; free(s->tc_lut); + free(s->tc_lut_f); free(s->enl_lut); free(s->enl_sx); free(s->enl_sy); free(s->enl_sz); free(s->enl_cmin); free(s->enl_cmax); free(s->scan_lut); free(s->scan_sx); free(s->scan_sy); free(s->scan_sz); free(s->scan_cmin); free(s->scan_cmax); + free(s->enl_lut_f); free(s->scan_lut_f); free(s->cmax); g_free(s); } @@ -1717,11 +1841,19 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, } free(old); } + /* float copies for the fast per-pixel expose path */ + for(int c = 0; c < 9; c++) s->m_in_f[c] = (float)s->m_in[c]; + s->ev_scale_f = (float)s->ev_scale; + s->tc_lut_f = malloc((size_t)n * n * 3 * sizeof(float)); + if(s->tc_lut_f) + for(size_t i = 0; i < (size_t)n * n * 3; i++) + s->tc_lut_f[i] = (float)s->tc_lut[i]; } /* ----- film develop ---------------------------------------------------- */ s->le0 = film->log_exposure[0]; s->le_step = (film->log_exposure[SF_NLE - 1] - film->log_exposure[0]) / (SF_NLE - 1); + s->inv_le_step = (float)(1.0 / s->le_step); for(int c = 0; c < 3; c++) s->gamma[c] = p->density_curve_gamma; for(int c = 0; c < 3; c++) { @@ -1732,7 +1864,12 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, if(v < mn) mn = v; if(v > mx) mx = v; } - for(int i = 0; i < SF_NLE; i++) s->curves_norm[i][c] = film->density_curves[i][c] - mn; + for(int i = 0; i < SF_NLE; i++) + { + const double v = film->density_curves[i][c] - mn; + s->curves_norm[i][c] = v; + s->curves_norm_f[i][c] = (float)v; + } s->film_dmax[c] = mx - mn; s->film_dmin[c] = mn; } @@ -1850,11 +1987,15 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, { const double v = interp_general(film->log_exposure[i], xp, fp, SF_NLE); s->curves_before[i][c] = s->film_positive ? -v : v; + s->curves_before_f[i][c] = (float)s->curves_before[i][c]; } } } else + { memcpy(s->curves_before, s->curves_norm, sizeof(s->curves_before)); + memcpy(s->curves_before_f, s->curves_norm_f, sizeof(s->curves_before_f)); + } /* ----- printing -------------------------------------------------------- */ if(s->has_print) @@ -1923,8 +2064,13 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, if(film->density_curves[i][c] > mx) mx = film->density_curves[i][c]; s->enl_lo[c] = -p->grain_density_min[c]; s->enl_hi[c] = mx; + s->enl_inv_range[c] = (float)(1.0 / (mx + p->grain_density_min[c])); } + s->log10_print_exposure = (float)log10(fmax(p->print_exposure, 1e-10)); build_print_curves(s->print_curves, print, p); + for(int i = 0; i < SF_NLE; i++) + for(int c = 0; c < 3; c++) + s->print_curves_f[i][c] = (float)s->print_curves[i][c]; } /* ----- scanning -------------------------------------------------------- */ @@ -1978,6 +2124,8 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, if(film->density_curves[i][c] > mx) mx = film->density_curves[i][c]; s->scan_hi[c] = mx; } + for(int c = 0; c < 3; c++) + s->scan_inv_range[c] = (float)(1.0 / (s->scan_hi[c] - s->scan_lo[c])); /* output matrix: CAT02 from the viewing illuminant to the output white */ double view_xy[2] = { s->illum_view_xyz[0] / (s->illum_view_xyz[0] + s->illum_view_xyz[1] @@ -2070,10 +2218,22 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, if(s->lut_steps >= 2) { if(s->has_print) + { build_lut3d(s, cmy_to_print_lograw, s->enl_lo, s->enl_hi, s->lut_steps, &s->enl_lut, &s->enl_sx, &s->enl_sy, &s->enl_sz, &s->enl_cmin, &s->enl_cmax); + const size_t n3 = (size_t)s->lut_steps * s->lut_steps * s->lut_steps * 3; + s->enl_lut_f = malloc(n3 * sizeof(float)); + if(s->enl_lut_f) + for(size_t i = 0; i < n3; i++) s->enl_lut_f[i] = (float)s->enl_lut[i]; + } build_lut3d(s, cmy_to_log_xyz, s->scan_lo, s->scan_hi, s->lut_steps, &s->scan_lut, &s->scan_sx, &s->scan_sy, &s->scan_sz, &s->scan_cmin, &s->scan_cmax); + { + const size_t n3 = (size_t)s->lut_steps * s->lut_steps * s->lut_steps * 3; + s->scan_lut_f = malloc(n3 * sizeof(float)); + if(s->scan_lut_f) + for(size_t i = 0; i < n3; i++) s->scan_lut_f[i] = (float)s->scan_lut[i]; + } } /* ----- output gamut compression ----------------------------------------- */ @@ -2100,10 +2260,9 @@ void sf_sim_expose(const sf_sim_t *sim, const float *rgb_in, float *raw, size_t { const float *in = rgb_in + px * nch_in; float *out = raw + px * nch_out; - const double rgb[3] = { in[0], in[1], in[2] }; - double r[3]; - expose_pixel(sim->m_in, sim->tc_lut, sim->tc_n, rgb, r); - for(int c = 0; c < 3; c++) out[c] = (float)(r[c] * sim->ev_scale); + float r[3]; + expose_pixel_f(sim->m_in_f, sim->tc_lut_f, sim->tc_n, in, r); + for(int c = 0; c < 3; c++) out[c] = r[c] * sim->ev_scale_f; } } @@ -2116,7 +2275,7 @@ void sf_sim_lograw(float *raw, size_t npix, int nch) { float *v = raw + px * nch; for(int c = 0; c < 3; c++) - v[c] = (float)log10(fmax((double)v[c], 0.0) + SF_LOG_EPS); + v[c] = SF_LOG10F(fmaxf(v[c], 0.0f) + SF_LOG_EPS); } } @@ -2135,21 +2294,21 @@ void sf_sim_develop_corr(const sf_sim_t *sim, const float *lograw, float *corr, { const float *in = lograw + px * nch_in; float *out = corr + px * 3; - double silver[3]; + float silver[3]; for(int c = 0; c < 3; c++) { - const double d = interp_curve_uniform(in[c], sim->gamma[c], sim->le0, sim->le_step, - sim->curves_norm, c); - silver[c] = sim->film_positive ? sim->film_dmax[c] - d : d; + const float d = interp_curve_uniform_f(in[c], (float)sim->gamma[c], (float)sim->le0, + sim->inv_le_step, sim->curves_norm_f, c); + silver[c] = sim->film_positive ? (float)sim->film_dmax[c] - d : d; if(sim->couplers_donor_lm) - silver[c] = silver[c] * (sim->couplers_donor_K[c] + sim->couplers_donor_Dref[c]) - / (sim->couplers_donor_K[c] + silver[c]); + silver[c] = silver[c] * ((float)sim->couplers_donor_K[c] + (float)sim->couplers_donor_Dref[c]) + / ((float)sim->couplers_donor_K[c] + silver[c]); } for(int m = 0; m < 3; m++) { - double acc = 0.0; - for(int k = 0; k < 3; k++) acc += silver[k] * sim->couplers_M[k][m]; - out[m] = (float)acc; + float acc = 0.0f; + for(int k = 0; k < 3; k++) acc += silver[k] * (float)sim->couplers_M[k][m]; + out[m] = acc; } } } @@ -2158,7 +2317,7 @@ void sf_sim_develop(const sf_sim_t *sim, const float *lograw, const float *corr, float *cmy, size_t npix, int nch_in, int nch_out) { const int use_corr = sim->couplers_active && corr != NULL; - const double(*curves)[3] = use_corr ? sim->curves_before : sim->curves_norm; + const float(*curves)[3] = use_corr ? sim->curves_before_f : sim->curves_norm_f; #ifdef _OPENMP #pragma omp parallel for schedule(static) #endif @@ -2169,15 +2328,15 @@ void sf_sim_develop(const sf_sim_t *sim, const float *lograw, const float *corr, float *out = cmy + px * nch_out; for(int c = 0; c < 3; c++) { - double crv = cr ? (double)cr[c] : 0.0; + float crv = cr ? cr[c] : 0.0f; /* receiver-side Langmuir applies to the inhibitor that ARRIVES, i.e. after the spatial diffusion blur, hence here and not in _corr */ if(cr && sim->couplers_recv_lm) - crv = crv * (sim->couplers_recv_Kr[c] + sim->couplers_recv_cref[c]) - / (sim->couplers_recv_Kr[c] + crv); - const double x = (double)in[c] - crv; - out[c] = (float)interp_curve_uniform(x, sim->gamma[c], sim->le0, sim->le_step, - curves, c); + crv = crv * ((float)sim->couplers_recv_Kr[c] + (float)sim->couplers_recv_cref[c]) + / ((float)sim->couplers_recv_Kr[c] + crv); + const float x = in[c] - crv; + out[c] = interp_curve_uniform_f(x, (float)sim->gamma[c], (float)sim->le0, + sim->inv_le_step, curves, c); } } } @@ -2186,8 +2345,6 @@ void sf_sim_print_expose(const sf_sim_t *sim, const float *cmy, float *lograw, size_t npix, int nch_in, int nch_out) { const int steps = sim->lut_steps; - const sf_pchip3d_t P = { steps, sim->enl_lut, sim->enl_sx, sim->enl_sy, sim->enl_sz, - sim->enl_cmin, sim->enl_cmax }; #ifdef _OPENMP #pragma omp parallel for schedule(static) #endif @@ -2195,25 +2352,39 @@ void sf_sim_print_expose(const sf_sim_t *sim, const float *cmy, float *lograw, { const float *in = cmy + px * nch_in; float *out = lograw + px * nch_out; - double l1[3]; - if(steps >= 2) + float l1[3]; + if(steps >= 2 && sim->enl_lut_f) + { + const float scale = (float)(steps - 1); + const float r = (in[0] - (float)sim->enl_lo[0]) * sim->enl_inv_range[0] * scale; + const float g = (in[1] - (float)sim->enl_lo[1]) * sim->enl_inv_range[1] * scale; + const float b = (in[2] - (float)sim->enl_lo[2]) * sim->enl_inv_range[2] * scale; + trilinear_interp_3d_f(l1, sim->enl_lut_f, steps, r, g, b); + } + else if(steps >= 2) { + const sf_pchip3d_t P = { steps, sim->enl_lut, sim->enl_sx, sim->enl_sy, sim->enl_sz, + sim->enl_cmin, sim->enl_cmax }; const double scale = (double)(steps - 1); - const double r = (in[0] - sim->enl_lo[0]) / (sim->enl_hi[0] - sim->enl_lo[0]) * scale; - const double g = (in[1] - sim->enl_lo[1]) / (sim->enl_hi[1] - sim->enl_lo[1]) * scale; - const double b = (in[2] - sim->enl_lo[2]) / (sim->enl_hi[2] - sim->enl_lo[2]) * scale; - pchip3d_interp(&P, r, g, b, l1); + const double r = (in[0] - sim->enl_lo[0]) * sim->enl_inv_range[0] * scale; + const double g = (in[1] - sim->enl_lo[1]) * sim->enl_inv_range[1] * scale; + const double b = (in[2] - sim->enl_lo[2]) * sim->enl_inv_range[2] * scale; + double l1d[3]; + pchip3d_interp(&P, r, g, b, l1d); + l1[0] = (float)l1d[0]; l1[1] = (float)l1d[1]; l1[2] = (float)l1d[2]; } else { + double l1d[3]; const double c[3] = { in[0], in[1], in[2] }; - cmy_to_print_lograw(sim, c, l1); + cmy_to_print_lograw(sim, c, l1d); + l1[0] = (float)l1d[0]; l1[1] = (float)l1d[1]; l1[2] = (float)l1d[2]; } - /* [st] raw = 10^l1 * print_exposure; back to log10 */ + /* [st] 10^l1 * print_exposure -> log domain: out = l1 + log10(print_exposure) */ for(int m = 0; m < 3; m++) { - const double r = pow(10.0, l1[m]) * sim->print_exposure; - out[m] = (float)log10(fmax(r, 0.0) + SF_LOG_EPS); + const float v = l1[m] + sim->log10_print_exposure; + out[m] = (v < -30.0f) ? -30.0f : v; /* prevent -inf from degenerate inputs */ } } } @@ -2229,8 +2400,8 @@ void sf_sim_print_develop(const sf_sim_t *sim, const float *lograw, float *cmy, const float *in = lograw + px * nch_in; float *out = cmy + px * nch_out; for(int c = 0; c < 3; c++) - out[c] = (float)interp_curve_uniform(in[c], 1.0, sim->le0, sim->le_step, - sim->print_curves, c); + out[c] = interp_curve_uniform_f(in[c], 1.0f, (float)sim->le0, + sim->inv_le_step, sim->print_curves_f, c); } } @@ -2238,8 +2409,6 @@ void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, size_t n int nch_in, int nch_out) { const int steps = sim->lut_steps; - const sf_pchip3d_t P = { steps, sim->scan_lut, sim->scan_sx, sim->scan_sy, sim->scan_sz, - sim->scan_cmin, sim->scan_cmax }; #ifdef _OPENMP #pragma omp parallel for schedule(static) #endif @@ -2247,22 +2416,36 @@ void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, size_t n { const float *in = cmy + px * nch_in; float *out = rgb_out + px * nch_out; - double lx[3]; - if(steps >= 2) + float lx[3]; + if(steps >= 2 && sim->scan_lut_f) + { + const float scale = (float)(steps - 1); + const float r = (in[0] - (float)sim->scan_lo[0]) * sim->scan_inv_range[0] * scale; + const float g = (in[1] - (float)sim->scan_lo[1]) * sim->scan_inv_range[1] * scale; + const float b = (in[2] - (float)sim->scan_lo[2]) * sim->scan_inv_range[2] * scale; + trilinear_interp_3d_f(lx, sim->scan_lut_f, steps, r, g, b); + } + else if(steps >= 2) { + const sf_pchip3d_t P = { steps, sim->scan_lut, sim->scan_sx, sim->scan_sy, sim->scan_sz, + sim->scan_cmin, sim->scan_cmax }; const double scale = (double)(steps - 1); - const double r = (in[0] - sim->scan_lo[0]) / (sim->scan_hi[0] - sim->scan_lo[0]) * scale; - const double g = (in[1] - sim->scan_lo[1]) / (sim->scan_hi[1] - sim->scan_lo[1]) * scale; - const double b = (in[2] - sim->scan_lo[2]) / (sim->scan_hi[2] - sim->scan_lo[2]) * scale; - pchip3d_interp(&P, r, g, b, lx); + const double r = (in[0] - sim->scan_lo[0]) * sim->scan_inv_range[0] * scale; + const double g = (in[1] - sim->scan_lo[1]) * sim->scan_inv_range[1] * scale; + const double b = (in[2] - sim->scan_lo[2]) * sim->scan_inv_range[2] * scale; + double lxd[3]; + pchip3d_interp(&P, r, g, b, lxd); + lx[0] = (float)lxd[0]; lx[1] = (float)lxd[1]; lx[2] = (float)lxd[2]; } else { + double lxd[3]; const double c[3] = { in[0], in[1], in[2] }; - cmy_to_log_xyz(sim, c, lx); + cmy_to_log_xyz(sim, c, lxd); + lx[0] = (float)lxd[0]; lx[1] = (float)lxd[1]; lx[2] = (float)lxd[2]; } - double xyz[3], rgb[3]; - for(int m = 0; m < 3; m++) xyz[m] = pow(10.0, lx[m]); + double xyz[3]; double rgb[3]; + for(int m = 0; m < 3; m++) xyz[m] = SF_POW10F(lx[m]); if(sim->out_luminance_boost != 1.0) for(int m = 0; m < 3; m++) xyz[m] *= sim->out_luminance_boost; if(sim->scan_bw_on) diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index 20ea9222eb35..4a649b1141aa 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -67,6 +67,8 @@ extern "C" { #define SF_NWL 81 /* 380..780 nm in 5 nm steps — spektrafilm SPECTRAL_SHAPE */ #define SF_NLE 256 /* log-exposure grid — spektrafilm LOG_EXPOSURE */ + + typedef struct sf_pack_t sf_pack_t; typedef struct sf_profile_t sf_profile_t; typedef struct sf_sim_t sf_sim_t; From a0fb6f19aa308833b74b27e4d15be998faa5e77a Mon Sep 17 00:00:00 2001 From: Arecsu Date: Thu, 16 Jul 2026 15:19:18 -0300 Subject: [PATCH 21/56] spektrafilm: -fno-math-errno flag + NEON matrix multiply for expose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add -fno-math-errno per-file compile flag for spektra_core.c and - NEON 3x3 matrix multiply batch (neon_mat3_mulv_batch): processes 4 pixels at once using vld3q/vmlaq_n/vst3q — replaces scalar 9-mul+6-add with 3 vmlaq_n_f32 sequences per matrix row - sf_sim_expose uses the NEON batch for the 3x3 RGB→XYZ matrix, then finishes each pixel scalarly (tri2quad + bilinear TC LUT) --- src/CMakeLists.txt | 8 +++++ src/common/spektra_sim.c | 65 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5c047ab10578..a9f2ebccecbd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1020,6 +1020,14 @@ add_library(lib_darktable SHARED ${DARKTABLE_BINDIR}/preferences_gen.h ${DARKTAB # since this isn't the same directory we do have to manually set it set_source_files_properties(${DARKTABLE_BINDIR}/version_gen.c PROPERTIES GENERATED TRUE) +# spektrafilm: -fno-math-errno tells the compiler math functions never set errno, +# enabling more aggressive optimization around powf/exp2f/log10f calls. +set_source_files_properties( + "common/spektra_core.c" + "common/spektra_sim.c" + PROPERTIES COMPILE_OPTIONS "-fno-math-errno" +) + add_dependencies(lib_darktable generate_styles_string) add_dependencies(lib_darktable generate_conf) add_dependencies(lib_darktable generate_version) diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 03460d716dac..b31a4dff0e5b 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -49,6 +49,31 @@ #include #define SF_LOG_EPS 1e-10 + +/* NEON 3×3 matrix multiply for 4 pixels at once via vmlaq_n_f32 (fused + multiply-add by scalar broadcast). Pure NEON — works on any ARMv8 target. */ +#if defined(__ARM_NEON) +#include + +static inline void neon_mat3_mulv_batch(const float m[9], const float *in, + float out[12]) +{ + float32x4x3_t rgb = vld3q_f32(in); + const float32x4_t r0 = rgb.val[0], r1 = rgb.val[1], r2 = rgb.val[2]; + float32x4_t x0 = vmulq_n_f32(r0, m[0]); + x0 = vmlaq_n_f32(x0, r1, m[1]); + x0 = vmlaq_n_f32(x0, r2, m[2]); + float32x4_t x1 = vmulq_n_f32(r0, m[3]); + x1 = vmlaq_n_f32(x1, r1, m[4]); + x1 = vmlaq_n_f32(x1, r2, m[5]); + float32x4_t x2 = vmulq_n_f32(r0, m[6]); + x2 = vmlaq_n_f32(x2, r1, m[7]); + x2 = vmlaq_n_f32(x2, r2, m[8]); + rgb.val[0] = x0; rgb.val[1] = x1; rgb.val[2] = x2; + vst3q_f32(out, rgb); +} +#endif /* __ARM_NEON */ + /* Fast pow10 / log10 via exp2f/log2f. Using compiler builtins gives the optimizer a better chance to inline/reduce them vs libm powf(10, x) which internally computes exp2(x * log2(10)) with extra overhead. */ @@ -2253,6 +2278,45 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, void sf_sim_expose(const sf_sim_t *sim, const float *rgb_in, float *raw, size_t npix, int nch_in, int nch_out) { +#if defined(__ARM_NEON) + if(nch_in == 3 && nch_out == 3 && sim->tc_lut_f && npix >= 4) + { + const size_t n4 = npix & ~(size_t)3; /* round down to multiple of 4 */ +#ifdef _OPENMP +#pragma omp parallel for schedule(static) +#endif + for(size_t px = 0; px < n4; px += 4) + { + const float *in = rgb_in + px * 3; + float *out = raw + px * 3; + float xyz[12]; + neon_mat3_mulv_batch(sim->m_in_f, in, xyz); + for(int k = 0; k < 4; k++) + { + const float *xyz_k = xyz + k * 3; + float r[3]; + const float b = xyz_k[0] + xyz_k[1] + xyz_k[2]; + const float xy[2] = { xyz_k[0] / fmaxf(b, 1e-10f), xyz_k[1] / fmaxf(b, 1e-10f) }; + float tc[2]; + tri2quad_f(tc, xy); + const float scale = (float)(sim->tc_n - 1); + bilinear_interp_2d_f(r, sim->tc_lut_f, sim->tc_n, tc[0] * scale, tc[1] * scale); + const float bb = isfinite(b) ? b : 0.0f; + for(int c = 0; c < 3; c++) out[k * 3 + c] = r[c] * bb * sim->ev_scale_f; + } + } + /* remainder (1-3 pixels, scalar fallback) */ + for(size_t px = n4; px < npix; px++) + { + const float *in = rgb_in + px * 3; + float *out = raw + px * 3; + float r[3]; + expose_pixel_f(sim->m_in_f, sim->tc_lut_f, sim->tc_n, in, r); + for(int c = 0; c < 3; c++) out[c] = r[c] * sim->ev_scale_f; + } + return; + } +#endif #ifdef _OPENMP #pragma omp parallel for schedule(static) #endif @@ -2450,7 +2514,6 @@ void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, size_t n for(int m = 0; m < 3; m++) xyz[m] *= sim->out_luminance_boost; if(sim->scan_bw_on) { - /* scanner black/white point (positive film): scale toward Y in [0,1] */ const double y = xyz[1]; double yc = sim->scan_bw_m * y + sim->scan_bw_q; yc = yc < 0.0 ? 0.0 : (yc > 1.0 ? 1.0 : yc); From 722d0479955f67eb2ffc8c5ef0229a7db84267f2 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Fri, 17 Jul 2026 07:53:52 +0200 Subject: [PATCH 22/56] add migration for new print diffusion stage --- src/iop/spektrafilm.c | 147 +++++++++++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 23 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 1f3c9bbadb57..bf7acac64ed1 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -86,7 +86,7 @@ #include "common/spektra_core.h" #include "common/spektra_sim.h" -DT_MODULE_INTROSPECTION(2, dt_iop_spektrafilm_params_t) +DT_MODULE_INTROSPECTION(3, dt_iop_spektrafilm_params_t) /* Spatial-scale constants, micrometres on film unless noted (see the LUT module for the full rationale; these are shared with modify_roi_in() and @@ -317,22 +317,34 @@ dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, dt_dev_pixelp int legacy_params(dt_iop_module_t *self, const void *const old_params, const int old_version, void **new_params, int32_t *new_params_size, int *new_version) { - /* v1 -> v2: added preflash_exposure/preflash_m_shift/preflash_y_shift, - diffusion_filter_family, and output_luminance_boost (Arecsu's - pre-compression boost patch). v1 here is the module's true original params - shape -- confirmed directly against the live, unmodified upstream - source, not reconstructed from memory -- covering every params layout - this module has shipped with so far: the introspection version was - never bumped through several earlier field additions (print_auto_exposure - among them) during this module's fast-moving initial development, so - this migration also recovers any history saved against those earlier - shapes, same reasoning as the previous v1->v2 migration this one - replaces: the struct has only ever grown by appending fields, so an - old, smaller saved blob still matches the leading fields of the - current struct, and the trailing fields (this migration's job) are - exactly what's missing. From this version onward, any further params - struct change should bump the version and add another case here rather - than silently drift again. */ + /* v1 -> v3 and v2 -> v3: each case below produces the current (v3) + struct directly, rather than chaining through v2 -- same convention + darktable's own modules use for multi-version migrations (see e.g. + exposure.c, where every old_version case sets *new_version straight + to its latest, not to old_version+1). + + v1 is the module's true original params shape, confirmed directly + against the live, unmodified upstream source -- covering every params + layout this module shipped with before the version was first bumped + (the introspection version was never bumped through several early + field additions, print_auto_exposure among them, during this module's + fast-moving initial development, so v1 recovers history saved against + any of those shapes too: the struct has only ever grown by appending + fields, so an old, smaller saved blob still matches the leading + fields of the current struct). + + v2 is the shape after the first proper version bump (preflash_*, + diffusion_filter_family, output_luminance_boost added) but before + print diffusion existed. + + v3 adds print_diffusion_on/print_diffusion_filter_family/ + print_diffusion_strength/print_diffusion_scale/print_diffusion_warmth + -- a second, independent diffusion filter applied at the print stage + rather than the film stage. + + From this version onward, any further params struct change should + bump the version and add another case here rather than silently + drift again. */ typedef struct dt_iop_spektrafilm_params_v1_t { uint32_t film_hash; @@ -362,6 +374,40 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int float film_format_mm; } dt_iop_spektrafilm_params_v1_t; + typedef struct dt_iop_spektrafilm_params_v2_t + { + uint32_t film_hash; + uint32_t paper_hash; + float exposure_ev; + float print_exposure_ev; + gboolean print_auto_exposure; + float print_contrast; + float filter_m; + float filter_y; + float couplers_amount; + float preflash_exposure; + float preflash_m_shift; + float preflash_y_shift; + gboolean scan_film; + dt_iop_spektrafilm_quality_t quality; + gboolean halation_on; + float halation_amount; + float halation_scale; + float boost_ev; + float boost_range; + float protect_ev; + gboolean diffusion_on; + dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; + float diffusion_strength; + float diffusion_scale; + float diffusion_warmth; + gboolean grain_on; + float grain_amount; + float grain_size; + float film_format_mm; + float output_luminance_boost; + } dt_iop_spektrafilm_params_v2_t; + if(old_version == 1) { const dt_iop_spektrafilm_params_v1_t *o = (dt_iop_spektrafilm_params_v1_t *)old_params; @@ -376,9 +422,9 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->filter_m = o->filter_m; n->filter_y = o->filter_y; n->couplers_amount = o->couplers_amount; - n->preflash_exposure = 0.0f; /* new field: neutral default, no-op (matches upstream) */ - n->preflash_m_shift = 0.0f; /* new field: neutral default, no-op */ - n->preflash_y_shift = 0.0f; /* new field: neutral default, no-op */ + n->preflash_exposure = 0.0f; /* new in v2: neutral default, no-op (matches upstream) */ + n->preflash_m_shift = 0.0f; /* new in v2: neutral default, no-op */ + n->preflash_y_shift = 0.0f; /* new in v2: neutral default, no-op */ n->scan_film = o->scan_film; n->quality = o->quality; n->halation_on = o->halation_on; @@ -388,22 +434,77 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->boost_range = o->boost_range; n->protect_ev = o->protect_ev; n->diffusion_on = o->diffusion_on; - /* new field: the engine was hardcoded to Black Pro-Mist before the + /* new in v2: the engine was hardcoded to Black Pro-Mist before the family selector existed, so this exactly reproduces old saved diffusion settings rather than just picking a neutral default. */ n->diffusion_filter_family = DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST; n->diffusion_strength = o->diffusion_strength; n->diffusion_scale = o->diffusion_scale; n->diffusion_warmth = o->diffusion_warmth; + /* new in v3: print diffusion never existed for anything saved against + v1, so it defaults off, same $DEFAULT the param itself declares. */ + n->print_diffusion_on = FALSE; + n->print_diffusion_filter_family = DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST; + n->print_diffusion_strength = 0.5f; + n->print_diffusion_scale = 1.0f; + n->print_diffusion_warmth = 0.0f; + n->grain_on = o->grain_on; + n->grain_amount = o->grain_amount; + n->grain_size = o->grain_size; + n->film_format_mm = o->film_format_mm; + n->output_luminance_boost = 1.0f; /* new in v2: neutral default, no-op (matches upstream) */ + + *new_params = n; + *new_params_size = sizeof(dt_iop_spektrafilm_params_t); + *new_version = 3; + return 0; + } + if(old_version == 2) + { + const dt_iop_spektrafilm_params_v2_t *o = (dt_iop_spektrafilm_params_v2_t *)old_params; + dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); + + n->film_hash = o->film_hash; + n->paper_hash = o->paper_hash; + n->exposure_ev = o->exposure_ev; + n->print_exposure_ev = o->print_exposure_ev; + n->print_auto_exposure = o->print_auto_exposure; + n->print_contrast = o->print_contrast; + n->filter_m = o->filter_m; + n->filter_y = o->filter_y; + n->couplers_amount = o->couplers_amount; + n->preflash_exposure = o->preflash_exposure; + n->preflash_m_shift = o->preflash_m_shift; + n->preflash_y_shift = o->preflash_y_shift; + n->scan_film = o->scan_film; + n->quality = o->quality; + n->halation_on = o->halation_on; + n->halation_amount = o->halation_amount; + n->halation_scale = o->halation_scale; + n->boost_ev = o->boost_ev; + n->boost_range = o->boost_range; + n->protect_ev = o->protect_ev; + n->diffusion_on = o->diffusion_on; + n->diffusion_filter_family = o->diffusion_filter_family; + n->diffusion_strength = o->diffusion_strength; + n->diffusion_scale = o->diffusion_scale; + n->diffusion_warmth = o->diffusion_warmth; + /* new in v3: nothing saved against v2 ever had print diffusion, so it + defaults off, same $DEFAULT the param itself declares. */ + n->print_diffusion_on = FALSE; + n->print_diffusion_filter_family = DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST; + n->print_diffusion_strength = 0.5f; + n->print_diffusion_scale = 1.0f; + n->print_diffusion_warmth = 0.0f; n->grain_on = o->grain_on; n->grain_amount = o->grain_amount; n->grain_size = o->grain_size; n->film_format_mm = o->film_format_mm; - n->output_luminance_boost = 1.0f; /* new field: neutral default, no-op (matches upstream) */ + n->output_luminance_boost = o->output_luminance_boost; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 2; + *new_version = 3; return 0; } return 1; From 91e1965efc1ede9b588eb4e7eb7f86a245d67b5d Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Fri, 17 Jul 2026 13:31:35 +0200 Subject: [PATCH 23/56] Halation now behaves like original spektrafilm, per film and we now expose all tuning parameters like the original --- data/kernels/spektrafilm.cl | 52 +++++- src/common/spektra_core.c | 43 +++-- src/common/spektra_core.h | 12 +- src/common/spektra_sim.c | 51 ++++++ src/common/spektra_sim.h | 15 ++ src/iop/spektrafilm.c | 312 ++++++++++++++++++++++++++++++------ 6 files changed, 412 insertions(+), 73 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 2f975881e123..af097cf70c99 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -588,18 +588,19 @@ __kernel void spektrafilm_passthrough(__read_only image2d_t in, __write_only ima /* spatial-effect kernels (identical to the LUT module's; blurs host-side) */ /* ======================================================================== */ -__kernel void spektrafilm_scatter_combine(__global const float4 *core, __global const float4 *tail, - __global float4 *out, const int w, const int h, +__kernel void spektrafilm_scatter_combine(__global const float4 *raw, __global const float4 *core, + __global const float4 *tail, __global float4 *out, + const int w, const int h, const float s_amount, const float ws_r, const float ws_g, const float ws_b) { const int x = get_global_id(0), y = get_global_id(1); if(x >= w || y >= h) return; size_t k = (size_t)y * w + x; - float4 c = core[k], t = tail[k], o; - o.x = (1.f - ws_r) * c.x + ws_r * t.x; - o.y = (1.f - ws_g) * c.y + ws_g * t.y; - o.z = (1.f - ws_b) * c.z + ws_b * t.z; - o.w = c.w; + float4 r = raw[k], c = core[k], t = tail[k], o; + o.x = r.x + s_amount * (((1.f - ws_r) * c.x + ws_r * t.x) - r.x); + o.y = r.y + s_amount * (((1.f - ws_g) * c.y + ws_g * t.y) - r.y); + o.z = r.z + s_amount * (((1.f - ws_b) * c.z + ws_b * t.z) - r.z); + o.w = r.w; out[k] = o; } @@ -617,6 +618,43 @@ __kernel void spektrafilm_accum(__global const float4 *blurred, __global float4 acc[k] = a; } +/* Pull one channel out of a float4 buffer into a packed single-channel + * buffer, so it can be blurred on its own (1 channel of work) instead of + * blurring all 4 channels of a float4 buffer just to keep 1 of them. */ +__kernel void spektrafilm_channel_extract(__global const float4 *src, __global float *dst, + const int w, const int h, const int channel) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + size_t k = (size_t)y * w + x; + float4 s = src[k]; + dst[k] = (channel == 0) ? s.x : (channel == 1) ? s.y : s.z; +} + +/* Accumulate weight*blurred[k] (a single-channel buffer, already blurred with + * that channel's own sigma via spektrafilm_channel_extract + a 1-channel + * Gaussian blur) into acc[.channel] only, leaving the other two channels of + * acc untouched (unless reset, which zeroes all of acc.xyz once up front). + * Used to assemble a genuinely per-channel-sigma blur: each channel gets its + * own extract + blur + accum, at 1x the per-channel blur cost instead of + * blurring a full float4 (4x the work) just to keep one channel of it. + * Channel is 0=R, 1=G, 2=B; alpha (.w) is left as acc's own, unset here + * since none of the scatter/tail stages carry alpha. */ +__kernel void spektrafilm_channel_accum(__global const float *blurred, __global float4 *acc, + const int w, const int h, const float weight, + const int channel, const int reset) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + size_t k = (size_t)y * w + x; + const float bv = blurred[k]; + float4 a = reset ? (float4)(0.f) : acc[k]; + const float av = (channel == 0) ? a.x : (channel == 1) ? a.y : a.z; + const float nv = av + weight * bv; + if(channel == 0) a.x = nv; else if(channel == 1) a.y = nv; else a.z = nv; + acc[k] = a; +} + __kernel void spektrafilm_halation_apply(__global float4 *raw, __global const float4 *blur, const int w, const int h, const float a_r, const float a_g, const float a_b) diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index 42714b09f4f8..3308b786ee84 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -256,23 +256,40 @@ void sf_boost_highlights(float *const raw, const int w, const int h, const float } } -void sf_halation(float *const raw, const int w, const int h, const double pixel_um, const float amount, - const float spatial_scale) +void sf_halation(float *const raw, const int w, const int h, const double pixel_um, + const float scatter_amount, const float scatter_scale, + const float halation_amount, const float halation_scale, + const double halation_strength[3], const double halation_first_sigma_um) { - if(amount <= 0.0f) return; + if(scatter_amount <= 0.0f && halation_amount <= 0.0f) return; - /* per-channel scatter radii (um on film) and core/tail mix weights */ + /* per-channel scatter radii (um on film) and core/tail mix weights. These + are the reference model's schema defaults (upstream HalationParams) and, + unlike halation_strength/halation_first_sigma_um below, are NOT varied + per film stock upstream (every (use, antihalation) preset leaves them + alone), so they stay fixed constants here too. */ static const double sc_core[3] = { 2.2, 2.0, 1.6 }; static const double sc_tail[3] = { 9.3, 9.7, 9.1 }; static const double w_s[3] = { 0.78, 0.65, 0.67 }; /* tail = sum of three Gaussians (amplitude, radius multiplier) */ static const double tail_amp[3] = { 0.1633, 0.6496, 0.1870 }; static const double tail_rat[3] = { 0.5360, 1.5236, 2.7684 }; - /* per-channel halation strength: red/green only, blue has none on real film */ - const double eff = pow((double)amount, 1.3); - const double a_tot[3] = { 0.05 * eff, 0.015 * eff, 0.0 }; - const double first_sigma_um = 65.0; /* base bounce radius */ - const double scl = fmax((double)spatial_scale, 1e-3); /* halation size multiplier */ + /* stage 1 (scatter): s_amount is the (1-s)*raw + s*scattered blend weight, + matching upstream's scatter_amount 1:1 (no extra curve). scl is the + shared core/tail spatial-scale multiplier. */ + const double s_amount = (double)scatter_amount; + const double scl = fmax((double)scatter_scale, 1e-3); + /* stage 2 (halation): per-channel strength at halation_amount==1.0, and the + first-bounce radius, both per-film (sf_sim_halation_params()) since + upstream keys these off the profile's use/antihalation tags -- e.g. a + modern strong-AH stock scatters far less red/green back than a + rem-jet-removed one. hscl is halation's OWN spatial-scale multiplier, + independent from the scatter stage's scl above. */ + const double eff = pow((double)halation_amount, 1.3); + const double a_tot[3] = { halation_strength[0] * eff, halation_strength[1] * eff, + halation_strength[2] * eff }; + const double first_sigma_um = halation_first_sigma_um; /* base bounce radius */ + const double hscl = fmax((double)halation_scale, 1e-3); const int n_bounces = 3; const double rho = 0.5; /* bounce decay */ @@ -283,6 +300,7 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ if(!plane) { dt_free_align(trans); return; } /* --- stage 1: scatter PSF (core + 3-component tail) --- */ + if(s_amount > 0.0) { float *const core = dt_alloc_align_float(nn); float *const tail = dt_alloc_align_float(nn); @@ -307,7 +325,8 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ for(size_t i = 0; i < nn; i++) { const int c = i % 3; - raw[i] = (float)((1.0 - w_s[c]) * core[i] + w_s[c] * tail[i]); + const double scattered = (1.0 - w_s[c]) * core[i] + w_s[c] * tail[i]; + raw[i] = (float)((1.0 - s_amount) * (double)raw[i] + s_amount * scattered); } } dt_free_align(core); @@ -316,7 +335,7 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ } /* --- stage 2: multi-bounce halation --- */ - if(a_tot[0] > 0.0 || a_tot[1] > 0.0) + if(halation_amount > 0.0f && (a_tot[0] > 0.0 || a_tot[1] > 0.0 || a_tot[2] > 0.0)) { double decay[8], dsum = 0.0; for(int k = 1; k <= n_bounces; k++) @@ -334,7 +353,7 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ for(int k = 1; k <= n_bounces; k++) { dt_iop_image_copy(comp, raw, nn); - const float sk = fmaxf((float)((first_sigma_um * scl / pixel_um) * sqrt((double)k)), 1e-6f); + const float sk = fmaxf((float)((first_sigma_um * hscl / pixel_um) * sqrt((double)k)), 1e-6f); const float sig3[3] = { sk, sk, sk }; _blur_per_channel(comp, w, h, sig3, plane, trans); const float wk = (float)decay[k - 1]; diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index 90c477d0718f..784186f24415 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -31,7 +31,17 @@ /* Spatial effects implemented in spektra_core.c (they use dt_gaussian and so need darktable linkage; everything else in this header is inline). */ void sf_blur_plane3(float *buf, int w, int h, float sigma, float *plane); -void sf_halation(float *raw, int w, int h, double pixel_um, float amount, float spatial_scale); +/* Two independently-controllable stages, matching upstream's HalationParams: + * scatter_amount / scatter_scale -- stage 1, in-emulsion core+tail scatter + * halation_amount / halation_scale -- stage 2, back-reflection multi-bounce + * halation_strength: per-channel (R,G,B) back-reflection strength at + * halation_amount==1.0; halation_first_sigma_um: first-bounce Gaussian radius + * in micrometres. Both come from sf_sim_halation_params() — per-film when the + * pack provides film_render_defaults[stock].halation, otherwise the generic + * still/strong-antihalation baseline. */ +void sf_halation(float *raw, int w, int h, double pixel_um, float scatter_amount, + float scatter_scale, float halation_amount, float halation_scale, + const double halation_strength[3], double halation_first_sigma_um); void sf_boost_highlights(float *raw, int w, int h, float boost_ev, float boost_range, float protect_ev); void sf_diffusion_filter(float *raw, int w, int h, double pixel_um, int family, float strength, diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index b31a4dff0e5b..7f641cedfbc9 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -93,6 +93,17 @@ static inline void neon_mat3_mulv_batch(const float m[9], const float *in, #define SF_CMAX_NBISECT 18 #define SF_MIDGRAY 0.184 +/* Generic (still film, strong-antihalation) halation baseline — the values + * spektra_core.c's sf_halation() used to hardcode unconditionally. Now the + * fallback used when a pack/stock has no film_render_defaults[stock].halation + * entry (see sf_sim_build() and sf_sim_halation_params() below). Mirrors + * upstream's ('still', 'strong') entry in params_builder.py's + * _HALATION_PRESETS. */ +#define SF_HALATION_STRENGTH_DEFAULT_R 0.05 +#define SF_HALATION_STRENGTH_DEFAULT_G 0.015 +#define SF_HALATION_STRENGTH_DEFAULT_B 0.0 +#define SF_HALATION_SIGMA_DEFAULT_UM 65.0 + /* ------------------------------------------------------------------------ */ /* small linear algebra */ /* ------------------------------------------------------------------------ */ @@ -278,6 +289,12 @@ struct sf_sim_t pack has no per-film grain entry (see sf_sim_build). */ double grain_rms[3], grain_uniformity[3]; + /* per-film halation preset (film_render_defaults[stock].halation in the + pack): back-reflection strength per channel + first-bounce sigma. + Defaults to SF_HALATION_STRENGTH_DEFAULT_* / SF_HALATION_SIGMA_DEFAULT_UM + when the pack has no per-stock entry (see sf_sim_build()). */ + double halation_strength[3], halation_sigma_um; + /* print exposure (exact spectral path) */ int has_print; double illum_print[SF_NWL]; /* enlarger source × dichroic pack */ @@ -1760,6 +1777,27 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, p->gamma_inter_g_rb, p->gamma_inter_b_rg, NULL, NULL, NULL, NULL, NULL); } + + /* per-film halation preset from the pack's film_render_defaults[stock].halation + * (upstream keys this off the profile's use/antihalation tags — modern + * strong-AH stocks get a much weaker, tighter halo than e.g. a rem-jet-removed + * or redscale stock). Seed with the generic still/strong-AH baseline first so + * a pack/stock without this data reproduces the previous fixed behaviour + * exactly; sf_pack_film_defaults() only overwrites entries it actually finds. */ + { + s->halation_strength[0] = SF_HALATION_STRENGTH_DEFAULT_R; + s->halation_strength[1] = SF_HALATION_STRENGTH_DEFAULT_G; + s->halation_strength[2] = SF_HALATION_STRENGTH_DEFAULT_B; + double sigma3[3] = { SF_HALATION_SIGMA_DEFAULT_UM, SF_HALATION_SIGMA_DEFAULT_UM, + SF_HALATION_SIGMA_DEFAULT_UM }; + sf_pack_film_defaults(pack, film->stock, NULL, NULL, NULL, NULL, s->halation_strength, + sigma3, NULL, NULL, NULL); + /* all known presets use one sigma for R/G/B (see _HALATION_PRESETS + upstream); take the first channel rather than plumb a 3-wide sigma + through sf_halation() for a split that doesn't currently exist. */ + s->halation_sigma_um = sigma3[0]; + } + /* neutral enlarger filters from the release database */ if(s->has_print && p->neutral_from_db) { @@ -2606,7 +2644,9 @@ sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *s) g->grain_uniformity[c] = (float)s->grain_uniformity[c]; /* self-consistent with g->film_dmax: see sf_sim_film_grain3 */ g->grain_dmin[c] = (float)s->film_dmin[c]; + g->halation_strength[c] = (float)s->halation_strength[c]; } + g->halation_first_sigma_um = (float)s->halation_sigma_um; g->film_positive = s->film_positive; g->couplers_active = s->couplers_active; @@ -2699,6 +2739,17 @@ void sf_sim_coupler_diffusion(const sf_sim_t *sim, double *size_um, double *tail *tail_w = sim ? sim->coupler_tail_w : 0.0; } +void sf_sim_halation_params(const sf_sim_t *sim, double strength[3], double *first_sigma_um) +{ + const double dflt[3] = { SF_HALATION_STRENGTH_DEFAULT_R, SF_HALATION_STRENGTH_DEFAULT_G, + SF_HALATION_STRENGTH_DEFAULT_B }; + if(strength) + { + for(int c = 0; c < 3; c++) strength[c] = sim ? sim->halation_strength[c] : dflt[c]; + } + if(first_sigma_um) *first_sigma_um = sim ? sim->halation_sigma_um : SF_HALATION_SIGMA_DEFAULT_UM; +} + void sf_sim_film_dmax3(const sf_sim_t *sim, float dmax[3]) { for(int c = 0; c < 3; c++) dmax[c] = sim ? (float)sim->film_dmax[c] : 2.2f; diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index 4a649b1141aa..0791526abca5 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -155,6 +155,12 @@ typedef struct sf_sim_gpu_t pack): RMS-granularity, uniformity and density floor, replacing the earlier one-size-fits-all constants */ float grain_rms[3], grain_uniformity[3], grain_dmin[3]; + /* per-film halation preset (film_render_defaults[stock].halation in the + pack): back-reflection strength per channel and first-bounce radius; + falls back to SF_HALATION_STRENGTH_DEFAULT_* / SF_HALATION_SIGMA_DEFAULT_UM + (spektra_sim.c) when the pack has no per-stock entry. See + sf_sim_halation_params(). */ + float halation_strength[3], halation_first_sigma_um; /* output gamut compression */ int out_compress; /* sf_output_compress_t */ float out_luminance_boost; @@ -189,6 +195,15 @@ void sf_sim_coupler_diffusion(const sf_sim_t *sim, double *size_um, double *tail bool sf_pack_film_coupler_diffusion(const sf_pack_t *pack, const char *film_stock, double *size_um, double *tail_um, double *tail_w); +/* per-film halation preset (film_render_defaults[stock].halation in the pack): + * back-reflection strength per channel (R/G/B) and the first-bounce Gaussian + * radius in micrometres. Both `strength` and `first_sigma_um` may be NULL if + * the caller only wants one. Falls back to the generic still-film / + * strong-antihalation baseline (SF_HALATION_STRENGTH_DEFAULT_* / + * SF_HALATION_SIGMA_DEFAULT_UM in spektra_sim.c) when `sim` is NULL or the + * pack predates per-stock halation data. */ +void sf_sim_halation_params(const sf_sim_t *sim, double strength[3], double *first_sigma_um); + const char *sf_profile_stock(const sf_profile_t *p); const char *sf_profile_name(const sf_profile_t *p); const char *sf_profile_stage(const sf_profile_t *p); /* "filming" / "printing" */ diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index bf7acac64ed1..7e10c01204a8 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -86,13 +86,16 @@ #include "common/spektra_core.h" #include "common/spektra_sim.h" -DT_MODULE_INTROSPECTION(3, dt_iop_spektrafilm_params_t) +DT_MODULE_INTROSPECTION(4, dt_iop_spektrafilm_params_t) /* Spatial-scale constants, micrometres on film unless noted (see the LUT module for the full rationale; these are shared with modify_roi_in() and tiling_callback() so the halo math stays in sync). */ #define SF_HALATION_FIRST_SIGMA_UM 65.0f #define SF_HALATION_PSF_SIGMAS 1.7320508f /* sqrt(3) */ +/* widest stage-1 scatter component: max(sc_tail)*max(tail_rat) from + spektra_core.c's sf_halation() = 9.7 * 2.7684 um, rounded up */ +#define SF_SCATTER_TAIL_MAX_UM 27.0f #define SF_GRAIN_BLUR_FACTOR 0.8f #define SF_GRAIN_SIZE_MIN 0.05f #define SF_HALO_SIGMAS 4.0f @@ -138,6 +141,8 @@ typedef struct dt_iop_spektrafilm_params_t gboolean scan_film; // $DEFAULT: FALSE $DESCRIPTION: "scan the film (skip print)" dt_iop_spektrafilm_quality_t quality; // $DEFAULT: DT_SPEKTRAFILM_Q_STANDARD $DESCRIPTION: "quality" gboolean halation_on; // $DEFAULT: TRUE $DESCRIPTION: "enable halation" + float scatter_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter amount" + float scatter_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter size" float halation_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "halation" float halation_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "halation size" float boost_ev; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 0.0 $DESCRIPTION: "highlight boost" @@ -177,7 +182,7 @@ typedef struct dt_iop_spektrafilm_gui_data_t GtkWidget *exposure_ev, *print_exposure_ev, *print_auto_exposure, *print_contrast, *filter_m, *filter_y; GtkWidget *couplers_amount, *scan_film, *quality; GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift; - GtkWidget *halation_on, *halation_amount, *halation_scale; + GtkWidget *halation_on, *scatter_amount, *scatter_scale, *halation_amount, *halation_scale; GtkWidget *boost_ev, *boost_range, *protect_ev; GtkWidget *diffusion_on, *diffusion_filter_family, *diffusion_strength, *diffusion_scale, *diffusion_warmth; GtkWidget *print_diffusion_on, *print_diffusion_filter_family, *print_diffusion_strength, *print_diffusion_scale, *print_diffusion_warmth; @@ -208,7 +213,7 @@ typedef struct dt_iop_spektrafilm_global_data_t int kernel_expose, kernel_lograw, kernel_develop_corr, kernel_develop; int kernel_grain_gen, kernel_grain_add; int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough; - int kernel_scatter_combine, kernel_accum, kernel_halation_apply; + int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply; int kernel_max_partials, kernel_max_reduce, kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix; } dt_iop_spektrafilm_global_data_t; @@ -238,6 +243,8 @@ void init_global(dt_iop_module_so_t *self) gd->kernel_passthrough = dt_opencl_create_kernel(program, "spektrafilm_passthrough"); gd->kernel_scatter_combine = dt_opencl_create_kernel(program, "spektrafilm_scatter_combine"); gd->kernel_accum = dt_opencl_create_kernel(program, "spektrafilm_accum"); + gd->kernel_channel_extract = dt_opencl_create_kernel(program, "spektrafilm_channel_extract"); + gd->kernel_channel_accum = dt_opencl_create_kernel(program, "spektrafilm_channel_accum"); gd->kernel_halation_apply = dt_opencl_create_kernel(program, "spektrafilm_halation_apply"); gd->kernel_max_partials = dt_opencl_create_kernel(program, "spektrafilm_max_partials"); gd->kernel_max_reduce = dt_opencl_create_kernel(program, "spektrafilm_max_reduce"); @@ -263,6 +270,8 @@ void cleanup_global(dt_iop_module_so_t *self) dt_opencl_free_kernel(gd->kernel_passthrough); dt_opencl_free_kernel(gd->kernel_scatter_combine); dt_opencl_free_kernel(gd->kernel_accum); + dt_opencl_free_kernel(gd->kernel_channel_extract); + dt_opencl_free_kernel(gd->kernel_channel_accum); dt_opencl_free_kernel(gd->kernel_halation_apply); dt_opencl_free_kernel(gd->kernel_max_partials); dt_opencl_free_kernel(gd->kernel_max_reduce); @@ -317,11 +326,11 @@ dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, dt_dev_pixelp int legacy_params(dt_iop_module_t *self, const void *const old_params, const int old_version, void **new_params, int32_t *new_params_size, int *new_version) { - /* v1 -> v3 and v2 -> v3: each case below produces the current (v3) - struct directly, rather than chaining through v2 -- same convention - darktable's own modules use for multi-version migrations (see e.g. - exposure.c, where every old_version case sets *new_version straight - to its latest, not to old_version+1). + /* v1 -> v4, v2 -> v4, v3 -> v4: each case below produces the current (v4) + struct directly, rather than chaining through the intermediate versions -- + same convention darktable's own modules use for multi-version migrations + (see e.g. exposure.c, where every old_version case sets *new_version + straight to its latest, not to old_version+1). v1 is the module's true original params shape, confirmed directly against the live, unmodified upstream source -- covering every params @@ -342,6 +351,17 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int -- a second, independent diffusion filter applied at the print stage rather than the film stage. + v4 splits what used to be one shared halation_amount/halation_scale + pair -- driving BOTH the in-emulsion scatter stage (always fully + engaged, s_amount==1 hardcoded) and the back-reflection halation stage + -- into two independent pairs: scatter_amount/scatter_scale (new) and + halation_amount/halation_scale (unchanged meaning, now stage-2 only). + Every case below sets scatter_amount = 1.0 (previously the implicit, + unconfigurable, always-on value) and scatter_scale = (the spatial multiplier that value used to feed into + BOTH stages) so migrated params reproduce the old rendering exactly, + pixel for pixel, before the user ever touches the new sliders. + From this version onward, any further params struct change should bump the version and add another case here rather than silently drift again. */ @@ -408,6 +428,45 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int float output_luminance_boost; } dt_iop_spektrafilm_params_v2_t; + typedef struct dt_iop_spektrafilm_params_v3_t + { + uint32_t film_hash; + uint32_t paper_hash; + float exposure_ev; + float print_exposure_ev; + gboolean print_auto_exposure; + float print_contrast; + float filter_m; + float filter_y; + float couplers_amount; + float preflash_exposure; + float preflash_m_shift; + float preflash_y_shift; + gboolean scan_film; + dt_iop_spektrafilm_quality_t quality; + gboolean halation_on; + float halation_amount; + float halation_scale; + float boost_ev; + float boost_range; + float protect_ev; + gboolean diffusion_on; + dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; + float diffusion_strength; + float diffusion_scale; + float diffusion_warmth; + gboolean print_diffusion_on; + dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; + float print_diffusion_strength; + float print_diffusion_scale; + float print_diffusion_warmth; + gboolean grain_on; + float grain_amount; + float grain_size; + float film_format_mm; + float output_luminance_boost; + } dt_iop_spektrafilm_params_v3_t; + if(old_version == 1) { const dt_iop_spektrafilm_params_v1_t *o = (dt_iop_spektrafilm_params_v1_t *)old_params; @@ -428,6 +487,10 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->scan_film = o->scan_film; n->quality = o->quality; n->halation_on = o->halation_on; + /* new in v4: scatter used to be hardcoded fully-on and shared + halation_scale as its spatial multiplier -- reproduce that exactly. */ + n->scatter_amount = 1.0f; + n->scatter_scale = o->halation_scale; n->halation_amount = o->halation_amount; n->halation_scale = o->halation_scale; n->boost_ev = o->boost_ev; @@ -456,7 +519,7 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 3; + *new_version = 4; return 0; } if(old_version == 2) @@ -479,6 +542,10 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->scan_film = o->scan_film; n->quality = o->quality; n->halation_on = o->halation_on; + /* new in v4: scatter used to be hardcoded fully-on and shared + halation_scale as its spatial multiplier -- reproduce that exactly. */ + n->scatter_amount = 1.0f; + n->scatter_scale = o->halation_scale; n->halation_amount = o->halation_amount; n->halation_scale = o->halation_scale; n->boost_ev = o->boost_ev; @@ -504,7 +571,57 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 3; + *new_version = 4; + return 0; + } + if(old_version == 3) + { + const dt_iop_spektrafilm_params_v3_t *o = (dt_iop_spektrafilm_params_v3_t *)old_params; + dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); + + n->film_hash = o->film_hash; + n->paper_hash = o->paper_hash; + n->exposure_ev = o->exposure_ev; + n->print_exposure_ev = o->print_exposure_ev; + n->print_auto_exposure = o->print_auto_exposure; + n->print_contrast = o->print_contrast; + n->filter_m = o->filter_m; + n->filter_y = o->filter_y; + n->couplers_amount = o->couplers_amount; + n->preflash_exposure = o->preflash_exposure; + n->preflash_m_shift = o->preflash_m_shift; + n->preflash_y_shift = o->preflash_y_shift; + n->scan_film = o->scan_film; + n->quality = o->quality; + n->halation_on = o->halation_on; + /* new in v4: scatter used to be hardcoded fully-on and shared + halation_scale as its spatial multiplier -- reproduce that exactly. */ + n->scatter_amount = 1.0f; + n->scatter_scale = o->halation_scale; + n->halation_amount = o->halation_amount; + n->halation_scale = o->halation_scale; + n->boost_ev = o->boost_ev; + n->boost_range = o->boost_range; + n->protect_ev = o->protect_ev; + n->diffusion_on = o->diffusion_on; + n->diffusion_filter_family = o->diffusion_filter_family; + n->diffusion_strength = o->diffusion_strength; + n->diffusion_scale = o->diffusion_scale; + n->diffusion_warmth = o->diffusion_warmth; + n->print_diffusion_on = o->print_diffusion_on; + n->print_diffusion_filter_family = o->print_diffusion_filter_family; + n->print_diffusion_strength = o->print_diffusion_strength; + n->print_diffusion_scale = o->print_diffusion_scale; + n->print_diffusion_warmth = o->print_diffusion_warmth; + n->grain_on = o->grain_on; + n->grain_amount = o->grain_amount; + n->grain_size = o->grain_size; + n->film_format_mm = o->film_format_mm; + n->output_luminance_boost = o->output_luminance_boost; + + *new_params = n; + *new_params_size = sizeof(dt_iop_spektrafilm_params_t); + *new_version = 4; return 0; } return 1; @@ -869,9 +986,20 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_um) { const float inv_um = 1.0f / fmaxf(pixel_um, 1e-3f); + /* halation stage: first-bounce radius, scaled by the user's halation_scale + (previously this padding ignored halation_scale entirely, silently + under-padding for anyone above the 1.0 default -- fixed here). */ + const float hal_scale = fmaxf(p->halation_scale, 1e-3f); const float hal = (p->halation_on && p->halation_amount > 0.0f) - ? SF_HALATION_FIRST_SIGMA_UM * SF_HALATION_PSF_SIGMAS * inv_um + ? SF_HALATION_FIRST_SIGMA_UM * SF_HALATION_PSF_SIGMAS * hal_scale * inv_um : 0.0f; + /* scatter stage: widest core+tail component, scaled by its own + scatter_scale (independent from halation_scale since the scatter_amount/ + scatter_scale split). */ + const float scat_scale = fmaxf(p->scatter_scale, 1e-3f); + const float scat = (p->halation_on && p->scatter_amount > 0.0f) + ? SF_SCATTER_TAIL_MAX_UM * SF_HALATION_PSF_SIGMAS * scat_scale * inv_um + : 0.0f; /* The widest of film-stage and print-stage diffusion determines the ROI padding — both must fit in the expanded tile. Both stages use the same bloom constant; only the user's scale slider differs between them. */ @@ -894,7 +1022,7 @@ static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_u ? fmaxf((float)SF_COUPLER_BLUR_UM, (float)(SF_EXPTAIL_R2 * 200.0)) * inv_um : 0.0f; - return fmaxf(fmaxf(hal, diff), fmaxf(grain, coupler)); + return fmaxf(fmaxf(fmaxf(hal, scat), diff), fmaxf(grain, coupler)); } void modify_roi_in(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, @@ -929,7 +1057,7 @@ void tiling_callback(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f); const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; tiling->factor = 2.5f; /* 4 float4 buffers, but they alias in practice */ - tiling->factor_cl = 2.5f; + tiling->factor_cl = 2.75f; /* + a 1ch scatter-stage scratch buffer (1/4 of a float4 buffer) */ tiling->maxbuf = 1.0f; tiling->maxbuf_cl = 1.0f; tiling->overhead = 0; @@ -1002,8 +1130,17 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c if(d->p.diffusion_on) sf_diffusion_filter(plane, w, h, (double)pixel_um, (int)d->p.diffusion_filter_family, d->p.diffusion_strength, d->p.diffusion_scale, d->p.diffusion_warmth); - if(d->p.halation_on && d->p.halation_amount > 0.0f) - sf_halation(plane, w, h, (double)pixel_um, d->p.halation_amount, d->p.halation_scale); + if(d->p.halation_on && (d->p.scatter_amount > 0.0f || d->p.halation_amount > 0.0f)) + { + double hal_strength[3], hal_sigma_um; + sf_sim_halation_params(sim, hal_strength, &hal_sigma_um); + /* modify_roi_in()/tiling_callback() already padded for at most + SF_HALATION_FIRST_SIGMA_UM (see _max_halo_sigma); clamp so a future + pack entry larger than that can't under-pad the halo. */ + hal_sigma_um = fmin(hal_sigma_um, (double)SF_HALATION_FIRST_SIGMA_UM); + sf_halation(plane, w, h, (double)pixel_um, d->p.scatter_amount, d->p.scatter_scale, + d->p.halation_amount, d->p.halation_scale, hal_strength, hal_sigma_um); + } /* 3) film development: log exposure, DIR coupler inhibition (the correction field diffuses in the emulsion: gaussian, sigma 20 um as in the @@ -1222,20 +1359,29 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ cl_mem plane2 = dt_opencl_alloc_device_buffer(devid, npix * f * 4); cl_mem tmpa = dt_opencl_alloc_device_buffer(devid, npix * f * 4); cl_mem acc = dt_opencl_alloc_device_buffer(devid, npix * f * 4); + /* single-channel scratch for the scatter stage's genuinely per-channel + blurs (spektrafilm_channel_extract + a 1ch Gaussian): 1/4 the size and + 1/4 the per-blur cost of running the equivalent work on a float4 + buffer, see the scatter stage below. */ + cl_mem plane1 = dt_opencl_alloc_device_buffer(devid, npix * f); dt_gaussian_cl_t *gauss = NULL; + dt_gaussian_cl_t *gauss1 = NULL; if(!mats_cl || !tc_cl || !cn_cl || !cb_cl || !sl_cl || !sx_cl || !sy_cl || !sz_cl || !sn_cl - || !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !acc + || !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !acc || !plane1 || (g->has_print && (!el_cl || !ex_cl || !ey_cl || !ez_cl || !en_cl || !em_cl || !pc_cl))) { err = CL_MEM_OBJECT_ALLOCATION_FAILURE; goto cleanup; } - /* pre-allocated gaussian blur object: avoids allocating 2 temp buffers per blur */ + /* pre-allocated gaussian blur objects: avoids allocating temp buffers per + blur. gauss (4ch) serves everything except the scatter stage; gauss1 + (1ch) serves the scatter stage's real per-channel blurs. */ { const dt_aligned_pixel_t gmax = { 1e9f, 1e9f, 1e9f, 1e9f }; const dt_aligned_pixel_t gmin = { -1e9f, -1e9f, -1e9f, -1e9f }; gauss = dt_gaussian_init_cl(devid, w, h, 4, gmax, gmin, 1.0f, DT_IOP_GAUSSIAN_ZERO); + gauss1 = dt_gaussian_init_cl(devid, w, h, 1, gmax, gmin, 1.0f, DT_IOP_GAUSSIAN_ZERO); } #define SF_GAUSS_BLUR(buf, _sg, label) do { \ if(gauss) { gauss->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss, (buf), (buf)); } \ @@ -1258,6 +1404,11 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ else { err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \ if(err == CL_SUCCESS) err = dt_gaussian_mean_blur_cl(devid, (dst), w, h, 4, (_sg)); } \ } while(0) +/* single-channel in-place blur (scatter stage only, on plane1) */ +#define SF_GAUSS_BLUR_1CH_L(buf, _sg) do { \ + if(gauss1) { gauss1->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss1, (buf), (buf)); } \ + else { err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 1, (_sg)); } \ + } while(0) /* ---- 1) expose: input image -> linear film raw exposure ---------------- */ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_expose, w, h, CLARG(dev_in), @@ -1326,47 +1477,92 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ } } - if(d->p.halation_on && d->p.halation_amount > 0.0f) + if(d->p.halation_on && (d->p.scatter_amount > 0.0f || d->p.halation_amount > 0.0f)) { - const float hscl = fmaxf(d->p.halation_scale, 1e-3f); - const float core_um = (2.2f + 2.0f + 1.6f) / 3.0f, tail_um = (9.3f + 9.7f + 9.1f) / 3.0f; - const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f }; - SF_GAUSS_BLUR_OP(plane, tmpa, fmaxf(core_um * hscl / pixel_um, 1e-3f), "halation core blur"); - for(int g3 = 0; g3 < 3; g3++) + if(d->p.scatter_amount > 0.0f) { - SF_GAUSS_BLUR_OP_L(plane, plane2, fmaxf(rat[g3] * tail_um * hscl / pixel_um, 1e-3f)); - if(err != CL_SUCCESS) break; - const int reset = (g3 == 0); - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(plane2), - CLARG(acc), CLARG(w), CLARG(h), CLARG(amp[g3]), - CLARG(reset)); - SF_CL_STEP("halation tail accum"); + const float sscl = fmaxf(d->p.scatter_scale, 1e-3f); + /* per-channel scatter radii (um on film) and tail mixture, identical to + spektra_core.c's sf_halation() sc_core/sc_tail/tail_amp/tail_rat. + Each channel needs its OWN sigma (R/G/B differ): extract that + channel into the single-channel scratch buffer plane1, blur it + alone (1x the work of a same-size float4 blur, not 4x), then + kernel_channel_accum folds it into the target channel of tmpa/acc. */ + const float sc_core[3] = { 2.2f, 2.0f, 1.6f }; + const float sc_tail[3] = { 9.3f, 9.7f, 9.1f }; + const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f }; + for(int c = 0; c < 3; c++) + { + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_extract, w, h, + CLARG(plane), CLARG(plane1), CLARG(w), CLARG(h), + CLARG(c)); + if(err != CL_SUCCESS) break; + SF_GAUSS_BLUR_1CH_L(plane1, fmaxf(sc_core[c] * sscl / pixel_um, 1e-6f)); + if(err != CL_SUCCESS) break; + const float core_weight = 1.0f; + const int core_reset = (c == 0); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_accum, w, h, + CLARG(plane1), CLARG(tmpa), CLARG(w), CLARG(h), + CLARG(core_weight), CLARG(c), CLARG(core_reset)); + SF_CL_STEP("scatter core blur"); + } + for(int g3 = 0; g3 < 3 && err == CL_SUCCESS; g3++) + for(int c = 0; c < 3; c++) + { + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_extract, w, h, + CLARG(plane), CLARG(plane1), CLARG(w), CLARG(h), + CLARG(c)); + if(err != CL_SUCCESS) break; + const float sigma = fmaxf(rat[g3] * sc_tail[c] * sscl / pixel_um, 1e-6f); + SF_GAUSS_BLUR_1CH_L(plane1, sigma); + if(err != CL_SUCCESS) break; + const int reset = (g3 == 0 && c == 0); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_accum, w, h, + CLARG(plane1), CLARG(acc), CLARG(w), CLARG(h), + CLARG(amp[g3]), CLARG(c), CLARG(reset)); + SF_CL_STEP("scatter tail accum"); + } + const float ws_r = 0.78f, ws_g = 0.65f, ws_b = 0.67f; + /* (1-s)*raw + s*scattered, matching sf_halation()'s CPU blend; `plane` + doubles as both the pre-scatter `raw` input and the `out` write + target -- safe since this is a purely per-pixel elementwise op. */ + const float s_amount = d->p.scatter_amount; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scatter_combine, w, h, CLARG(plane), + CLARG(tmpa), CLARG(acc), CLARG(plane), CLARG(w), + CLARG(h), CLARG(s_amount), CLARG(ws_r), CLARG(ws_g), + CLARG(ws_b)); + SF_CL_STEP("scatter combine"); } - const float ws_r = 0.78f, ws_g = 0.65f, ws_b = 0.67f; - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scatter_combine, w, h, CLARG(tmpa), - CLARG(acc), CLARG(plane), CLARG(w), CLARG(h), - CLARG(ws_r), CLARG(ws_g), CLARG(ws_b)); - SF_CL_STEP("scatter combine"); - - const int N = 3; - const float first_sigma = SF_HALATION_FIRST_SIGMA_UM; - const float dec[3] = { 1.0f/1.75f, 0.5f/1.75f, 0.25f/1.75f }; - for(int k = 1; k <= N; k++) + + if(d->p.halation_amount > 0.0f) { - SF_GAUSS_BLUR_OP_L(plane, plane2, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f)); - if(err != CL_SUCCESS) break; - const int reset = (k == 1); - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(plane2), - CLARG(acc), CLARG(w), CLARG(h), CLARG(dec[k - 1]), - CLARG(reset)); - SF_CL_STEP("halation bounce accum"); + const float hscl = fmaxf(d->p.halation_scale, 1e-3f); + const int N = 3; + /* per-film first-bounce radius (still ~65um / cine ~50um on real + stocks); clamped to what modify_roi_in()/tiling_callback() padded + for, see the matching comment in process(). */ + const float first_sigma = fminf(g->halation_first_sigma_um, SF_HALATION_FIRST_SIGMA_UM); + const float dec[3] = { 1.0f/1.75f, 0.5f/1.75f, 0.25f/1.75f }; + for(int k = 1; k <= N; k++) + { + SF_GAUSS_BLUR_OP_L(plane, plane2, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f)); + if(err != CL_SUCCESS) break; + const int reset = (k == 1); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(plane2), + CLARG(acc), CLARG(w), CLARG(h), CLARG(dec[k - 1]), + CLARG(reset)); + SF_CL_STEP("halation bounce accum"); + } + const float h_eff = powf(d->p.halation_amount, 1.3f); + /* per-film halation strength (e.g. a strong-AH stock stays near-zero on + blue and much lower on red/green than a no-AH/redscale stock). */ + const float a_r = g->halation_strength[0] * h_eff, a_g = g->halation_strength[1] * h_eff, + a_b = g->halation_strength[2] * h_eff; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_halation_apply, w, h, CLARG(plane), + CLARG(acc), CLARG(w), CLARG(h), CLARG(a_r), + CLARG(a_g), CLARG(a_b)); + SF_CL_STEP("halation apply"); } - const float h_eff = powf(d->p.halation_amount, 1.3f); - const float a_r = 0.05f * h_eff, a_g = 0.015f * h_eff, a_b = 0.0f; - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_halation_apply, w, h, CLARG(plane), - CLARG(acc), CLARG(w), CLARG(h), CLARG(a_r), - CLARG(a_g), CLARG(a_b)); - SF_CL_STEP("halation apply"); } /* ---- 3) film development ------------------------------------------------ */ @@ -1509,6 +1705,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ cleanup: if(gauss) dt_gaussian_free_cl(gauss); + if(gauss1) dt_gaussian_free_cl(gauss1); dt_opencl_release_mem_object(mats_cl); dt_opencl_release_mem_object(tc_cl); dt_opencl_release_mem_object(cn_cl); @@ -1531,6 +1728,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ dt_opencl_release_mem_object(plane2); dt_opencl_release_mem_object(tmpa); dt_opencl_release_mem_object(acc); + dt_opencl_release_mem_object(plane1); return err; } #endif /* HAVE_OPENCL */ @@ -1838,6 +2036,14 @@ void gui_init(dt_iop_module_t *self) /* ---- tab: halation ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("halation"), NULL); g->halation_on = dt_bauhaus_toggle_from_params(self, "halation_on"); + g->scatter_amount = dt_bauhaus_slider_from_params(self, "scatter_amount"); + gtk_widget_set_tooltip_text(g->scatter_amount, + _("in-emulsion light scatter, before the halation bounce" + " (1.0 = film-accurate; 0 = off)")); + g->scatter_scale = dt_bauhaus_slider_from_params(self, "scatter_scale"); + gtk_widget_set_tooltip_text(g->scatter_scale, + _("scatter size: scales the in-emulsion scatter radius" + " (1.0 = film-accurate)")); g->halation_amount = dt_bauhaus_slider_from_params(self, "halation_amount"); dt_bauhaus_slider_set_soft_range(g->halation_amount, 0.0f, 2.0f); gtk_widget_set_tooltip_text(g->halation_amount, From b47e51f8032e01c8ae2ba6547d62314f15921d2f Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Fri, 17 Jul 2026 13:40:56 +0200 Subject: [PATCH 24/56] Make halation amount behave like the original --- src/common/spektra_core.c | 10 +-- src/iop/spektrafilm.c | 140 +++++++++++++++++++++++++++++++++----- 2 files changed, 130 insertions(+), 20 deletions(-) diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index 3308b786ee84..06a3025e8ab9 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -284,10 +284,12 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ upstream keys these off the profile's use/antihalation tags -- e.g. a modern strong-AH stock scatters far less red/green back than a rem-jet-removed one. hscl is halation's OWN spatial-scale multiplier, - independent from the scatter stage's scl above. */ - const double eff = pow((double)halation_amount, 1.3); - const double a_tot[3] = { halation_strength[0] * eff, halation_strength[1] * eff, - halation_strength[2] * eff }; + independent from the scatter stage's scl above. halation_amount is a + direct linear multiplier on strength here, matching upstream's + a_tot = halation_strength * halation_amount exactly (no extra curve). */ + const double a_tot[3] = { halation_strength[0] * (double)halation_amount, + halation_strength[1] * (double)halation_amount, + halation_strength[2] * (double)halation_amount }; const double first_sigma_um = halation_first_sigma_um; /* base bounce radius */ const double hscl = fmax((double)halation_scale, 1e-3); const int n_bounces = 3; diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 7e10c01204a8..83ceeece4a24 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -86,7 +86,7 @@ #include "common/spektra_core.h" #include "common/spektra_sim.h" -DT_MODULE_INTROSPECTION(4, dt_iop_spektrafilm_params_t) +DT_MODULE_INTROSPECTION(5, dt_iop_spektrafilm_params_t) /* Spatial-scale constants, micrometres on film unless noted (see the LUT module for the full rationale; these are shared with modify_roi_in() and @@ -326,11 +326,11 @@ dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, dt_dev_pixelp int legacy_params(dt_iop_module_t *self, const void *const old_params, const int old_version, void **new_params, int32_t *new_params_size, int *new_version) { - /* v1 -> v4, v2 -> v4, v3 -> v4: each case below produces the current (v4) - struct directly, rather than chaining through the intermediate versions -- - same convention darktable's own modules use for multi-version migrations - (see e.g. exposure.c, where every old_version case sets *new_version - straight to its latest, not to old_version+1). + /* v1 -> v5, v2 -> v5, v3 -> v5, v4 -> v5: each case below produces the + current (v5) struct directly, rather than chaining through the + intermediate versions -- same convention darktable's own modules use for + multi-version migrations (see e.g. exposure.c, where every old_version + case sets *new_version straight to its latest, not to old_version+1). v1 is the module's true original params shape, confirmed directly against the live, unmodified upstream source -- covering every params @@ -356,14 +356,29 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int engaged, s_amount==1 hardcoded) and the back-reflection halation stage -- into two independent pairs: scatter_amount/scatter_scale (new) and halation_amount/halation_scale (unchanged meaning, now stage-2 only). - Every case below sets scatter_amount = 1.0 (previously the implicit, + Every v1/v2/v3 case sets scatter_amount = 1.0 (previously the implicit, unconfigurable, always-on value) and scatter_scale = (the spatial multiplier that value used to feed into BOTH stages) so migrated params reproduce the old rendering exactly, pixel for pixel, before the user ever touches the new sliders. - From this version onward, any further params struct change should - bump the version and add another case here rather than silently + v5 is a params-VALUE change, not a struct-shape change: sf_halation() + no longer applies eff = halation_amount^1.3 before scaling + halation_strength -- halation_amount is now a direct linear multiplier, + matching upstream's a_tot = halation_strength * halation_amount exactly. + Every version prior to v5 (v1 through v4, all of which share today's + dt_iop_spektrafilm_params_t layout for this field) rendered under the + ^1.3 curve, so every case below remaps + halation_amount := old_halation_amount^1.3 -- the exact inverse of + dropping the curve -- so a_tot stays numerically identical and every + migrated edit still renders pixel-for-pixel as before, even though nothing + about the struct's field layout changed. This is why a params version + bump is still required even for a pure algorithm/semantics change: without + it, darktable has no reason to call this function at all, and old edits + would silently double-apply/skip the curve on load. + + From this version onward, any further params struct or semantics change + should bump the version and add another case here rather than silently drift again. */ typedef struct dt_iop_spektrafilm_params_v1_t { @@ -467,6 +482,47 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int float output_luminance_boost; } dt_iop_spektrafilm_params_v3_t; + typedef struct dt_iop_spektrafilm_params_v4_t + { + uint32_t film_hash; + uint32_t paper_hash; + float exposure_ev; + float print_exposure_ev; + gboolean print_auto_exposure; + float print_contrast; + float filter_m; + float filter_y; + float couplers_amount; + float preflash_exposure; + float preflash_m_shift; + float preflash_y_shift; + gboolean scan_film; + dt_iop_spektrafilm_quality_t quality; + gboolean halation_on; + float scatter_amount; + float scatter_scale; + float halation_amount; + float halation_scale; + float boost_ev; + float boost_range; + float protect_ev; + gboolean diffusion_on; + dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; + float diffusion_strength; + float diffusion_scale; + float diffusion_warmth; + gboolean print_diffusion_on; + dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; + float print_diffusion_strength; + float print_diffusion_scale; + float print_diffusion_warmth; + gboolean grain_on; + float grain_amount; + float grain_size; + float film_format_mm; + float output_luminance_boost; + } dt_iop_spektrafilm_params_v4_t; + if(old_version == 1) { const dt_iop_spektrafilm_params_v1_t *o = (dt_iop_spektrafilm_params_v1_t *)old_params; @@ -491,7 +547,7 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int halation_scale as its spatial multiplier -- reproduce that exactly. */ n->scatter_amount = 1.0f; n->scatter_scale = o->halation_scale; - n->halation_amount = o->halation_amount; + n->halation_amount = powf(o->halation_amount, 1.3f); /* v5: undo dropped ^1.3 curve */ n->halation_scale = o->halation_scale; n->boost_ev = o->boost_ev; n->boost_range = o->boost_range; @@ -519,7 +575,7 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 4; + *new_version = 5; return 0; } if(old_version == 2) @@ -546,7 +602,7 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int halation_scale as its spatial multiplier -- reproduce that exactly. */ n->scatter_amount = 1.0f; n->scatter_scale = o->halation_scale; - n->halation_amount = o->halation_amount; + n->halation_amount = powf(o->halation_amount, 1.3f); /* v5: undo dropped ^1.3 curve */ n->halation_scale = o->halation_scale; n->boost_ev = o->boost_ev; n->boost_range = o->boost_range; @@ -571,7 +627,7 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 4; + *new_version = 5; return 0; } if(old_version == 3) @@ -598,7 +654,57 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int halation_scale as its spatial multiplier -- reproduce that exactly. */ n->scatter_amount = 1.0f; n->scatter_scale = o->halation_scale; - n->halation_amount = o->halation_amount; + n->halation_amount = powf(o->halation_amount, 1.3f); /* v5: undo dropped ^1.3 curve */ + n->halation_scale = o->halation_scale; + n->boost_ev = o->boost_ev; + n->boost_range = o->boost_range; + n->protect_ev = o->protect_ev; + n->diffusion_on = o->diffusion_on; + n->diffusion_filter_family = o->diffusion_filter_family; + n->diffusion_strength = o->diffusion_strength; + n->diffusion_scale = o->diffusion_scale; + n->diffusion_warmth = o->diffusion_warmth; + n->print_diffusion_on = o->print_diffusion_on; + n->print_diffusion_filter_family = o->print_diffusion_filter_family; + n->print_diffusion_strength = o->print_diffusion_strength; + n->print_diffusion_scale = o->print_diffusion_scale; + n->print_diffusion_warmth = o->print_diffusion_warmth; + n->grain_on = o->grain_on; + n->grain_amount = o->grain_amount; + n->grain_size = o->grain_size; + n->film_format_mm = o->film_format_mm; + n->output_luminance_boost = o->output_luminance_boost; + + *new_params = n; + *new_params_size = sizeof(dt_iop_spektrafilm_params_t); + *new_version = 5; + return 0; + } + if(old_version == 4) + { + const dt_iop_spektrafilm_params_v4_t *o = (dt_iop_spektrafilm_params_v4_t *)old_params; + dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); + + n->film_hash = o->film_hash; + n->paper_hash = o->paper_hash; + n->exposure_ev = o->exposure_ev; + n->print_exposure_ev = o->print_exposure_ev; + n->print_auto_exposure = o->print_auto_exposure; + n->print_contrast = o->print_contrast; + n->filter_m = o->filter_m; + n->filter_y = o->filter_y; + n->couplers_amount = o->couplers_amount; + n->preflash_exposure = o->preflash_exposure; + n->preflash_m_shift = o->preflash_m_shift; + n->preflash_y_shift = o->preflash_y_shift; + n->scan_film = o->scan_film; + n->quality = o->quality; + n->halation_on = o->halation_on; + /* v4 already had independent scatter_amount/scatter_scale -- carry them + over unchanged, only halation_amount's curve is being removed here. */ + n->scatter_amount = o->scatter_amount; + n->scatter_scale = o->scatter_scale; + n->halation_amount = powf(o->halation_amount, 1.3f); /* v5: undo dropped ^1.3 curve */ n->halation_scale = o->halation_scale; n->boost_ev = o->boost_ev; n->boost_range = o->boost_range; @@ -621,7 +727,7 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 4; + *new_version = 5; return 0; } return 1; @@ -1553,7 +1659,9 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(reset)); SF_CL_STEP("halation bounce accum"); } - const float h_eff = powf(d->p.halation_amount, 1.3f); + /* halation_amount is a direct linear multiplier on strength, matching + upstream's a_tot = halation_strength * halation_amount (no curve). */ + const float h_eff = d->p.halation_amount; /* per-film halation strength (e.g. a strong-AH stock stays near-zero on blue and much lower on red/green than a no-AH/redscale stock). */ const float a_r = g->halation_strength[0] * h_eff, a_g = g->halation_strength[1] * h_eff, From ea09d6c447695f0cec7983a7629a1541a6c120f3 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Fri, 17 Jul 2026 14:04:48 +0200 Subject: [PATCH 25/56] add strength to halation and grain sliders to clarify --- src/iop/spektrafilm.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 83ceeece4a24..f960c8f532a1 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -143,7 +143,7 @@ typedef struct dt_iop_spektrafilm_params_t gboolean halation_on; // $DEFAULT: TRUE $DESCRIPTION: "enable halation" float scatter_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter amount" float scatter_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter size" - float halation_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "halation" + float halation_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "halation strength" float halation_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "halation size" float boost_ev; // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 0.0 $DESCRIPTION: "highlight boost" float boost_range; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.3 $DESCRIPTION: "boost range" @@ -159,7 +159,7 @@ typedef struct dt_iop_spektrafilm_params_t float print_diffusion_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "print diffusion size" float print_diffusion_warmth; // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "print diffusion halo warmth" gboolean grain_on; // $DEFAULT: TRUE $DESCRIPTION: "enable grain" - float grain_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain" + float grain_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain strength" float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "film format" float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost" From c70e02cae0972c0dcfc36d2c0a7a93eeef5e019d Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Fri, 17 Jul 2026 14:16:54 +0200 Subject: [PATCH 26/56] additional safeguards to not reset to wrong values on tab double click --- src/iop/spektrafilm.c | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index f960c8f532a1..965c3a956836 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -2014,6 +2014,30 @@ void gui_update(dt_iop_module_t *self) if(!strcmp(g->entries[g->film_entry[f]].stock, "kodak_portra_400")) fi = f; } dt_bauhaus_combobox_set(g->film, fi); + /* _film_changed() is a no-op during the combobox-set above (it bails out + on darktable.gui->reset, which gui_update runs under, so programmatic + loads don't get treated as user edits / spawn spurious history items). + That means its scan_film "what should a reset target" bookkeeping -- + self->default_params->scan_film and the checkbox's own cached + "dt-toggle-default" -- never gets re-baselined on a fresh module load, + only when the user actually interacts with the film combobox. Left + alone, both stay at the compiled FALSE default after e.g. closing and + reopening darktable on an image using a positive/reversal film (which + has no print stage and needs scan_film TRUE): the checkbox itself still + shows correctly checked here (synced from p->scan_film below), but a + later double-click reset on it would silently flip scan_film back off. + Re-baseline both here too, exactly like _film_changed does -- but + WITHOUT touching p->scan_film itself, since the just-loaded value may + be a deliberate user override away from the film's natural mode and + must be preserved on load; only the reset target needs fixing. */ + if(fi < g->n_films) + { + const sf_prof_entry_t *e = &g->entries[g->film_entry[fi]]; + if(self->default_params) + ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = e->positive; + if(g->scan_film) + g_object_set_data(G_OBJECT(g->scan_film), "dt-toggle-default", GINT_TO_POINTER(e->positive)); + } int pi = 0; const char *target = (fi < g->n_films) ? g->entries[g->film_entry[fi]].target_print : NULL; for(int k = 0; k < g->n_papers; k++) From d2c0c5f64c213284219e9afd30ac786d4d2ac4f8 Mon Sep 17 00:00:00 2001 From: Arecsu Date: Fri, 17 Jul 2026 17:51:22 -0300 Subject: [PATCH 27/56] spektrafilm: group film/print dropdowns by color/monochrome sections Add sf_profile_channel_model() accessor and bw field to entry struct, then sort film stock dropdown into 4 labeled sections (negative color, positive color, negative monochrome, positive monochrome) and print paper dropdown into 2 sections (color, monochrome) using dt_bauhaus_combobox_add_section for bold unselectable headers. --- src/common/spektra_sim.c | 4 +-- src/common/spektra_sim.h | 1 + src/iop/spektrafilm.c | 77 +++++++++++++++++++++++++++++++++------- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 7f641cedfbc9..69f32cede5dc 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -50,8 +50,7 @@ #define SF_LOG_EPS 1e-10 -/* NEON 3×3 matrix multiply for 4 pixels at once via vmlaq_n_f32 (fused - multiply-add by scalar broadcast). Pure NEON — works on any ARMv8 target. */ +/* 4-pixel NEON 3×3 matrix multiply: vld3q → vmlaq_n ×3 → vst3q. */ #if defined(__ARM_NEON) #include @@ -799,6 +798,7 @@ const char *sf_profile_name(const sf_profile_t *p) { return p->name; } const char *sf_profile_stage(const sf_profile_t *p) { return p->stage; } const char *sf_profile_type(const sf_profile_t *p) { return p->type; } const char *sf_profile_target_print(const sf_profile_t *p) { return p->target_print; } +const char *sf_profile_channel_model(const sf_profile_t *p) { return p->channel_model; } /* ------------------------------------------------------------------------ */ /* parameter defaults & colour spaces */ diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index 0791526abca5..59fb907abb7b 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -209,6 +209,7 @@ const char *sf_profile_name(const sf_profile_t *p); const char *sf_profile_stage(const sf_profile_t *p); /* "filming" / "printing" */ const char *sf_profile_type(const sf_profile_t *p); /* "negative" / "positive" */ const char *sf_profile_target_print(const sf_profile_t *p); /* may be NULL */ +const char *sf_profile_channel_model(const sf_profile_t *p); /* "color" / "bw" / NULL */ /* -------------------------------------------------------------- params -- */ diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 965c3a956836..d1ad00146f73 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -173,6 +173,7 @@ typedef struct sf_prof_entry_t char target_print[SF_NAME_LEN]; gboolean printing; /* stage == "printing" */ gboolean positive; /* info.type == "positive" (slide / reversal) */ + gboolean bw; /* channel_model == "bw" */ uint32_t hash; } sf_prof_entry_t; @@ -795,6 +796,8 @@ static int sf_scan_profiles(sf_prof_entry_t *out, int maxn) if(tp) g_strlcpy(e->target_print, tp, SF_NAME_LEN); const char *type = sf_profile_type(prof); e->positive = (type && !strcmp(type, "positive")); + const char *cm = sf_profile_channel_model(prof); + e->bw = (cm && !strcmp(cm, "bw")); e->hash = sf_name_hash(e->stock); sf_profile_free(prof); n++; @@ -1866,9 +1869,9 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) if(darktable.gui->reset) return; dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; - const int fi = dt_bauhaus_combobox_get(g->film); - if(fi < 0 || fi >= g->n_films) return; - const sf_prof_entry_t *e = &g->entries[g->film_entry[fi]]; + const int fi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film)); + if(fi < 0) return; + const sf_prof_entry_t *e = &g->entries[fi]; p->film_hash = e->hash; /* Keep the "default" scan_film following this film's own positive/negative type too, not just the live params -- so a double-click reset (which @@ -1908,7 +1911,7 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) if(!strcmp(g->entries[g->paper_entry[k]].stock, e->target_print)) { ++darktable.gui->reset; - dt_bauhaus_combobox_set(g->paper, k); + dt_bauhaus_combobox_set_from_value(g->paper, g->paper_entry[k]); --darktable.gui->reset; break; } @@ -1920,9 +1923,9 @@ static void _paper_changed(GtkWidget *w, dt_iop_module_t *self) if(darktable.gui->reset) return; dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; - const int pi = dt_bauhaus_combobox_get(g->paper); - if(pi < 0 || pi >= g->n_papers) return; - p->paper_hash = g->entries[g->paper_entry[pi]].hash; + const int pi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->paper)); + if(pi < 0) return; + p->paper_hash = g->entries[pi].hash; dt_dev_add_history_item(darktable.develop, self, TRUE); } @@ -1989,14 +1992,62 @@ void gui_update(dt_iop_module_t *self) if(g->n_films == 0) dt_bauhaus_combobox_add(g->film, _("(no profiles found)")); else - for(int f = 0; f < g->n_films; f++) - dt_bauhaus_combobox_add(g->film, g->entries[g->film_entry[f]].name); + { + static const struct { int pos; int bw; const char *label; } film_groups[] = { + { 0, 0, N_("negative color") }, + { 1, 0, N_("positive color") }, + { 0, 1, N_("negative monochrome") }, + { 1, 1, N_("positive monochrome") }, + }; + for(int gi = 0; gi < 4; gi++) + { + gboolean first = TRUE; + for(int f = 0; f < g->n_films; f++) + { + const sf_prof_entry_t *e = &g->entries[g->film_entry[f]]; + if(e->positive != film_groups[gi].pos || e->bw != film_groups[gi].bw) + continue; + if(first) + { + dt_bauhaus_combobox_add_section(g->film, _(film_groups[gi].label)); + first = FALSE; + } + dt_bauhaus_combobox_add_full(g->film, e->name, + DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, + GINT_TO_POINTER(g->film_entry[f]), + NULL, TRUE); + } + } + } dt_bauhaus_combobox_clear(g->paper); if(g->n_papers == 0) dt_bauhaus_combobox_add(g->paper, _("(none)")); else - for(int k = 0; k < g->n_papers; k++) - dt_bauhaus_combobox_add(g->paper, g->entries[g->paper_entry[k]].name); + { + static const struct { int bw; const char *label; } paper_groups[] = { + { 0, N_("color") }, + { 1, N_("monochrome") }, + }; + for(int gi = 0; gi < 2; gi++) + { + gboolean first = TRUE; + for(int k = 0; k < g->n_papers; k++) + { + const sf_prof_entry_t *e = &g->entries[g->paper_entry[k]]; + if(e->bw != paper_groups[gi].bw) + continue; + if(first) + { + dt_bauhaus_combobox_add_section(g->paper, _(paper_groups[gi].label)); + first = FALSE; + } + dt_bauhaus_combobox_add_full(g->paper, e->name, + DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, + GINT_TO_POINTER(g->paper_entry[k]), + NULL, TRUE); + } + } + } int fi = 0; gboolean film_matched = FALSE; @@ -2013,7 +2064,7 @@ void gui_update(dt_iop_module_t *self) for(int f = 0; f < g->n_films; f++) if(!strcmp(g->entries[g->film_entry[f]].stock, "kodak_portra_400")) fi = f; } - dt_bauhaus_combobox_set(g->film, fi); + dt_bauhaus_combobox_set_from_value(g->film, g->film_entry[fi]); /* _film_changed() is a no-op during the combobox-set above (it bails out on darktable.gui->reset, which gui_update runs under, so programmatic loads don't get treated as user edits / spawn spurious history items). @@ -2047,7 +2098,7 @@ void gui_update(dt_iop_module_t *self) : (target && !strcmp(e->stock, target))) pi = k; } - dt_bauhaus_combobox_set(g->paper, pi); + dt_bauhaus_combobox_set_from_value(g->paper, g->paper_entry[pi]); /* toggle_from_params check buttons are NOT auto-synced by dt_bauhaus_update_from_field (it only handles sliders/combos), so set From 8fbc8a6bfeb466f41c4190e3deccd0c64f99e074 Mon Sep 17 00:00:00 2001 From: Arecsu Date: Sat, 18 Jul 2026 04:58:44 -0300 Subject: [PATCH 28/56] spektrafilm: natural sort for profile lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace g_ascii_strcasecmp with sf_nat_cmp in sf_scan_profiles so that embedded numbers compare numerically — "Vision3 50D" < "Vision3 200T" instead of lexicographic "200T" < "50D". --- src/iop/spektrafilm.c | 56 +++++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index d1ad00146f73..82e5e454baaa 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -758,6 +758,35 @@ static void sf_pack_dir(char *dst, size_t dstsz) snprintf(dst, dstsz, "%s/spektrafilm", cfg); } +/* natural (human) string compare: embedded numbers compared numerically + so "Vision3 50D" < "Vision3 200T" < "Vision3 500T" */ +static int sf_nat_cmp(const char *a, const char *b) +{ + for(;;) + { + if(*a == 0) return *b == 0 ? 0 : -1; + if(*b == 0) return 1; + int da = (unsigned)*a - '0' < 10u; + int db = (unsigned)*b - '0' < 10u; + if(da && db) + { + unsigned long va = 0, vb = 0; + while((unsigned)*a - '0' < 10u) { va = va * 10 + (*a - '0'); a++; } + while((unsigned)*b - '0' < 10u) { vb = vb * 10 + (*b - '0'); b++; } + if(va != vb) return va < vb ? -1 : 1; + } + else if(da != db) + return da ? -1 : 1; + else + { + int ca = g_ascii_tolower(*a); + int cb = g_ascii_tolower(*b); + if(ca != cb) return ca < cb ? -1 : 1; + a++; b++; + } + } +} + /* scan /spektrafilm/profiles/ (all .json files); reads only the info header of each profile (stock / name / stage / target_print) */ static int sf_scan_profiles(sf_prof_entry_t *out, int maxn) @@ -803,10 +832,11 @@ static int sf_scan_profiles(sf_prof_entry_t *out, int maxn) n++; } g_dir_close(gd); - /* stable alphabetical order by display name */ + /* natural order by display name (numbers compared numerically, + so "50D" < "200T" instead of lexicographic "200T" < "50D") */ for(int i = 0; i < n; i++) for(int j = i + 1; j < n; j++) - if(g_ascii_strcasecmp(out[j].name, out[i].name) < 0) + if(sf_nat_cmp(out[j].name, out[i].name) < 0) { sf_prof_entry_t t = out[i]; out[i] = out[j]; @@ -1993,7 +2023,7 @@ void gui_update(dt_iop_module_t *self) dt_bauhaus_combobox_add(g->film, _("(no profiles found)")); else { - static const struct { int pos; int bw; const char *label; } film_groups[] = { + static const struct { int pos; int bw; const char *label; } groups[] = { { 0, 0, N_("negative color") }, { 1, 0, N_("positive color") }, { 0, 1, N_("negative monochrome") }, @@ -2005,13 +2035,8 @@ void gui_update(dt_iop_module_t *self) for(int f = 0; f < g->n_films; f++) { const sf_prof_entry_t *e = &g->entries[g->film_entry[f]]; - if(e->positive != film_groups[gi].pos || e->bw != film_groups[gi].bw) - continue; - if(first) - { - dt_bauhaus_combobox_add_section(g->film, _(film_groups[gi].label)); - first = FALSE; - } + if(e->positive != groups[gi].pos || e->bw != groups[gi].bw) continue; + if(first) { dt_bauhaus_combobox_add_section(g->film, _(groups[gi].label)); first = FALSE; } dt_bauhaus_combobox_add_full(g->film, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(g->film_entry[f]), @@ -2024,7 +2049,7 @@ void gui_update(dt_iop_module_t *self) dt_bauhaus_combobox_add(g->paper, _("(none)")); else { - static const struct { int bw; const char *label; } paper_groups[] = { + static const struct { int bw; const char *label; } pgroups[] = { { 0, N_("color") }, { 1, N_("monochrome") }, }; @@ -2034,13 +2059,8 @@ void gui_update(dt_iop_module_t *self) for(int k = 0; k < g->n_papers; k++) { const sf_prof_entry_t *e = &g->entries[g->paper_entry[k]]; - if(e->bw != paper_groups[gi].bw) - continue; - if(first) - { - dt_bauhaus_combobox_add_section(g->paper, _(paper_groups[gi].label)); - first = FALSE; - } + if(e->bw != pgroups[gi].bw) continue; + if(first) { dt_bauhaus_combobox_add_section(g->paper, _(pgroups[gi].label)); first = FALSE; } dt_bauhaus_combobox_add_full(g->paper, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(g->paper_entry[k]), From fd7ea4c7416ee8edfeb1dd5d09dcf11bd4196dbf Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 19 Jul 2026 13:00:34 +0200 Subject: [PATCH 29/56] move away from darktable gaussians and use the same as spektrafilm --- data/kernels/spektrafilm.cl | 72 +++++++++++++ src/common/spektra_core.c | 194 +++++++++++++++++++++--------------- src/common/spektra_core.h | 29 +++++- src/iop/spektrafilm.c | 146 +++++++++++++++++---------- 4 files changed, 305 insertions(+), 136 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index af097cf70c99..c7d562401e7d 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -588,6 +588,78 @@ __kernel void spektrafilm_passthrough(__read_only image2d_t in, __write_only ima /* spatial-effect kernels (identical to the LUT module's; blurs host-side) */ /* ======================================================================== */ +/* Direct (exact) separable Gaussian convolution, one pass along rows or + * columns. `weights` holds 2*radius+1 normalized taps built host-side by + * sf_gauss_kernel_1d() (spektra_core.c/.h) -- the same kernel the CPU path + * convolves with, so GPU and CPU renders match. Clamp-to-edge boundary. + * Separate _row/_col entry points (rather than a stride parameter) keep the + * inner loop's memory access pattern explicit at the call site. Separate + * _1c/_4c variants avoid packing a lone scatter-stage channel into an + * otherwise-wasted float4. */ +__kernel void spektrafilm_gauss_row_4c(__global const float4 *src, __global float4 *dst, + const int w, const int h, + __global const float *weights, const int radius) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + float4 acc = (float4)(0.0f); + for(int k = -radius; k <= radius; k++) + { + int xx = x + k; + xx = xx < 0 ? 0 : (xx >= w ? w - 1 : xx); + acc += weights[k + radius] * src[(size_t)y * w + xx]; + } + dst[(size_t)y * w + x] = acc; +} + +__kernel void spektrafilm_gauss_col_4c(__global const float4 *src, __global float4 *dst, + const int w, const int h, + __global const float *weights, const int radius) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + float4 acc = (float4)(0.0f); + for(int k = -radius; k <= radius; k++) + { + int yy = y + k; + yy = yy < 0 ? 0 : (yy >= h ? h - 1 : yy); + acc += weights[k + radius] * src[(size_t)yy * w + x]; + } + dst[(size_t)y * w + x] = acc; +} + +__kernel void spektrafilm_gauss_row_1c(__global const float *src, __global float *dst, + const int w, const int h, + __global const float *weights, const int radius) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + float acc = 0.0f; + for(int k = -radius; k <= radius; k++) + { + int xx = x + k; + xx = xx < 0 ? 0 : (xx >= w ? w - 1 : xx); + acc += weights[k + radius] * src[(size_t)y * w + xx]; + } + dst[(size_t)y * w + x] = acc; +} + +__kernel void spektrafilm_gauss_col_1c(__global const float *src, __global float *dst, + const int w, const int h, + __global const float *weights, const int radius) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + float acc = 0.0f; + for(int k = -radius; k <= radius; k++) + { + int yy = y + k; + yy = yy < 0 ? 0 : (yy >= h ? h - 1 : yy); + acc += weights[k + radius] * src[(size_t)yy * w + x]; + } + dst[(size_t)y * w + x] = acc; +} + __kernel void spektrafilm_scatter_combine(__global const float4 *raw, __global const float4 *core, __global const float4 *tail, __global float4 *out, const int w, const int h, const float s_amount, diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index 06a3025e8ab9..19f2d50bced1 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -20,15 +20,16 @@ * * These two operations are the only parts of the film simulation that a static * LUT cannot carry, because they are neighbour-dependent. They live here (rather - * than in the inline-only spektra_core.h) so they can use darktable's Gaussian - * blur (dt_gaussian) instead of a hand-rolled kernel. The math is unchanged from - * the spektrafilm / agx-emulsion reference; only the blur backend differs, so - * edge handling now follows dt_gaussian (DT_IOP_GAUSSIAN_ZERO) rather than the - * previous edge-replicate. + * than in the inline-only spektra_core.h) so they can allocate scratch buffers + * for a proper direct Gaussian convolution (see _sf_gauss_kernel_1d below): a + * truncated, normalized kernel applied separably (row pass, transpose, row + * pass, transpose back), matching what the reference spektrafilm's own + * Gaussian blur actually does, rather than a recursive IIR approximation whose + * effective blur radius measurably departs from the requested sigma. Boundary + * handling is clamp-to-edge. */ #include "common/darktable.h" -#include "common/gaussian.h" #include "common/imagebuf.h" #include @@ -49,74 +50,83 @@ /* ------------------------------------------------------------------------ */ /* Row-major Gaussian blur with transpose */ /* ------------------------------------------------------------------------ */ -/* The standard IIR column pass has stride = width*4 bytes per pixel access, - striding beyond cache-line reach at any realistic resolution. Instead: - 1. Row-pass (causal + anticausal) — good stride, stays in cache +/* A naive column pass has stride = width*4 bytes per pixel access, striding + beyond cache-line reach at any realistic resolution. Instead: + 1. Row-pass (direct convolution) — good stride, stays in cache 2. Cache-blocked transpose — temp → scratch, sequential access both ways 3. Row-pass again on transposed data — second dimension, also good stride 4. Transpose back The transpose itself is tiled so it stays in L2. */ -/* IIR coefficients for Gaussian order-0 (the only order spektrafilm uses) */ -static void _sf_gauss_coeffs(const float sigma, float *a0, float *a1, float *a2, - float *a3, float *b1, float *b2, float *coefp, float *coefn) +/* Maximum kernel half-width: see SF_GAUSS_MAX_RADIUS in spektra_core.h. */ + +/* Build a normalized, truncated 1D Gaussian kernel -- truncate=4 sigma, + matching scipy.ndimage.gaussian_filter's own default (what the reference + spektrafilm actually blurs with) -- so this blur is exact to kernel- + truncation precision (~1e-7 relative error at 4 sigma) for ANY sigma. + This replaces a recursive (Young-van Vliet IIR) approximation, whose + actual blur radius measurably departs from the sigma it's asked for -- + confirmed by simulating its impulse response and comparing the resulting + second moment to the requested sigma: the discrepancy is a stable ~18% + too wide for sigma above ~1.5px, but the error changes character (and can + flip to too NARROW) below that, exactly the small-sigma range scatter's + core/tail and grain's clump blur typically fall into. A direct kernel has + no such regime-dependent error to chase. `kernel` must have room for + 2*max_radius+1 taps; returns the radius actually used. */ +int sf_gauss_kernel_1d(const float sigma, float *const kernel, const int max_radius) { - const float alpha = 1.695f / sigma; - const float ema = expf(-alpha); - const float ema2 = expf(-2.0f * alpha); - *b1 = -2.0f * ema; - *b2 = ema2; - const float k = (1.0f - ema) * (1.0f - ema) / (1.0f + (2.0f * alpha * ema) - ema2); - *a0 = k; - *a1 = k * (alpha - 1.0f) * ema; - *a2 = k * (alpha + 1.0f) * ema; - *a3 = -k * ema2; - *coefp = (*a0 + *a1) / (1.0f + *b1 + *b2); - *coefn = (*a2 + *a3) / (1.0f + *b1 + *b2); + int radius = (int)ceilf(4.0f * sigma); + if(radius < 1) radius = 1; + if(radius > max_radius) radius = max_radius; + double sum = 0.0; + const double inv2s2 = 1.0 / (2.0 * (double)sigma * (double)sigma); + for(int i = -radius; i <= radius; i++) + { + const double v = exp(-(double)(i * i) * inv2s2); + kernel[i + radius] = (float)v; + sum += v; + } + const float invsum = (float)(1.0 / sum); + for(int i = 0; i < 2 * radius + 1; i++) kernel[i] *= invsum; + return radius; } -/* Single-channel causal IIR pass over `len` elements, row-major (stride=1). - `in` and `out` may alias (same pointer). Range clamping to [vmin, vmax]. */ -static void _sf_gauss_causal(const float *in, float *out, const int len, - const float a0, const float a1, - const float b1, const float b2, - const float coefp, const float vmin, const float vmax) +/* Exact grain-clump renormalization: blurring the grain-delta buffer + necessarily reduces its variance (that's what turns per-pixel noise into + soft "clumps"), so the result needs scaling back up to keep the grain at + its intended RMS after clumping. For a normalized kernel k, convolving + white noise reduces variance by sum(k^2) along that axis; since the clump + blur is separable (the same kernel applied along both axes), the full 2D + variance factor is sum(k^2)^2, and the amplitude factor that undoes it is + 1/sum(k^2). Computed directly from the same kernel actually used for the + blur, so this is exact rather than an assumed closed-form approximation. */ +float sf_gauss_grain_renorm(const float sigma) { - float xp = CLAMP(in[0], vmin, vmax); - float yb = xp * coefp; - float yp = yb; - out[0] = yp; - for(int i = 1; i < len; i++) - { - const float xc = CLAMP(in[i], vmin, vmax); - const float yc = a0 * xc + a1 * xp - b1 * yp - b2 * yb; - xp = xc; - yb = yp; - yp = yc; - out[i] = yc; - } + if(sigma < 1e-6f) return 1.0f; + float kernel[2 * SF_GAUSS_MAX_RADIUS + 1]; + const int radius = sf_gauss_kernel_1d(sigma, kernel, SF_GAUSS_MAX_RADIUS); + double ss = 0.0; + for(int i = 0; i < 2 * radius + 1; i++) ss += (double)kernel[i] * kernel[i]; + return (float)(1.0 / ss); } -/* Single-channel anticausal IIR pass, merging into `out` (+=). Backward. */ -static void _sf_gauss_anticausal(const float *in, float *out, const int len, - const float a2, const float a3, - const float b1, const float b2, - const float coefn, const float vmin, const float vmax) +/* Direct 1D convolution along stride-1 elements, clamp-to-edge boundary (a + plain "hold the edge value" extension -- the exact boundary rule matters + far less than the kernel itself once the kernel is exact). `in` and + `out` must NOT alias. */ +static void _sf_gauss_convolve_1d(const float *const in, float *const out, const int len, + const float *const kernel, const int radius) { - float xn = CLAMP(in[len - 1], vmin, vmax); - float xa = xn; - float yn = xn * coefn; - float ya = yn; - out[len - 1] += yn; - for(int i = len - 2; i >= 0; i--) + for(int x = 0; x < len; x++) { - const float xc = CLAMP(in[i], vmin, vmax); - const float yc = a2 * xn + a3 * xa - b1 * yn - b2 * ya; - xa = xn; - xn = xc; - ya = yn; - yn = yc; - out[i] += yc; + float acc = 0.0f; + for(int k = -radius; k <= radius; k++) + { + int xx = x + k; + xx = xx < 0 ? 0 : (xx >= len ? len - 1 : xx); + acc += kernel[k + radius] * in[xx]; + } + out[x] = acc; } } @@ -140,51 +150,68 @@ static void _sf_transpose(const float *const src, float *const dst, } } -/* Single-channel IIR blur with cache-blocked transpose. `trans` is a w*h - intermediate buffer for the transpose (may be NULL: falls back to the - standard dt_gaussian path when trans is unavailable or dimensions are small). */ +/* Single-channel exact Gaussian blur with cache-blocked transpose. `trans` + is a w*h intermediate buffer for the transpose (may be NULL: the row/col + passes then operate directly without the transpose, which is fine for + small buffers where cache locality matters less). */ static void _blur_channel(float *const buf, const int w, const int h, const int c, - const float sigma, float *const plane, float *const trans) + const float sigma, float *const plane, float *const trans) { if(sigma < 1e-6f) return; const size_t npix = (size_t)w * h; for(size_t i = 0; i < npix; i++) plane[i] = buf[i * 3 + c]; - const float vmin = -1.0e9f, vmax = 1.0e9f; + + float kernel[2 * SF_GAUSS_MAX_RADIUS + 1]; + const int radius = sf_gauss_kernel_1d(sigma, kernel, SF_GAUSS_MAX_RADIUS); if(trans && w >= 16 && h >= 16) { - float a0, a1, a2, a3, b1, b2, coefp, coefn; - _sf_gauss_coeffs(sigma, &a0, &a1, &a2, &a3, &b1, &b2, &coefp, &coefn); float *const temp = trans; - /* Pass 1: row-major on each row → temp */ + /* Pass 1: row-major on each row -> temp */ DT_OMP_FOR() for(int j = 0; j < h; j++) { const size_t off = (size_t)j * w; - _sf_gauss_causal(plane + off, temp + off, w, a0, a1, b1, b2, coefp, vmin, vmax); - _sf_gauss_anticausal(plane + off, temp + off, w, a2, a3, b1, b2, coefn, vmin, vmax); + _sf_gauss_convolve_1d(plane + off, temp + off, w, kernel, radius); } - /* Transpose temp (w×h) → plane (h×w) */ + /* Transpose temp (w×h) -> plane (h×w) */ _sf_transpose(temp, plane, w, h); - /* Pass 2: row-major on transposed data → temp */ + /* Pass 2: row-major on transposed data -> temp */ DT_OMP_FOR() for(int j = 0; j < w; j++) { const size_t off = (size_t)j * h; - _sf_gauss_causal(plane + off, temp + off, h, a0, a1, b1, b2, coefp, vmin, vmax); - _sf_gauss_anticausal(plane + off, temp + off, h, a2, a3, b1, b2, coefn, vmin, vmax); + _sf_gauss_convolve_1d(plane + off, temp + off, h, kernel, radius); } - /* Transpose back temp (h×w) → plane (w×h) */ + /* Transpose back temp (h×w) -> plane (w×h) */ _sf_transpose(temp, plane, h, w); } else { - dt_gaussian_t *g = dt_gaussian_init(w, h, 1, &vmax, &vmin, sigma, DT_IOP_GAUSSIAN_ZERO); - if(g) + /* small buffer: skip the cache-blocking transpose, convolve directly */ + float *const row_tmp = dt_alloc_align_float((size_t)MAX(w, h)); + if(row_tmp) { - dt_gaussian_blur(g, plane, plane); - dt_gaussian_free(g); + for(int j = 0; j < h; j++) + { + _sf_gauss_convolve_1d(plane + (size_t)j * w, row_tmp, w, kernel, radius); + memcpy(plane + (size_t)j * w, row_tmp, sizeof(float) * w); + } + float *const col_in = dt_alloc_align_float((size_t)h); + float *const col_out = dt_alloc_align_float((size_t)h); + if(col_in && col_out) + { + for(int i = 0; i < w; i++) + { + for(int j = 0; j < h; j++) col_in[j] = plane[(size_t)j * w + i]; + _sf_gauss_convolve_1d(col_in, col_out, h, kernel, radius); + for(int j = 0; j < h; j++) plane[(size_t)j * w + i] = col_out[j]; + } + } + dt_free_align(col_in); + dt_free_align(col_out); } + dt_free_align(row_tmp); } for(size_t i = 0; i < npix; i++) buf[i * 3 + c] = plane[i]; } @@ -382,9 +409,10 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ * where the per-channel PSF K_s is a sum of radial exponentials grouped into * core / halo / bloom. Each exponential exp(-r/lambda)/(2*pi*lambda^2) has * radial RMS lambda*sqrt(2); we approximate each as a Gaussian of that sigma so - * the whole PSF becomes a weighted bank of Gaussian blurs (dt_gaussian), summed - * per channel. The strength->p_s table, geometric lambda progressions, group - * weights and warmth redistribution are ported exactly from spektrafilm; only + * the whole PSF becomes a weighted bank of Gaussian blurs (_blur_channel, this + * file's own exact direct convolution), summed per channel. The strength->p_s + * table, geometric lambda progressions, group weights and warmth + * redistribution are ported exactly from spektrafilm; only * the exponential->Gaussian per-component shape is an approximation (a soft * diffusion halo is dominated by scale, not tail shape). */ diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index 784186f24415..686630b55e89 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -28,8 +28,8 @@ #define SPEKTRA_INLINE static inline #endif -/* Spatial effects implemented in spektra_core.c (they use dt_gaussian and so - need darktable linkage; everything else in this header is inline). */ +/* Spatial effects implemented in spektra_core.c (they need dt_alloc_align_float + and OpenMP linkage; everything else in this header is inline). */ void sf_blur_plane3(float *buf, int w, int h, float sigma, float *plane); /* Two independently-controllable stages, matching upstream's HalationParams: * scatter_amount / scatter_scale -- stage 1, in-emulsion core+tail scatter @@ -539,6 +539,31 @@ SPEKTRA_INLINE uint32_t sf_pixel_seed(uint32_t xi, uint32_t yi, uint32_t chan) density[] is modified in place; d_ref is a representative mid density (mean of the film's d_min/d_max) used as the contrast pivot. */ #define SF_PRINT_EV_TO_DENSITY 0.30103f /* log10(2): one stop == 0.301 density */ + +/* Maximum kernel half-width (taps = 2*radius+1) for sf_gauss_kernel_1d below. + Caps cost for pathologically large sigma (very high film_format_mm + combined with very low resolution); every physically-plausible sigma this + module uses stays far under this. Shared by spektra_core.c's CPU direct + convolution and spektrafilm.c's GPU host-side weight upload, so both + dispatch the identical kernel for a given sigma. */ +#define SF_GAUSS_MAX_RADIUS 512 + +/* Build a normalized, truncated 1D Gaussian kernel -- truncate=4 sigma, + * matching scipy.ndimage.gaussian_filter's own default (what the reference + * spektrafilm actually blurs with), so the blur is exact to kernel- + * truncation precision for any sigma. `kernel` must have room for + * 2*max_radius+1 taps; returns the radius actually used. Exported so both + * the CPU convolution (spektra_core.c) and the GPU host-side weight upload + * (spektrafilm.c's process_cl) build the identical kernel for a given sigma. */ +int sf_gauss_kernel_1d(float sigma, float *kernel, int max_radius); + +/* Exact grain-clump variance-restoration factor for a separable 2D Gaussian + * blur of the given sigma (see _sf_gauss_kernel_1d / sf_gauss_grain_renorm + * in spektra_core.c): blurring the grain-delta buffer necessarily shrinks + * its variance, and this is the exact factor (1/sum(kernel^2), computed + * from the actual kernel used) that restores it to the intended RMS. */ +float sf_gauss_grain_renorm(float sigma); + #define SF_FILTRATION_TO_DENSITY 0.30f /* full filtration slider == 0.30 density */ SPEKTRA_INLINE void sf_apply_print_grading(float density[3], float d_ref, float print_exposure, float print_contrast, float filtration_m, diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 82e5e454baaa..8a16948da490 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -215,6 +215,7 @@ typedef struct dt_iop_spektrafilm_global_data_t int kernel_grain_gen, kernel_grain_add; int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough; int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply; + int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c; int kernel_max_partials, kernel_max_reduce, kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix; } dt_iop_spektrafilm_global_data_t; @@ -245,6 +246,10 @@ void init_global(dt_iop_module_so_t *self) gd->kernel_scatter_combine = dt_opencl_create_kernel(program, "spektrafilm_scatter_combine"); gd->kernel_accum = dt_opencl_create_kernel(program, "spektrafilm_accum"); gd->kernel_channel_extract = dt_opencl_create_kernel(program, "spektrafilm_channel_extract"); + gd->kernel_gauss_row_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_4c"); + gd->kernel_gauss_col_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_4c"); + gd->kernel_gauss_row_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_1c"); + gd->kernel_gauss_col_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_1c"); gd->kernel_channel_accum = dt_opencl_create_kernel(program, "spektrafilm_channel_accum"); gd->kernel_halation_apply = dt_opencl_create_kernel(program, "spektrafilm_halation_apply"); gd->kernel_max_partials = dt_opencl_create_kernel(program, "spektrafilm_max_partials"); @@ -272,6 +277,10 @@ void cleanup_global(dt_iop_module_so_t *self) dt_opencl_free_kernel(gd->kernel_scatter_combine); dt_opencl_free_kernel(gd->kernel_accum); dt_opencl_free_kernel(gd->kernel_channel_extract); + dt_opencl_free_kernel(gd->kernel_gauss_row_4c); + dt_opencl_free_kernel(gd->kernel_gauss_col_4c); + dt_opencl_free_kernel(gd->kernel_gauss_row_1c); + dt_opencl_free_kernel(gd->kernel_gauss_col_1c); dt_opencl_free_kernel(gd->kernel_channel_accum); dt_opencl_free_kernel(gd->kernel_halation_apply); dt_opencl_free_kernel(gd->kernel_max_partials); @@ -1196,7 +1205,7 @@ void tiling_callback(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f); const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; tiling->factor = 2.5f; /* 4 float4 buffers, but they alias in practice */ - tiling->factor_cl = 2.75f; /* + a 1ch scatter-stage scratch buffer (1/4 of a float4 buffer) */ + tiling->factor_cl = 4.0f; /* + gtmp4 (1 float4) + plane1 and gtmp1 (1ch each, 1/4 float4) */ tiling->maxbuf = 1.0f; tiling->maxbuf_cl = 1.0f; tiling->overhead = 0; @@ -1356,7 +1365,7 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c const float sigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); sf_blur_plane3(gbuf, w, h, sigma, scratch); - const float renorm = sqrtf(2.0f * sqrtf((float)M_PI) * fmaxf(sigma, 0.3f)); + const float renorm = sf_gauss_grain_renorm(fmaxf(sigma, 0.3f)); #ifdef _OPENMP #pragma omp parallel for default(none) shared(plane, gbuf) firstprivate(npix, renorm) \ schedule(static) @@ -1404,8 +1413,10 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c /* GPU path: mirrors process(). Per-pixel stages run as kernels on the validated float tables from sf_sim_gpu_export() (POCL-checked to ~1e-6 vs the CPU engine); the Gaussian blurs (diffusion bank, halation bounces, - coupler correction diffusion, grain clumps) use dt_gaussian on the float4 - buffers, exactly as the CPU path uses sf_blur_plane3. */ + coupler correction diffusion, grain clumps) use this file's own direct + separable convolution (spektrafilm_gauss_row/col_*c, weights built + host-side by sf_gauss_kernel_1d -- see spektra_core.c/.h), exactly as the + CPU path uses sf_blur_plane3. */ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_in, cl_mem dev_out, const dt_iop_roi_t *const roi_in, const dt_iop_roi_t *const roi_out) @@ -1503,50 +1514,84 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ 1/4 the per-blur cost of running the equivalent work on a float4 buffer, see the scatter stage below. */ cl_mem plane1 = dt_opencl_alloc_device_buffer(devid, npix * f); - dt_gaussian_cl_t *gauss = NULL; - dt_gaussian_cl_t *gauss1 = NULL; + /* row-pass intermediates for the direct (exact) separable convolution + below: dedicated buffers, distinct from every buffer a blur might be + called on in place, so the row pass never aliases its own input. */ + cl_mem gtmp4 = dt_opencl_alloc_device_buffer(devid, npix * f * 4); + cl_mem gtmp1 = dt_opencl_alloc_device_buffer(devid, npix * f); + /* kernel weights (2*SF_GAUSS_MAX_RADIUS+1 taps, built host-side by + sf_gauss_kernel_1d and rewritten before each blur dispatch below) */ + cl_mem gauss_w = dt_opencl_alloc_device_buffer(devid, sizeof(float) * (2 * SF_GAUSS_MAX_RADIUS + 1)); if(!mats_cl || !tc_cl || !cn_cl || !cb_cl || !sl_cl || !sx_cl || !sy_cl || !sz_cl || !sn_cl - || !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !acc || !plane1 + || !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !acc || !plane1 || !gtmp4 || !gtmp1 + || !gauss_w || (g->has_print && (!el_cl || !ex_cl || !ey_cl || !ez_cl || !en_cl || !em_cl || !pc_cl))) { err = CL_MEM_OBJECT_ALLOCATION_FAILURE; goto cleanup; } - - /* pre-allocated gaussian blur objects: avoids allocating temp buffers per - blur. gauss (4ch) serves everything except the scatter stage; gauss1 - (1ch) serves the scatter stage's real per-channel blurs. */ - { - const dt_aligned_pixel_t gmax = { 1e9f, 1e9f, 1e9f, 1e9f }; - const dt_aligned_pixel_t gmin = { -1e9f, -1e9f, -1e9f, -1e9f }; - gauss = dt_gaussian_init_cl(devid, w, h, 4, gmax, gmin, 1.0f, DT_IOP_GAUSSIAN_ZERO); - gauss1 = dt_gaussian_init_cl(devid, w, h, 1, gmax, gmin, 1.0f, DT_IOP_GAUSSIAN_ZERO); - } -#define SF_GAUSS_BLUR(buf, _sg, label) do { \ - if(gauss) { gauss->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss, (buf), (buf)); } \ - else { err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 4, (_sg)); } \ +/* Direct (exact) separable Gaussian blur: builds the exact kernel + host-side (the same sf_gauss_kernel_1d() the CPU path convolves with), + uploads it, then dispatches a row pass into a dedicated scratch buffer + followed by a col pass into `dst` -- safe even when dst==buf (in-place), + since the row pass fully consumes buf into scratch before the col pass + writes buf. No sigma-correction factor: unlike a recursive/IIR + approximation, a direct truncated kernel has no sigma-dependent error to + correct for in the first place. */ +#define SF_GAUSS_BLUR4(buf, _sg, label) do { \ + if(err == CL_SUCCESS) \ + { \ + float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ + const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \ + err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \ + sizeof(float) * (2 * _kr + 1), TRUE); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \ + CLARG(buf), CLARG(gtmp4), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \ + CLARG(gtmp4), CLARG(buf), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + } \ SF_CL_STEP(label); \ } while(0) -#define SF_GAUSS_BLUR_OP(src, dst, _sg, label) do { \ - if(gauss) { gauss->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss, (src), (dst)); } \ - else { err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \ - if(err == CL_SUCCESS) err = dt_gaussian_mean_blur_cl(devid, (dst), w, h, 4, (_sg)); } \ - SF_CL_STEP(label); \ - } while(0) -/* loop-safe variants: sets err, caller checks err/breaks */ -#define SF_GAUSS_BLUR_L(buf, _sg) do { \ - if(gauss) { gauss->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss, (buf), (buf)); } \ - else { err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 4, (_sg)); } \ - } while(0) -#define SF_GAUSS_BLUR_OP_L(src, dst, _sg) do { \ - if(gauss) { gauss->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss, (src), (dst)); } \ - else { err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \ - if(err == CL_SUCCESS) err = dt_gaussian_mean_blur_cl(devid, (dst), w, h, 4, (_sg)); } \ +/* loop-safe variant: sets err, caller checks err/breaks; src/dst may differ + (e.g. accumulating several blurred copies of the same source). */ +#define SF_GAUSS_BLUR4_OP_L(src, dst, _sg) do { \ + if(err == CL_SUCCESS) \ + { \ + float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ + const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \ + err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \ + sizeof(float) * (2 * _kr + 1), TRUE); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \ + CLARG(src), CLARG(gtmp4), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \ + CLARG(gtmp4), CLARG(dst), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + } \ } while(0) /* single-channel in-place blur (scatter stage only, on plane1) */ -#define SF_GAUSS_BLUR_1CH_L(buf, _sg) do { \ - if(gauss1) { gauss1->sigma = (_sg); err = dt_gaussian_blur_cl_buffer(gauss1, (buf), (buf)); } \ - else { err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 1, (_sg)); } \ +#define SF_GAUSS_BLUR1_L(buf, _sg) do { \ + if(err == CL_SUCCESS) \ + { \ + float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ + const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \ + err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \ + sizeof(float) * (2 * _kr + 1), TRUE); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_1c, w, h, \ + CLARG(buf), CLARG(gtmp1), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_1c, w, h, \ + CLARG(gtmp1), CLARG(buf), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + } \ } while(0) /* ---- 1) expose: input image -> linear film raw exposure ---------------- */ @@ -1594,9 +1639,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ for(int j = 0; j < plan.n; j++) { const float sigma = fmaxf(plan.sigma_um[j] * dsc / pixel_um, 1e-3f); - err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, tmpa, 0, 0, npix * f * 4); - if(err != CL_SUCCESS) break; - err = dt_gaussian_mean_blur_cl(devid, tmpa, w, h, 4, sigma); + SF_GAUSS_BLUR4_OP_L(plane, tmpa, sigma); if(err != CL_SUCCESS) break; const int reset = (j == 0); const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j]; @@ -1636,7 +1679,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(plane), CLARG(plane1), CLARG(w), CLARG(h), CLARG(c)); if(err != CL_SUCCESS) break; - SF_GAUSS_BLUR_1CH_L(plane1, fmaxf(sc_core[c] * sscl / pixel_um, 1e-6f)); + SF_GAUSS_BLUR1_L(plane1, fmaxf(sc_core[c] * sscl / pixel_um, 1e-6f)); if(err != CL_SUCCESS) break; const float core_weight = 1.0f; const int core_reset = (c == 0); @@ -1653,7 +1696,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(c)); if(err != CL_SUCCESS) break; const float sigma = fmaxf(rat[g3] * sc_tail[c] * sscl / pixel_um, 1e-6f); - SF_GAUSS_BLUR_1CH_L(plane1, sigma); + SF_GAUSS_BLUR1_L(plane1, sigma); if(err != CL_SUCCESS) break; const int reset = (g3 == 0 && c == 0); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_accum, w, h, @@ -1684,7 +1727,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ const float dec[3] = { 1.0f/1.75f, 0.5f/1.75f, 0.25f/1.75f }; for(int k = 1; k <= N; k++) { - SF_GAUSS_BLUR_OP_L(plane, plane2, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f)); + SF_GAUSS_BLUR4_OP_L(plane, plane2, fmaxf(first_sigma * hscl * sqrtf((float)k) / pixel_um, 1e-3f)); if(err != CL_SUCCESS) break; const int reset = (k == 1); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(plane2), @@ -1733,7 +1776,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ { if(sig[g3] > 0.1f) { - SF_GAUSS_BLUR_OP_L(acc, plane2, sig[g3]); + SF_GAUSS_BLUR4_OP_L(acc, plane2, sig[g3]); if(err != CL_SUCCESS) break; } else @@ -1750,7 +1793,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ } } else if(csigma > 0.1f) - SF_GAUSS_BLUR(acc, csigma, "coupler blur"); + SF_GAUSS_BLUR4(acc, csigma, "coupler blur"); } cl_mem corr_buf = (g->coupler_tail_w > 0.0f) ? tmpa : acc; err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_develop, w, h, CLARG(plane), @@ -1779,8 +1822,8 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_CL_STEP("grain gen"); const float gsigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); - SF_GAUSS_BLUR(tmpa, gsigma, "grain blur"); - const float grenorm = sqrtf(2.0f * sqrtf((float)M_PI) * fmaxf(gsigma, 0.3f)); + SF_GAUSS_BLUR4(tmpa, gsigma, "grain blur"); + const float grenorm = sf_gauss_grain_renorm(fmaxf(gsigma, 0.3f)); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2), CLARG(tmpa), CLARG(w), CLARG(h), CLARG(grenorm)); SF_CL_STEP("grain add"); @@ -1808,7 +1851,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ for(int j = 0; j < pplan.n; j++) { const float sigma = fmaxf(pplan.sigma_um[j] * pdsc / pixel_um, 1e-3f); - SF_GAUSS_BLUR_OP_L(plane, tmpa, sigma); + SF_GAUSS_BLUR4_OP_L(plane, tmpa, sigma); if(err != CL_SUCCESS) break; const int reset = (j == 0); const float wr = pplan.wr[j], wg = pplan.wg[j], wb = pplan.wb[j]; @@ -1845,8 +1888,6 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_CL_STEP("scan"); cleanup: - if(gauss) dt_gaussian_free_cl(gauss); - if(gauss1) dt_gaussian_free_cl(gauss1); dt_opencl_release_mem_object(mats_cl); dt_opencl_release_mem_object(tc_cl); dt_opencl_release_mem_object(cn_cl); @@ -1870,6 +1911,9 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ dt_opencl_release_mem_object(tmpa); dt_opencl_release_mem_object(acc); dt_opencl_release_mem_object(plane1); + dt_opencl_release_mem_object(gtmp4); + dt_opencl_release_mem_object(gtmp1); + dt_opencl_release_mem_object(gauss_w); return err; } #endif /* HAVE_OPENCL */ From aaa7b105136ddb0f0156c38878a5dc2de2c2083a Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 19 Jul 2026 13:01:52 +0200 Subject: [PATCH 30/56] move grain to multi sublayer --- data/kernels/spektrafilm.cl | 103 ++++++++++ src/common/spektra_sim.c | 372 +++++++++++++++++++++++++++++++++++- src/common/spektra_sim.h | 49 ++++- src/iop/spektrafilm.c | 107 +++++++++-- 4 files changed, 610 insertions(+), 21 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index c7d562401e7d..9eb33d3f3d32 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -452,6 +452,109 @@ __kernel void spektrafilm_grain_gen(__global const float4 *dens, __global float4 grain_buf[k] = (float4)(gd[0], gd[1], gd[2], 0.f); } +/* Inverse-lookup: given the already-computed NET total density `target` for + * one channel, find its fractional position on the [0, n) exposure-grid + * axis by searching `arr` (assumed monotonic -- guaranteed for a sum of + * same-signed CDF terms) via binary search plus linear interpolation. Walks + * a strided (non-contiguous) column of a [n][...] table without copying it + * out first -- the same "find where the total curve reads D" step + * spektrafilm's own interp_density_cmy_layers_channel performs. */ +static float sf_cl_grain_curve_inverse(__global const float *arr, int n, int stride, float target) +{ + const int increasing = arr[(n - 1) * stride] >= arr[0]; + int lo = 0, hi = n - 1; + while(hi - lo > 1) + { + const int mid = (lo + hi) / 2; + const float v = arr[mid * stride]; + if((increasing && v <= target) || (!increasing && v >= target)) lo = mid; + else hi = mid; + } + const float v0 = arr[lo * stride], v1 = arr[hi * stride]; + const float denom = v1 - v0; + float frac = (fabs(denom) > 1e-9f) ? (target - v0) / denom : 0.0f; + frac = clamp(frac, 0.0f, 1.0f); + return (float)lo + frac; +} + +/* Linearly interpolate a strided per-index array at the continuous index + * `pos` produced by sf_cl_grain_curve_inverse above. */ +static float sf_cl_grain_curve_sample(__global const float *arr, int n, int stride, float pos) +{ + int i0 = (int)pos; + if(i0 < 0) i0 = 0; + if(i0 > n - 2) i0 = (n - 2 < 0) ? 0 : n - 2; + const float frac = pos - (float)i0; + return arr[i0 * stride] * (1.0f - frac) + arr[(i0 + 1) * stride] * frac; +} + +/* Multi-sublayer grain delta (see sf_grain_delta_ml, spektra_sim.c): only + * dispatched when n_sub > 1 -- a film whose own fitted density-curve model + * has more than one emulsion sub-layer. For n_sub<=1 the host dispatches + * spektrafilm_grain_gen above instead, unchanged. Sums each sub-layer's + * independent particle draw, recovering that sub-layer's own density by + * inverse-interpolating the self-consistent summed sub-layer curve at the + * exposure position the already-computed total density corresponds to. + * layer_curve is [nle][max_sub][3] row-major, layer_curve_total is + * [nle][3]; max_sub (SF_GRAIN_MAX_SUBLAYERS host-side) is the table's + * fixed stride regardless of how many sub-layers n_sub actually uses. */ +__kernel void spektrafilm_grain_gen_ml(__global const float4 *dens, __global float4 *grain_buf, + const int w, const int h, const float grain_amount, + const int roi_x, const int roi_y, const int mono, + const int n_sub, const int nle, const int max_sub, + const float dmin0, const float dmin1, const float dmin2, + const float unf0, const float unf1, const float unf2, + __global const float *layer_dmax, + __global const float *layer_npart, + __global const float *layer_dmin, + __global const float *layer_curve_total, + __global const float *layer_curve) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 d4 = dens[k]; + const float dd[3] = { d4.x, d4.y, d4.z }; + const float dmn[3] = { dmin0, dmin1, dmin2 }; + const float unf[3] = { unf0, unf1, unf2 }; + const int lstride = max_sub * 3; + + if(mono) + { + const float dm = (dd[0] + dd[1] + dd[2]) / 3.0f; + const float pos = sf_cl_grain_curve_inverse(layer_curve_total + 1, nle, 3, dm); + float total_abs = 0.0f; + for(int sl = 0; sl < n_sub; sl++) + { + const float raw = sf_cl_grain_curve_sample(layer_curve + sl * 3 + 1, nle, lstride, pos); + const float d_abs = raw + layer_dmin[sl * 3 + 1]; + const uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), (uint)(sl * 10)); + total_abs += sf_layer_particle(d_abs, layer_dmax[sl * 3 + 1], layer_npart[sl * 3 + 1], unf[1], seed); + } + const float gval = total_abs - dmn[1]; + const float dl = (gval - dm) * grain_amount; + grain_buf[k] = (float4)(dl, dl, dl, 0.f); + return; + } + + float gd[3]; + for(int c = 0; c < 3; c++) + { + const float pos = sf_cl_grain_curve_inverse(layer_curve_total + c, nle, 3, dd[c]); + float total_abs = 0.0f; + for(int sl = 0; sl < n_sub; sl++) + { + const float raw = sf_cl_grain_curve_sample(layer_curve + sl * 3 + c, nle, lstride, pos); + const float d_abs = raw + layer_dmin[sl * 3 + c]; + const uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), (uint)(c + sl * 10)); + total_abs += sf_layer_particle(d_abs, layer_dmax[sl * 3 + c], layer_npart[sl * 3 + c], unf[c], seed); + } + const float g = total_abs - dmn[c]; + gd[c] = (g - dd[c]) * grain_amount; + } + grain_buf[k] = (float4)(gd[0], gd[1], gd[2], 0.f); +} + __kernel void spektrafilm_grain_add(__global float4 *dens_buf, __global const float4 *grain_buf, const int w, const int h, const float renorm) { diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 69f32cede5dc..1b55687b69eb 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -34,10 +34,23 @@ #include "spektra_sim.h" +#include "common/darktable.h" +#include "common/imagebuf.h" + #include #include #include +/* spektra_core.h's bundle-loader half needs file-IO/locale helpers; darktable + poisons bare libc fopen, so map them to glib here (this .c does not itself + read bundles, but the shared header must compile in this translation + unit) -- same mapping spektra_core.c itself uses. */ +#define SF_READ_FILE(path, out, len) \ + (g_file_get_contents((path), (out), (len), NULL) ? 0 : -1) +#define SF_FREE_FILE(buf) g_free(buf) +#define SF_STRTOD(s, end) g_ascii_strtod((s), (end)) +#include "spektra_core.h" + #include /* ensure C99 math functions for SF_POW10F/SF_LOG10F (exp2f, log2f) */ #if !defined(exp2f) && !defined(_GNU_SOURCE) @@ -288,6 +301,41 @@ struct sf_sim_t pack has no per-film grain entry (see sf_sim_build). */ double grain_rms[3], grain_uniformity[3]; + /* Multi-sublayer grain model (matches spektrafilm's own "always multilayer" + particle model -- see grain.py's module doc and _realized_peak_correction: + summing several independent sub-layer particle draws, calibrated so their + combined peak variance matches the catalogue RMS-granularity, is what + keeps the realized grain amplitude correct; a single-layer approximation + alone was found to differ by as much as the ~1.5x the reference's own + docs warn a naive single-layer rule would carry). Active only when the + film's own fitted density-curve model (curves_model) has more than one + layer; grain_n_sublayers stays at 1 (the existing single-layer behavior, + using the plain film_dmax/film_dmin/curves_norm already computed above) + for every stock whose curve fit is single-layer to begin with -- which + is not an approximation in that case, since upstream treats a + single-layer curve model as the trivial one-sub-layer case of the same + general model, not a separate simplified path. */ + int grain_n_sublayers; + double grain_particle_scale[SF_GRAIN_MAX_SUBLAYERS]; /* coarsest sub-layer == 1.0 */ + /* per-sublayer absolute (fog-inclusive) Dmax and particle count, precomputed + once from the fitted curve model, same rms_granularity-driven area formula + as the single-layer path but corrected for how the sub-layers combine + (see spektra_sim.c's _sf_grain_layers_from_curves). */ + double grain_layer_dmax[SF_GRAIN_MAX_SUBLAYERS][3]; + double grain_layer_npart[SF_GRAIN_MAX_SUBLAYERS][3]; + /* per-sublayer density floor (density_max_fractions[sl] * grain_density_min), + needed to convert the interpolated net sub-layer density back to the + absolute value sf_layer_particle() expects. */ + double grain_layer_dmin[SF_GRAIN_MAX_SUBLAYERS][3]; + /* raw (un-summed) per-sub-layer curve terms across the exposure grid, and + their sum (self-consistent by construction, used as the lookup axis to + recover each sub-layer's density from the already-computed *total* + density at render time -- the same inverse lookup spektrafilm's own + interp_density_cmy_layers_channel performs, just against a table built + here instead of scipy). Both indexed [le][sublayer][channel]. */ + float grain_layer_curve[SF_NLE][SF_GRAIN_MAX_SUBLAYERS][3]; + float grain_layer_curve_total[SF_NLE][3]; + /* per-film halation preset (film_render_defaults[stock].halation in the pack): back-reflection strength per channel + first-bounce sigma. Defaults to SF_HALATION_STRENGTH_DEFAULT_* / SF_HALATION_SIGMA_DEFAULT_UM @@ -584,12 +632,16 @@ bool sf_pack_film_coupler_diffusion(const sf_pack_t *pack, const char *film_stoc /* Per-film grain catalogue data: film_render_defaults[stock].grain in the pack, exported verbatim from spektrafilm's GrainParams (rms_granularity, - uniformity, density_min — see spektrafilm_export_data.py's _grain_export). - Any output pointer may be NULL. Returns false and leaves outputs untouched - if the stock has no "grain" entry (older packs, or the stock predates - per-film grain), so the caller can keep its fallback constants. */ + uniformity, density_min, particle_scale_sublayers — see + spektrafilm_export_data.py's _grain_export). Any output pointer may be + NULL. particle_scale[]/n_scale are optional (older packs, or stocks with + only one emulsion sub-layer, have no "particle_scale_sublayers" entry; + *n_scale is left at 0 in that case so the caller falls back to its + single-layer default). Returns false and leaves outputs untouched if the + stock has no "grain" entry at all. */ bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock, - double rms[3], double uniformity[3], double density_min[3]) + double rms[3], double uniformity[3], double density_min[3], + double particle_scale[SF_GRAIN_MAX_SUBLAYERS], int *n_scale) { if(!pack || !pack->film_defaults) return false; JsonObject *db = json_node_get_object(pack->film_defaults); @@ -607,6 +659,18 @@ bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock, ok = json_read_darray(gr, "uniformity", uniformity, 3) || ok; if(density_min && json_object_has_member(gr, "density_min")) ok = json_read_darray(gr, "density_min", density_min, 3) || ok; + if(n_scale) *n_scale = 0; + if(particle_scale && n_scale && json_object_has_member(gr, "particle_scale_sublayers")) + { + JsonNode *node = json_object_get_member(gr, "particle_scale_sublayers"); + JsonArray *arr = (node && JSON_NODE_HOLDS_ARRAY(node)) ? json_node_get_array(node) : NULL; + const int n = arr ? MIN((int)json_array_get_length(arr), SF_GRAIN_MAX_SUBLAYERS) : 0; + if(n > 0) + { + for(int i = 0; i < n; i++) particle_scale[i] = json_array_get_double_element(arr, i); + *n_scale = n; + } + } return ok; } @@ -1353,6 +1417,153 @@ static void eval_cdfs_channel(double *out, const double *le, int nle, const doub /* [mc] apply_print_curves_morph without developer exhaustion. * With morph inactive this reduces to a plain model evaluation. */ +/* Build the multi-sublayer grain model from a film's fitted density-curve + * model (sf_curves_model_t) -- spektrafilm's own grain.py documents the + * model as "always multilayer": several independent emulsion sub-layers are + * sampled separately and summed, calibrated (_realized_peak_correction / + * _coarsest_area_from_curves in grain.py) so their COMBINED peak variance + * matches the catalogue RMS-granularity. A single-sublayer curve fit is + * just the trivial one-sublayer case of the same model, so it needs no + * separate branch here beyond n_layers<=1 meaning the loops below run once. + * + * `density_min` is the film's overall (fog) floor per channel + * (p->grain_density_min, already updated in place by sf_pack_film_grain); + * `uniformity`/`rms` are that same per-film grain catalogue data; + * `particle_scale`/`n_scale` come from the pack's particle_scale_sublayers + * (falls back to a single coarsest layer, scale 1.0, if the pack has none or + * the stock's curve fit itself is single-layer). + * + * Writes s->grain_n_sublayers, grain_particle_scale, grain_layer_dmax, + * grain_layer_npart, grain_layer_dmin, grain_layer_curve and + * grain_layer_curve_total. */ +static void _sf_build_grain_layers(sf_sim_t *s, const sf_profile_t *film, + const double density_min[3], const double uniformity[3], + const double rms[3], const double particle_scale[SF_GRAIN_MAX_SUBLAYERS], + int n_scale) +{ + const sf_curves_model_t *m = &film->curves_model; + const int nl = (m->n_layers > 1 && n_scale > 1) ? MIN(m->n_layers, MIN(n_scale, SF_GRAIN_MAX_SUBLAYERS)) + : 1; + s->grain_n_sublayers = nl; + const float ref_um = SF_GRAIN_REF_UM; + const double pix_ref = (double)ref_um * (double)ref_um; + const double A48 = 3.14159265358979 * 24.0 * 24.0; + + if(nl <= 1) + { + /* trivial one-sublayer case: reuse the plain total curve already + computed above (s->curves_norm) verbatim, so behavior for any stock + whose own curve fit is single-layer (or has no fit / no per-stock + particle_scale_sublayers at all) is byte-for-byte identical to the + pre-existing single-layer path -- this is not an approximate + fallback, it's what upstream's own model reduces to in this case. */ + s->grain_particle_scale[0] = 1.0; + for(int c = 0; c < 3; c++) + { + double mx = -1e300; + for(int i = 0; i < SF_NLE; i++) + { + const double v = s->curves_norm[i][c]; + s->grain_layer_curve[i][0][c] = (float)v; + s->grain_layer_curve_total[i][c] = (float)v; + if(v > mx) mx = v; + } + s->grain_layer_dmin[0][c] = density_min[c]; + s->grain_layer_dmax[0][c] = mx + density_min[c]; + const double d_ref_c = 1.0 + density_min[c]; + const double sig = rms[c] / 1000.0; + const double denom = fmax(d_ref_c * (s->grain_layer_dmax[0][c] - uniformity[c] * d_ref_c), 1e-6); + const double a_grain = sig * sig * A48 / denom; + s->grain_layer_npart[0][c] = pix_ref / fmax(a_grain, 1e-4); + } + return; + } + + /* multi-sublayer case: evaluate each sub-layer's CDF term separately (no + morph -- the film side has no live chemistry re-evaluation, only the + print does, see build_print_curves), matching grain.py's + interp_density_cmy_layers_channel/_coarsest_area_from_curves inputs. */ + const int positive = s->film_positive; + double layer_curve[SF_NLE][SF_GRAIN_MAX_SUBLAYERS][3]; + double layer_max_raw[SF_GRAIN_MAX_SUBLAYERS][3]; + for(int c = 0; c < 3; c++) + { + double centers[8], amps[8], sigmas[8]; + memcpy(centers, m->centers[c], sizeof(double) * nl); + memcpy(amps, m->amplitudes[c], sizeof(double) * nl); + memcpy(sigmas, m->sigmas[c], sizeof(double) * nl); + for(int l = 0; l < nl; l++) + { + double mx = -1e300; + for(int i = 0; i < SF_NLE; i++) + { + double z = (film->log_exposure[i] - centers[l]) / sigmas[l]; + if(positive) z = -z; + const double v = amps[l] * norm_cdf(z); + layer_curve[i][l][c] = v; + if(v > mx) mx = v; + } + layer_max_raw[l][c] = mx; + } + } + + double density_max_total_raw[3], density_max_fractions[SF_GRAIN_MAX_SUBLAYERS][3]; + double layer_dmin[SF_GRAIN_MAX_SUBLAYERS][3], layer_dmax[SF_GRAIN_MAX_SUBLAYERS][3]; + for(int c = 0; c < 3; c++) + { + double tot = 0.0; + for(int l = 0; l < nl; l++) tot += layer_max_raw[l][c]; + density_max_total_raw[c] = fmax(tot, 1e-9); + for(int l = 0; l < nl; l++) + { + density_max_fractions[l][c] = layer_max_raw[l][c] / density_max_total_raw[c]; + layer_dmin[l][c] = density_max_fractions[l][c] * density_min[c]; + layer_dmax[l][c] = layer_max_raw[l][c] + layer_dmin[l][c]; + } + } + + /* _coarsest_area_from_curves: peak (over the exposure grid) of the summed + per-sublayer variance shape, weighted by particle_scale/fraction. */ + double peak[3] = { 0.0, 0.0, 0.0 }; + for(int c = 0; c < 3; c++) + { + for(int i = 0; i < SF_NLE; i++) + { + double sum_sl = 0.0; + for(int l = 0; l < nl; l++) + { + const double d_abs = layer_curve[i][l][c] + layer_dmin[l][c]; + const double weight = particle_scale[l] / density_max_fractions[l][c]; + sum_sl += weight * d_abs * (layer_dmax[l][c] - uniformity[c] * d_abs); + } + if(sum_sl > peak[c]) peak[c] = sum_sl; + } + peak[c] = fmax(peak[c], 1e-9); + } + + for(int c = 0; c < 3; c++) + { + const double sigma_in = rms[c] / 1000.0; + const double a_coarsest = sigma_in * sigma_in * A48 / peak[c]; + for(int l = 0; l < nl; l++) + { + const double particle_area = a_coarsest * particle_scale[l]; + s->grain_layer_npart[l][c] = pix_ref * density_max_fractions[l][c] / fmax(particle_area, 1e-9); + s->grain_layer_dmax[l][c] = layer_dmax[l][c]; + s->grain_layer_dmin[l][c] = layer_dmin[l][c]; + for(int i = 0; i < SF_NLE; i++) s->grain_layer_curve[i][l][c] = (float)layer_curve[i][l][c]; + } + for(int i = 0; i < SF_NLE; i++) + { + double t = 0.0; + for(int l = 0; l < nl; l++) t += layer_curve[i][l][c]; + s->grain_layer_curve_total[i][c] = (float)t; + } + } + for(int l = nl; l < SF_GRAIN_MAX_SUBLAYERS; l++) s->grain_particle_scale[l] = 0.0; + for(int l = 0; l < nl; l++) s->grain_particle_scale[l] = particle_scale[l]; +} + static void build_print_curves(double (*curves)[3], const sf_profile_t *print, const sf_sim_params_t *p) { @@ -1951,8 +2162,18 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, s->grain_rms[c] = legacy_rms[c]; s->grain_uniformity[c] = legacy_unif[c]; } + /* particle_scale_sublayers defaults to spektrafilm's own GrainParams + default [1.0, 0.5, 0.25] (coarsest == 1) when the pack has none for + this stock, so a film whose curve fit IS multilayer still gets a + physically reasonable sub-layer split rather than silently + collapsing to one layer for lack of catalogue data. */ + double particle_scale[SF_GRAIN_MAX_SUBLAYERS] = { 1.0, 0.5, 0.25, 0, 0, 0, 0, 0 }; + int n_scale = 3; sf_pack_film_grain(pack, film->stock, s->grain_rms, s->grain_uniformity, - p->grain_density_min); + p->grain_density_min, particle_scale, &n_scale); + if(n_scale <= 0) n_scale = 3; /* pack had a "grain" entry but no scale array */ + _sf_build_grain_layers(s, film, p->grain_density_min, s->grain_uniformity, + s->grain_rms, particle_scale, n_scale); } /* [cp] coupler matrix: donor row -> receiver column, scaled by amount */ s->couplers_active = p->couplers_active; @@ -2650,6 +2871,20 @@ sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *s) g->film_positive = s->film_positive; g->couplers_active = s->couplers_active; + g->grain_n_sublayers = s->grain_n_sublayers; + for(int l = 0; l < SF_GRAIN_MAX_SUBLAYERS; l++) + { + g->grain_particle_scale[l] = (float)s->grain_particle_scale[l]; + for(int c = 0; c < 3; c++) + { + g->grain_layer_dmax[l][c] = (float)s->grain_layer_dmax[l][c]; + g->grain_layer_npart[l][c] = (float)s->grain_layer_npart[l][c]; + g->grain_layer_dmin[l][c] = (float)s->grain_layer_dmin[l][c]; + } + } + g->grain_layer_curve = &s->grain_layer_curve[0][0][0]; /* borrowed */ + g->grain_layer_curve_total = &s->grain_layer_curve_total[0][0]; /* borrowed */ + g->has_print = s->has_print; g->steps = s->lut_steps; const size_t n3 = (size_t)s->lut_steps * s->lut_steps * s->lut_steps * 3; @@ -2775,3 +3010,128 @@ void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], } } +void sf_sim_grain_layers(const sf_sim_t *sim, sf_grain_layers_t *out) +{ + if(!sim) + { + /* no sim: single trivial layer, empty lookup table (caller must guard + n==1 && layer_curve==NULL by using its own legacy single-layer path, + exactly as sf_sim_film_grain3's callers already do without a sim). */ + out->n = 1; + out->particle_scale[0] = 1.0; + out->layer_curve = NULL; + out->layer_curve_total = NULL; + return; + } + out->n = sim->grain_n_sublayers; + for(int l = 0; l < SF_GRAIN_MAX_SUBLAYERS; l++) + { + out->particle_scale[l] = sim->grain_particle_scale[l]; + for(int c = 0; c < 3; c++) + { + out->layer_dmax[l][c] = sim->grain_layer_dmax[l][c]; + out->layer_npart[l][c] = sim->grain_layer_npart[l][c]; + out->layer_dmin[l][c] = sim->grain_layer_dmin[l][c]; + } + } + out->layer_curve = sim->grain_layer_curve; + out->layer_curve_total = sim->grain_layer_curve_total; +} + +/* Given the already-computed NET total density `target` for one channel, + * find its fractional position on the [0, SF_NLE) exposure-grid axis by + * searching `arr` (assumed monotonic -- guaranteed here, since it's a sum of + * same-signed-CDF terms that all move the same direction over the grid) via + * binary search plus linear interpolation between the two straddling grid + * points. `stride` lets this walk a non-contiguous column of a [SF_NLE][..] + * array (e.g. one channel of grain_layer_curve_total) without copying it + * out first. This is the same "find where the total curve reads D" step + * spektrafilm's own interp_density_cmy_layers_channel performs (there, via + * a table built ad hoc each call; here, against grain_layer_curve_total, + * built once at sim-build time). */ +static float _sf_grain_curve_inverse(const float *arr, int n, int stride, float target) +{ + const int increasing = arr[(n - 1) * stride] >= arr[0]; + int lo = 0, hi = n - 1; + while(hi - lo > 1) + { + const int mid = (lo + hi) / 2; + const float v = arr[mid * stride]; + if((increasing && v <= target) || (!increasing && v >= target)) lo = mid; + else hi = mid; + } + const float v0 = arr[lo * stride], v1 = arr[hi * stride]; + const float denom = v1 - v0; + float frac = (fabsf(denom) > 1e-9f) ? (target - v0) / denom : 0.0f; + if(frac < 0.0f) frac = 0.0f; + if(frac > 1.0f) frac = 1.0f; + return (float)lo + frac; +} + +/* Linearly interpolate a (possibly strided) per-index array at the + * continuous index `pos` produced by _sf_grain_curve_inverse above. */ +static float _sf_grain_curve_sample(const float *arr, int n, int stride, float pos) +{ + int i0 = (int)pos; + if(i0 < 0) i0 = 0; + if(i0 > n - 2) i0 = (n - 2 < 0) ? 0 : n - 2; + const float frac = pos - (float)i0; + return arr[i0 * stride] * (1.0f - frac) + arr[(i0 + 1) * stride] * frac; +} + +/* Multi-sublayer grain delta: like sf_grain_delta_dmax (spektra_core.h), but + * for a film whose own fitted density-curve model has more than one + * emulsion sub-layer (sf_sim_grain_layers().n > 1). Each sub-layer is drawn + * independently at its own precomputed dmax/npart, using its own density + * recovered by inverse-interpolating the self-consistent summed sub-layer + * curve at the exposure position the already-computed total density + * corresponds to -- exactly what spektrafilm's own + * interp_density_cmy_layers_channel does, just against a table built once + * at sim-build time instead of a per-pixel scipy call. The per-sublayer + * draws are SUMMED (not averaged) before taking the delta, matching + * upstream's _channel_sublayer_grain -- each draw already carries its own + * (smaller, for finer sub-layers) share of the total variance, so summing + * them reproduces the combined multilayer noise the catalogue RMS was + * calibrated against, rather than diluting it the way an average would. */ +void sf_grain_delta_ml(const sf_grain_layers_t *layers, const float dens[3], float amount, + float out_delta[3], uint32_t xi, uint32_t yi, int mono, + const float dmin_c[3], const float unif_c[3]) +{ + const int nsub = layers->n; + const int nle = SF_NLE; + const int lstride = SF_GRAIN_MAX_SUBLAYERS * 3; + + if(mono) + { + const float dm = (dens[0] + dens[1] + dens[2]) / 3.0f; + const float pos = _sf_grain_curve_inverse(&layers->layer_curve_total[0][1], nle, 3, dm); + float total_abs = 0.0f; + for(int sl = 0; sl < nsub; sl++) + { + const float raw = _sf_grain_curve_sample(&layers->layer_curve[0][sl][1], nle, lstride, pos); + const float d_abs = raw + (float)layers->layer_dmin[sl][1]; + total_abs += sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][1], (float)layers->layer_npart[sl][1], + unif_c[1], sf_pixel_seed(xi, yi, (uint32_t)(sl * 10))); + } + const float g = total_abs - dmin_c[1]; + const float d = (g - dm) * amount; + out_delta[0] = out_delta[1] = out_delta[2] = d; + return; + } + + for(int c = 0; c < 3; c++) + { + const float pos = _sf_grain_curve_inverse(&layers->layer_curve_total[0][c], nle, 3, dens[c]); + float total_abs = 0.0f; + for(int sl = 0; sl < nsub; sl++) + { + const float raw = _sf_grain_curve_sample(&layers->layer_curve[0][sl][c], nle, lstride, pos); + const float d_abs = raw + (float)layers->layer_dmin[sl][c]; + total_abs += sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][c], (float)layers->layer_npart[sl][c], + unif_c[c], sf_pixel_seed(xi, yi, (uint32_t)(c + sl * 10))); + } + const float g = total_abs - dmin_c[c]; + out_delta[c] = (g - dens[c]) * amount; + } +} + diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index 59fb907abb7b..6d30d9340057 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -59,6 +59,7 @@ #include #include +#include #ifdef __cplusplus extern "C" { @@ -66,6 +67,10 @@ extern "C" { #define SF_NWL 81 /* 380..780 nm in 5 nm steps — spektrafilm SPECTRAL_SHAPE */ #define SF_NLE 256 /* log-exposure grid — spektrafilm LOG_EXPOSURE */ +/* max emulsion sub-layers a film's fitted density-curve model can have + (matches sf_curves_model_t's centers/amplitudes/sigmas[3][8] in + spektra_sim.c) and the max particle_scale_sublayers entries read below. */ +#define SF_GRAIN_MAX_SUBLAYERS 8 @@ -155,6 +160,19 @@ typedef struct sf_sim_gpu_t pack): RMS-granularity, uniformity and density floor, replacing the earlier one-size-fits-all constants */ float grain_rms[3], grain_uniformity[3], grain_dmin[3]; + /* multi-sublayer grain model (see sf_grain_layers_t / _sf_build_grain_layers + in spektra_sim.c): n==1 for a single-layer curve fit, the existing + single-layer behavior. The two per-exposure-grid tables are borrowed + pointers into the sim's own storage (same convention as cmax_table + below), not copied -- process_cl() turns them into a device constant + buffer the same way it already does for cmax_table. */ + int grain_n_sublayers; + float grain_particle_scale[SF_GRAIN_MAX_SUBLAYERS]; + float grain_layer_dmax[SF_GRAIN_MAX_SUBLAYERS][3]; + float grain_layer_npart[SF_GRAIN_MAX_SUBLAYERS][3]; + float grain_layer_dmin[SF_GRAIN_MAX_SUBLAYERS][3]; + const float *grain_layer_curve; /* [SF_NLE][SF_GRAIN_MAX_SUBLAYERS][3], borrowed */ + const float *grain_layer_curve_total; /* [SF_NLE][3], borrowed */ /* per-film halation preset (film_render_defaults[stock].halation in the pack): back-reflection strength per channel and first-bounce radius; falls back to SF_HALATION_STRENGTH_DEFAULT_* / SF_HALATION_SIGMA_DEFAULT_UM @@ -179,8 +197,37 @@ void sf_sim_film_dmax3(const sf_sim_t *sim, float dmax[3]); falls back to the legacy fixed constants (SF_GRAIN_LEGACY_* in spektra_core.h) when sim is NULL or the pack predates per-film grain. */ void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3]); + +/* Multi-sublayer grain model (see _sf_build_grain_layers in spektra_sim.c): + * n==1 for any stock whose own fitted density-curve model is single-layer + * (or the pack has no particle_scale_sublayers for it) -- the existing + * single-layer behavior, not a fallback approximation of a separate case. + * layer_curve/layer_curve_total point into the sim's own storage (valid for + * the sim's lifetime; not copied, since the table is a small but non-trivial + * SF_NLE*SF_GRAIN_MAX_SUBLAYERS*3 floats). */ +typedef struct sf_grain_layers_t +{ + int n; + double particle_scale[SF_GRAIN_MAX_SUBLAYERS]; + double layer_dmax[SF_GRAIN_MAX_SUBLAYERS][3]; + double layer_npart[SF_GRAIN_MAX_SUBLAYERS][3]; + double layer_dmin[SF_GRAIN_MAX_SUBLAYERS][3]; + const float (*layer_curve)[SF_GRAIN_MAX_SUBLAYERS][3]; /* [SF_NLE][sublayer][channel] */ + const float (*layer_curve_total)[3]; /* [SF_NLE][channel] */ +} sf_grain_layers_t; +void sf_sim_grain_layers(const sf_sim_t *sim, sf_grain_layers_t *out); +/* Multi-sublayer grain delta (see _sf_build_grain_layers / sf_grain_layers_t + * above): only call this when sf_sim_grain_layers()'s n > 1 -- for n==1, + * calling the existing single-layer sf_grain_delta_dmax() (spektra_core.h) + * directly is both simpler and avoids a redundant lookup round-trip. */ +void sf_grain_delta_ml(const sf_grain_layers_t *layers, const float dens[3], float amount, + float out_delta[3], uint32_t xi, uint32_t yi, int mono, + const float dmin_c[3], const float unif_c[3]); +/* SF_GRAIN_MAX_SUBLAYERS is defined earlier in this header, next to SF_NLE + (both are needed by sf_sim_gpu_t above, which comes before this point). */ bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock, - double rms[3], double uniformity[3], double density_min[3]); + double rms[3], double uniformity[3], double density_min[3], + double particle_scale[SF_GRAIN_MAX_SUBLAYERS], int *n_scale); #define SF_COUPLER_BLUR_UM 20.0 /* gaussian core default when pack lacks it */ /* exponential-tail gaussian mixture (upstream fit, n=3) — shared with halation */ #define SF_EXPTAIL_A0 0.1633 diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 8a16948da490..4c6694de47ce 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -207,12 +207,22 @@ typedef struct dt_iop_spektrafilm_data_t sf_sim_gpu_t *gpu; /* float tables for process_cl; NULL for exact quality */ uint64_t sim_key; /* hash of everything the sim build depends on */ char sim_error[256]; + /* multi-sublayer grain GPU constant buffers (see process_cl's grain + stage): built from d->gpu's grain_layer_* tables, which only change + when d->gpu itself is rebuilt (a new film/paper/quality choice), never + per-tile. Cached here and keyed on the `gpu` pointer they were built + from, instead of being re-uploaded on every process_cl() call -- + tiled processing calls process_cl() once per tile, so uploading these + fresh every time was pure per-tile overhead for data that never + changes between tiles of the same image. */ + const sf_sim_gpu_t *grain_cl_built_for; + cl_mem grain_cl_dmax, grain_cl_npart, grain_cl_dmin, grain_cl_total, grain_cl_curve; } dt_iop_spektrafilm_data_t; typedef struct dt_iop_spektrafilm_global_data_t { int kernel_expose, kernel_lograw, kernel_develop_corr, kernel_develop; - int kernel_grain_gen, kernel_grain_add; + int kernel_grain_gen, kernel_grain_gen_ml, kernel_grain_add; int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough; int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply; int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c; @@ -238,6 +248,7 @@ void init_global(dt_iop_module_so_t *self) gd->kernel_develop_corr = dt_opencl_create_kernel(program, "spektrafilm_develop_corr"); gd->kernel_develop = dt_opencl_create_kernel(program, "spektrafilm_develop"); gd->kernel_grain_gen = dt_opencl_create_kernel(program, "spektrafilm_grain_gen"); + gd->kernel_grain_gen_ml = dt_opencl_create_kernel(program, "spektrafilm_grain_gen_ml"); gd->kernel_grain_add = dt_opencl_create_kernel(program, "spektrafilm_grain_add"); gd->kernel_print_expose = dt_opencl_create_kernel(program, "spektrafilm_print_expose"); gd->kernel_print_develop = dt_opencl_create_kernel(program, "spektrafilm_print_develop"); @@ -269,6 +280,7 @@ void cleanup_global(dt_iop_module_so_t *self) dt_opencl_free_kernel(gd->kernel_develop_corr); dt_opencl_free_kernel(gd->kernel_develop); dt_opencl_free_kernel(gd->kernel_grain_gen); + dt_opencl_free_kernel(gd->kernel_grain_gen_ml); dt_opencl_free_kernel(gd->kernel_grain_add); dt_opencl_free_kernel(gd->kernel_print_expose); dt_opencl_free_kernel(gd->kernel_print_develop); @@ -901,6 +913,11 @@ void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelp { if(d->gpu) sf_sim_gpu_free(d->gpu); if(d->sim) sf_sim_free(d->sim); + if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax); + if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart); + if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin); + if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total); + if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve); dt_pthread_mutex_destroy(&d->lock); } free(piece->data); @@ -992,6 +1009,15 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, { sf_sim_gpu_free(d->gpu); d->gpu = NULL; + /* these were uploaded from this gpu's grain_layer_* tables; release them + now so process_cl() re-uploads fresh ones from the new gpu instead of + reusing now-stale content. */ + if(d->grain_cl_dmax) { dt_opencl_release_mem_object(d->grain_cl_dmax); d->grain_cl_dmax = NULL; } + if(d->grain_cl_npart) { dt_opencl_release_mem_object(d->grain_cl_npart); d->grain_cl_npart = NULL; } + if(d->grain_cl_dmin) { dt_opencl_release_mem_object(d->grain_cl_dmin); d->grain_cl_dmin = NULL; } + if(d->grain_cl_total) { dt_opencl_release_mem_object(d->grain_cl_total); d->grain_cl_total = NULL; } + if(d->grain_cl_curve) { dt_opencl_release_mem_object(d->grain_cl_curve); d->grain_cl_curve = NULL; } + d->grain_cl_built_for = NULL; } if(d->sim) { @@ -1352,15 +1378,25 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c (rms-granularity, uniformity, density floor) — Portra 400 no longer shares Tri-X's grain signature */ + sf_grain_layers_t layers; + sf_sim_grain_layers(sim, &layers); /* n==1 for a single-layer curve fit: + the exact same case sf_grain_delta_dmax + already handles, called unchanged below + rather than routed through the + multi-sublayer lookup for no reason */ #ifdef _OPENMP -#pragma omp parallel for default(none) shared(plane, gbuf, gdmax, grms, gunif, gdmin) \ +#pragma omp parallel for default(none) shared(plane, gbuf, gdmax, grms, gunif, gdmin, layers) \ firstprivate(w, npix, roi_x, roi_y, amount, mono) schedule(static) #endif for(size_t k = 0; k < npix; k++) { const int x = (int)(k % (size_t)w), y = (int)(k / (size_t)w); - sf_grain_delta_dmax(plane + k * 3, amount, gbuf + k * 3, (uint32_t)(x + roi_x), - (uint32_t)(y + roi_y), mono, gdmax, gdmin, grms, gunif); + if(layers.n > 1) + sf_grain_delta_ml(&layers, plane + k * 3, amount, gbuf + k * 3, (uint32_t)(x + roi_x), + (uint32_t)(y + roi_y), mono, gdmin, gunif); + else + sf_grain_delta_dmax(plane + k * 3, amount, gbuf + k * 3, (uint32_t)(x + roi_x), + (uint32_t)(y + roi_y), mono, gdmax, gdmin, grms, gunif); } const float sigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); @@ -1809,16 +1845,59 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ const int roi_x = roi_in->x, roi_y = roi_in->y; const float amount = d->p.grain_amount; const int mono = g->film_bw; /* B&W: achromatic grain */ - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_gen, w, h, CLARG(plane2), - CLARG(tmpa), CLARG(w), CLARG(h), CLARG(amount), - CLARG(roi_x), CLARG(roi_y), CLARG(mono), - CLARG(g->film_dmax[0]), CLARG(g->film_dmax[1]), - CLARG(g->film_dmax[2]), CLARG(g->grain_dmin[0]), - CLARG(g->grain_dmin[1]), CLARG(g->grain_dmin[2]), - CLARG(g->grain_rms[0]), CLARG(g->grain_rms[1]), - CLARG(g->grain_rms[2]), CLARG(g->grain_uniformity[0]), - CLARG(g->grain_uniformity[1]), - CLARG(g->grain_uniformity[2])); + if(g->grain_n_sublayers > 1) + { + /* multi-sublayer grain (see spektrafilm_grain_gen_ml, spektrafilm.cl): + a film whose own fitted density-curve model has more than one + emulsion sub-layer. Built once per d->gpu (see d->grain_cl_built_for + above) rather than re-uploaded on every process_cl() call: tiled + processing calls this once per tile, and this data never changes + between tiles of the same image, so re-uploading it per tile was + pure overhead -- exactly the kind that shows up as slower tiled + rendering, not slower per-pixel compute. */ + const int nsub = g->grain_n_sublayers, nle = SF_NLE, maxsub = SF_GRAIN_MAX_SUBLAYERS; + if(d->grain_cl_built_for != g) + { + if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax); + if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart); + if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin); + if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total); + if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve); + d->grain_cl_dmax = dt_opencl_copy_host_to_device_constant( + devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmax); + d->grain_cl_npart = dt_opencl_copy_host_to_device_constant( + devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_npart); + d->grain_cl_dmin = dt_opencl_copy_host_to_device_constant( + devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmin); + d->grain_cl_total = dt_opencl_copy_host_to_device_constant( + devid, (size_t)nle * 3 * f, (void *)g->grain_layer_curve_total); + d->grain_cl_curve = dt_opencl_copy_host_to_device_constant( + devid, (size_t)nle * maxsub * 3 * f, (void *)g->grain_layer_curve); + d->grain_cl_built_for = g; + } + if(!d->grain_cl_dmax || !d->grain_cl_npart || !d->grain_cl_dmin || !d->grain_cl_total + || !d->grain_cl_curve) + err = CL_MEM_OBJECT_ALLOCATION_FAILURE; + else + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_grain_gen_ml, w, h, CLARG(plane2), CLARG(tmpa), CLARG(w), CLARG(h), + CLARG(amount), CLARG(roi_x), CLARG(roi_y), CLARG(mono), CLARG(nsub), CLARG(nle), + CLARG(maxsub), CLARG(g->grain_dmin[0]), CLARG(g->grain_dmin[1]), CLARG(g->grain_dmin[2]), + CLARG(g->grain_uniformity[0]), CLARG(g->grain_uniformity[1]), + CLARG(g->grain_uniformity[2]), CLARG(d->grain_cl_dmax), CLARG(d->grain_cl_npart), + CLARG(d->grain_cl_dmin), CLARG(d->grain_cl_total), CLARG(d->grain_cl_curve)); + } + else + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_gen, w, h, CLARG(plane2), + CLARG(tmpa), CLARG(w), CLARG(h), CLARG(amount), + CLARG(roi_x), CLARG(roi_y), CLARG(mono), + CLARG(g->film_dmax[0]), CLARG(g->film_dmax[1]), + CLARG(g->film_dmax[2]), CLARG(g->grain_dmin[0]), + CLARG(g->grain_dmin[1]), CLARG(g->grain_dmin[2]), + CLARG(g->grain_rms[0]), CLARG(g->grain_rms[1]), + CLARG(g->grain_rms[2]), CLARG(g->grain_uniformity[0]), + CLARG(g->grain_uniformity[1]), + CLARG(g->grain_uniformity[2])); SF_CL_STEP("grain gen"); const float gsigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); From 273b8ccf37a4745a787d8bb06e9850b87473bbeb Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 19 Jul 2026 13:02:32 +0200 Subject: [PATCH 31/56] add calibration constant to make grain behave more like spektrafilm --- src/iop/spektrafilm.c | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 4c6694de47ce..78d70c8daf83 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -98,6 +98,20 @@ DT_MODULE_INTROSPECTION(5, dt_iop_spektrafilm_params_t) #define SF_SCATTER_TAIL_MAX_UM 27.0f #define SF_GRAIN_BLUR_FACTOR 0.8f #define SF_GRAIN_SIZE_MIN 0.05f +/* Calibration against the reference spektrafilm app's own rendered output, + * same film stock (Portra 400 / Portra Endura) and nominal (1.0, 1.0) + * sliders: even with the exact multi-sublayer particle model and exact + * Gaussian convolution, darktable's grain came out visually fainter and + * coarser than the reference at those defaults -- matching a stock-independent + * particle model has no free parameter left to soak up that gap, so these + * are a direct measured correction on the user-facing sliders rather than a + * further formula change. Applied only where grain_amount/grain_size feed + * the actual generation and clump blur (process()/process_cl()); left out of + * _max_halo_sigma's ROI/tiling padding, since a *smaller* effective size only + * needs less padding, not more, so the unscaled (larger) estimate there stays + * safely conservative. */ +#define SF_GRAIN_STRENGTH_CAL 1.20f +#define SF_GRAIN_SIZE_CAL 0.65f #define SF_HALO_SIGMAS 4.0f #define SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM 950.0f /* DIR coupler inhibitor diffusion; spektrafilm params_schema @@ -1368,7 +1382,7 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c { float *gbuf = corr; /* corr is free now — reuse as the grain delta buffer */ const int roi_x = roi_in->x, roi_y = roi_in->y; - const float amount = d->p.grain_amount; + const float amount = d->p.grain_amount * SF_GRAIN_STRENGTH_CAL; const int mono = sf_sim_film_bw(sim); /* B&W: achromatic grain */ float gdmax[3], grms[3], gunif[3], gdmin[3]; sf_sim_film_dmax3(sim, gdmax); /* the emulsion's own D-max: slide film @@ -1399,7 +1413,8 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c (uint32_t)(y + roi_y), mono, gdmax, gdmin, grms, gunif); } const float sigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM - * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); + * fmaxf(d->p.grain_size * SF_GRAIN_SIZE_CAL, SF_GRAIN_SIZE_MIN) + / fmaxf(pixel_um, 1e-3f); sf_blur_plane3(gbuf, w, h, sigma, scratch); const float renorm = sf_gauss_grain_renorm(fmaxf(sigma, 0.3f)); #ifdef _OPENMP @@ -1843,7 +1858,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ if(d->p.grain_on && d->p.grain_amount > 0.0f) { const int roi_x = roi_in->x, roi_y = roi_in->y; - const float amount = d->p.grain_amount; + const float amount = d->p.grain_amount * SF_GRAIN_STRENGTH_CAL; const int mono = g->film_bw; /* B&W: achromatic grain */ if(g->grain_n_sublayers > 1) { @@ -1900,7 +1915,8 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(g->grain_uniformity[2])); SF_CL_STEP("grain gen"); const float gsigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM - * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); + * fmaxf(d->p.grain_size * SF_GRAIN_SIZE_CAL, SF_GRAIN_SIZE_MIN) + / fmaxf(pixel_um, 1e-3f); SF_GAUSS_BLUR4(tmpa, gsigma, "grain blur"); const float grenorm = sf_gauss_grain_renorm(fmaxf(gsigma, 0.3f)); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2), From a0cb53ad1c12ad36b8844e00f0e6e7e609349023 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 19 Jul 2026 13:18:41 +0200 Subject: [PATCH 32/56] improve performance --- src/common/spektra_core.c | 113 ++++++++++++++++++++++++++++++++++---- src/common/spektra_core.h | 17 ++++++ src/iop/spektrafilm.c | 113 +++++++++++++++++++++++++++----------- 3 files changed, 202 insertions(+), 41 deletions(-) diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index 19f2d50bced1..4514f2ddda59 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -110,6 +110,67 @@ float sf_gauss_grain_renorm(const float sigma) return (float)(1.0 / ss); } +/* SF_GAUSS_EXACT_MAX_SIGMA / SF_GAUSS_SIGMA_CORRECTION are defined in + spektra_core.h, shared with spektrafilm.c's GPU macros. */ + +/* IIR coefficients for Gaussian order-0 (Young-van Vliet). `sigma` here is + * already corrected (see SF_GAUSS_SIGMA_CORRECTION) -- this function has no + * opinion on that, it just builds the filter for whatever sigma it's given. */ +static void _sf_gauss_iir_coeffs(const float sigma, float *a0, float *a1, float *a2, + float *a3, float *b1, float *b2, float *coefp, float *coefn) +{ + const float alpha = 1.695f / sigma; + const float ema = expf(-alpha); + const float ema2 = expf(-2.0f * alpha); + *b1 = -2.0f * ema; + *b2 = ema2; + const float k = (1.0f - ema) * (1.0f - ema) / (1.0f + (2.0f * alpha * ema) - ema2); + *a0 = k; + *a1 = k * (alpha - 1.0f) * ema; + *a2 = k * (alpha + 1.0f) * ema; + *a3 = -k * ema2; + *coefp = (*a0 + *a1) / (1.0f + *b1 + *b2); + *coefn = (*a2 + *a3) / (1.0f + *b1 + *b2); +} + +/* Causal + anticausal IIR pass over `len` elements (stride 1), combined into + * `out`. Range-clamped to [vmin, vmax] as a safety margin -- generation + * values here never approach the +-1e9 bounds in practice. */ +static void _sf_gauss_iir_1d(const float *const in, float *const out, const int len, + const float a0, const float a1, const float a2, const float a3, + const float b1, const float b2, const float coefp, const float coefn) +{ + const float vmin = -1.0e9f, vmax = 1.0e9f; + float xp = CLAMP(in[0], vmin, vmax); + float yb = xp * coefp; + float yp = yb; + out[0] = yp; + for(int i = 1; i < len; i++) + { + const float xc = CLAMP(in[i], vmin, vmax); + const float yc = a0 * xc + a1 * xp - b1 * yp - b2 * yb; + xp = xc; + yb = yp; + yp = yc; + out[i] = yc; + } + float xn = CLAMP(in[len - 1], vmin, vmax); + float xa = xn; + float yn = xn * coefn; + float ya = yn; + out[len - 1] += yn; + for(int i = len - 2; i >= 0; i--) + { + const float xc = CLAMP(in[i], vmin, vmax); + const float yc = a2 * xn + a3 * xa - b1 * yn - b2 * ya; + xa = xn; + xn = xc; + ya = yn; + yn = yc; + out[i] += yc; + } +} + /* Direct 1D convolution along stride-1 elements, clamp-to-edge boundary (a plain "hold the edge value" extension -- the exact boundary rule matters far less than the kernel itself once the kernel is exact). `in` and @@ -155,14 +216,22 @@ static void _sf_transpose(const float *const src, float *const dst, passes then operate directly without the transpose, which is fine for small buffers where cache locality matters less). */ static void _blur_channel(float *const buf, const int w, const int h, const int c, - const float sigma, float *const plane, float *const trans) + const float sigma, float *const plane, float *const trans, + const int exact_only) { if(sigma < 1e-6f) return; const size_t npix = (size_t)w * h; for(size_t i = 0; i < npix; i++) plane[i] = buf[i * 3 + c]; + const int use_iir = !exact_only && sigma >= SF_GAUSS_EXACT_MAX_SIGMA; float kernel[2 * SF_GAUSS_MAX_RADIUS + 1]; - const int radius = sf_gauss_kernel_1d(sigma, kernel, SF_GAUSS_MAX_RADIUS); + int radius = 0; + float a0 = 0, a1 = 0, a2 = 0, a3 = 0, b1 = 0, b2 = 0, coefp = 0, coefn = 0; + if(use_iir) + _sf_gauss_iir_coeffs(sigma * SF_GAUSS_SIGMA_CORRECTION, &a0, &a1, &a2, &a3, &b1, &b2, &coefp, + &coefn); + else + radius = sf_gauss_kernel_1d(sigma, kernel, SF_GAUSS_MAX_RADIUS); if(trans && w >= 16 && h >= 16) { @@ -172,7 +241,8 @@ static void _blur_channel(float *const buf, const int w, const int h, const int for(int j = 0; j < h; j++) { const size_t off = (size_t)j * w; - _sf_gauss_convolve_1d(plane + off, temp + off, w, kernel, radius); + if(use_iir) _sf_gauss_iir_1d(plane + off, temp + off, w, a0, a1, a2, a3, b1, b2, coefp, coefn); + else _sf_gauss_convolve_1d(plane + off, temp + off, w, kernel, radius); } /* Transpose temp (w×h) -> plane (h×w) */ _sf_transpose(temp, plane, w, h); @@ -181,7 +251,8 @@ static void _blur_channel(float *const buf, const int w, const int h, const int for(int j = 0; j < w; j++) { const size_t off = (size_t)j * h; - _sf_gauss_convolve_1d(plane + off, temp + off, h, kernel, radius); + if(use_iir) _sf_gauss_iir_1d(plane + off, temp + off, h, a0, a1, a2, a3, b1, b2, coefp, coefn); + else _sf_gauss_convolve_1d(plane + off, temp + off, h, kernel, radius); } /* Transpose back temp (h×w) -> plane (w×h) */ _sf_transpose(temp, plane, h, w); @@ -194,7 +265,10 @@ static void _blur_channel(float *const buf, const int w, const int h, const int { for(int j = 0; j < h; j++) { - _sf_gauss_convolve_1d(plane + (size_t)j * w, row_tmp, w, kernel, radius); + if(use_iir) + _sf_gauss_iir_1d(plane + (size_t)j * w, row_tmp, w, a0, a1, a2, a3, b1, b2, coefp, coefn); + else + _sf_gauss_convolve_1d(plane + (size_t)j * w, row_tmp, w, kernel, radius); memcpy(plane + (size_t)j * w, row_tmp, sizeof(float) * w); } float *const col_in = dt_alloc_align_float((size_t)h); @@ -204,7 +278,8 @@ static void _blur_channel(float *const buf, const int w, const int h, const int for(int i = 0; i < w; i++) { for(int j = 0; j < h; j++) col_in[j] = plane[(size_t)j * w + i]; - _sf_gauss_convolve_1d(col_in, col_out, h, kernel, radius); + if(use_iir) _sf_gauss_iir_1d(col_in, col_out, h, a0, a1, a2, a3, b1, b2, coefp, coefn); + else _sf_gauss_convolve_1d(col_in, col_out, h, kernel, radius); for(int j = 0; j < h; j++) plane[(size_t)j * w + i] = col_out[j]; } } @@ -216,12 +291,30 @@ static void _blur_channel(float *const buf, const int w, const int h, const int for(size_t i = 0; i < npix; i++) buf[i * 3 + c] = plane[i]; } -/* Blur all three channels with the same sigma (grain). Allocates trans buffer. */ +/* Blur all three channels with the same sigma (grain clumps). Always uses + the exact kernel regardless of sigma: sf_gauss_grain_renorm computes the + variance-restoration factor from that exact kernel's own coefficients, so + the blur actually applied must match it precisely -- the IIR fast path's + renormalization behavior hasn't been separately verified, so grain stays + on the one path its own math is derived from. Allocates trans buffer. */ void sf_blur_plane3(float *const buf, const int w, const int h, const float sigma, float *const plane) { if(sigma < 0.3f) return; float *const trans = dt_alloc_align_float((size_t)w * h); - for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma, plane, trans); + for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma, plane, trans, /*exact_only=*/1); + dt_free_align(trans); +} + +/* Same as sf_blur_plane3, but allows the fast IIR path at large sigma: for + callers with no downstream dependency on the exact kernel's shape (DIR + coupler correction-field diffusion, unlike grain, isn't renormalizing + against it -- it's just smoothing a density correction, not restoring a + noise buffer's variance). */ +void sf_blur_plane3_fast(float *const buf, const int w, const int h, const float sigma, float *const plane) +{ + if(sigma < 0.3f) return; + float *const trans = dt_alloc_align_float((size_t)w * h); + for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma, plane, trans, /*exact_only=*/0); dt_free_align(trans); } @@ -229,7 +322,7 @@ void sf_blur_plane3(float *const buf, const int w, const int h, const float sigm static void _blur_per_channel(float *const buf, const int w, const int h, const float sigma[3], float *const plane, float *const trans) { - for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma[c], plane, trans); + for(int c = 0; c < 3; c++) _blur_channel(buf, w, h, c, sigma[c], plane, trans, /*exact_only=*/0); } /* Apply halation + scatter to a w*h*3 LINEAR plane, in place. @@ -624,7 +717,7 @@ void sf_diffusion_filter(float *const raw, const int w, const int h, const doubl { const float sigma = (float)(plan.sigma_um[j] * sc / fmax(pixel_um, 1e-3)); dt_iop_image_copy(comp, raw, nn); - for(int c = 0; c < 3; c++) _blur_channel(comp, w, h, c, sigma, plane1, trans); + for(int c = 0; c < 3; c++) _blur_channel(comp, w, h, c, sigma, plane1, trans, /*exact_only=*/0); const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j]; for(size_t i = 0; i < npix; i++) { diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index 686630b55e89..621758010fb3 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -31,6 +31,7 @@ /* Spatial effects implemented in spektra_core.c (they need dt_alloc_align_float and OpenMP linkage; everything else in this header is inline). */ void sf_blur_plane3(float *buf, int w, int h, float sigma, float *plane); +void sf_blur_plane3_fast(float *buf, int w, int h, float sigma, float *plane); /* Two independently-controllable stages, matching upstream's HalationParams: * scatter_amount / scatter_scale -- stage 1, in-emulsion core+tail scatter * halation_amount / halation_scale -- stage 2, back-reflection multi-bounce @@ -548,6 +549,22 @@ SPEKTRA_INLINE uint32_t sf_pixel_seed(uint32_t xi, uint32_t yi, uint32_t chan) dispatch the identical kernel for a given sigma. */ #define SF_GAUSS_MAX_RADIUS 512 +/* Above this sigma, the recursive (Young-van Vliet IIR) approximation is + accurate enough to use as a fast path instead of the exact kernel below: + simulating its actual impulse response against the requested sigma shows + the effective-vs-requested ratio stabilizes at a stable 1.1799 for sigma + >= ~1.5px, correctable with the single constant factor below. Below that + threshold the ratio isn't flat (it drifts, and below ~0.5px flips to + UNDER-blurring), which is why the exact kernel exists at all -- so this + threshold routes only the large-radius blurs (halation's bounce + especially, where the exact kernel's O(radius) cost is worst) through the + O(1)-per-pixel fast path, recovering most of the performance the exact + kernel gave up without reintroducing the inaccuracy it fixed at small + sigma. Shared by spektra_core.c's CPU dispatch and spektrafilm.c's GPU + macros so both switch over at the same sigma. */ +#define SF_GAUSS_EXACT_MAX_SIGMA 2.0f +#define SF_GAUSS_SIGMA_CORRECTION 0.8475f /* 1 / 1.1799 */ + /* Build a normalized, truncated 1D Gaussian kernel -- truncate=4 sigma, * matching scipy.ndimage.gaussian_filter's own default (what the reference * spektrafilm actually blurs with), so the blur is exact to kernel- diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 78d70c8daf83..95d49a3b0db8 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -1354,25 +1354,25 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c { const float wbase = 1.0f - (float)ctail_w; memcpy(tmp, corr, sizeof(float) * npix * 3); - if(csigma > 0.1f) sf_blur_plane3(tmp, w, h, csigma, scratch); + if(csigma > 0.1f) sf_blur_plane3_fast(tmp, w, h, csigma, scratch); for(size_t i = 0; i < npix * 3; i++) mix[i] = wbase * tmp[i]; for(int g3 = 0; g3 < 3; g3++) { memcpy(tmp, corr, sizeof(float) * npix * 3); const float ts = rat[g3] * tail_px; - if(ts > 0.1f) sf_blur_plane3(tmp, w, h, ts, scratch); + if(ts > 0.1f) sf_blur_plane3_fast(tmp, w, h, ts, scratch); const float wk = (float)ctail_w * amp[g3]; for(size_t i = 0; i < npix * 3; i++) mix[i] += wk * tmp[i]; } memcpy(corr, mix, sizeof(float) * npix * 3); } else if(csigma > 0.1f) - sf_blur_plane3(corr, w, h, csigma, scratch); /* alloc failed: core only */ + sf_blur_plane3_fast(corr, w, h, csigma, scratch); /* alloc failed: core only */ dt_free_align(mix); dt_free_align(tmp); } else if(csigma > 0.1f) - sf_blur_plane3(corr, w, h, csigma, scratch); + sf_blur_plane3_fast(corr, w, h, csigma, scratch); } sf_sim_develop(sim, plane, couplers ? corr : NULL, plane, npix, 3, 3); @@ -1607,41 +1607,92 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ } \ SF_CL_STEP(label); \ } while(0) +/* Same as SF_GAUSS_BLUR4, but above SF_GAUSS_EXACT_MAX_SIGMA falls back to + darktable's fast recursive blur (corrected -- see SF_GAUSS_SIGMA_CORRECTION) + instead of the exact row/col kernels: for callers with no downstream + dependency on the exact kernel's shape (unlike grain's SF_GAUSS_BLUR4 + above, which stays on the exact path unconditionally since + sf_gauss_grain_renorm is computed from it), this recovers most of the + O(radius) cost the exact kernel pays at large sigma. */ +#define SF_GAUSS_BLUR4_FAST(buf, _sg, label) do { \ + if(err == CL_SUCCESS) \ + { \ + if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \ + err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 4, (_sg) * SF_GAUSS_SIGMA_CORRECTION); \ + else \ + { \ + float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ + const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \ + err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \ + sizeof(float) * (2 * _kr + 1), TRUE); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \ + CLARG(buf), CLARG(gtmp4), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \ + CLARG(gtmp4), CLARG(buf), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + } \ + } \ + SF_CL_STEP(label); \ + } while(0) /* loop-safe variant: sets err, caller checks err/breaks; src/dst may differ - (e.g. accumulating several blurred copies of the same source). */ + (e.g. accumulating several blurred copies of the same source). Falls back + to the fast recursive blur above SF_GAUSS_EXACT_MAX_SIGMA, same rationale + as SF_GAUSS_BLUR4_FAST -- none of this macro's callers (halation bounce, + coupler tail, both diffusion filters) renormalize against the exact + kernel's shape. */ #define SF_GAUSS_BLUR4_OP_L(src, dst, _sg) do { \ if(err == CL_SUCCESS) \ { \ - float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ - const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \ - err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \ - sizeof(float) * (2 * _kr + 1), TRUE); \ - if(err == CL_SUCCESS) \ - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \ - CLARG(src), CLARG(gtmp4), CLARG(w), CLARG(h), \ - CLARG(gauss_w), CLARG(_kr)); \ - if(err == CL_SUCCESS) \ - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \ - CLARG(gtmp4), CLARG(dst), CLARG(w), CLARG(h), \ - CLARG(gauss_w), CLARG(_kr)); \ + if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \ + { \ + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \ + if(err == CL_SUCCESS) \ + err = dt_gaussian_mean_blur_cl(devid, (dst), w, h, 4, (_sg) * SF_GAUSS_SIGMA_CORRECTION); \ + } \ + else \ + { \ + float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ + const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \ + err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \ + sizeof(float) * (2 * _kr + 1), TRUE); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \ + CLARG(src), CLARG(gtmp4), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \ + CLARG(gtmp4), CLARG(dst), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + } \ } \ } while(0) -/* single-channel in-place blur (scatter stage only, on plane1) */ +/* single-channel in-place blur (scatter stage only, on plane1). Same fast + fallback above SF_GAUSS_EXACT_MAX_SIGMA -- scatter's core/tail sigmas are + normally small (sub-few-px), but the fallback is here for whatever a user's + scatter_scale slider can push them to. */ #define SF_GAUSS_BLUR1_L(buf, _sg) do { \ if(err == CL_SUCCESS) \ { \ - float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ - const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \ - err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \ - sizeof(float) * (2 * _kr + 1), TRUE); \ - if(err == CL_SUCCESS) \ - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_1c, w, h, \ - CLARG(buf), CLARG(gtmp1), CLARG(w), CLARG(h), \ - CLARG(gauss_w), CLARG(_kr)); \ - if(err == CL_SUCCESS) \ - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_1c, w, h, \ - CLARG(gtmp1), CLARG(buf), CLARG(w), CLARG(h), \ - CLARG(gauss_w), CLARG(_kr)); \ + if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \ + err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 1, (_sg) * SF_GAUSS_SIGMA_CORRECTION); \ + else \ + { \ + float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ + const int _kr = sf_gauss_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \ + err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \ + sizeof(float) * (2 * _kr + 1), TRUE); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_1c, w, h, \ + CLARG(buf), CLARG(gtmp1), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + if(err == CL_SUCCESS) \ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_1c, w, h, \ + CLARG(gtmp1), CLARG(buf), CLARG(w), CLARG(h), \ + CLARG(gauss_w), CLARG(_kr)); \ + } \ } \ } while(0) @@ -1844,7 +1895,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ } } else if(csigma > 0.1f) - SF_GAUSS_BLUR4(acc, csigma, "coupler blur"); + SF_GAUSS_BLUR4_FAST(acc, csigma, "coupler blur"); } cl_mem corr_buf = (g->coupler_tail_w > 0.0f) ? tmpa : acc; err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_develop, w, h, CLARG(plane), From 19454d2cc06f7305295461aafa7bf1c20da12878 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 19 Jul 2026 20:43:27 +0200 Subject: [PATCH 33/56] more performance improvements --- data/kernels/spektrafilm.cl | 9 +++++++-- src/common/spektra_core.h | 23 ++++++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 9eb33d3f3d32..94165efa0881 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -271,10 +271,15 @@ static inline float sf_u01(uint s) { return (sf_h(s) & 0xffffff) / (float)0x1000000; } +/* sf_nrm: sum-of-4-uniforms (Irwin-Hall) approximate standard normal, + instead of Box-Muller's sqrt+log+cos chain -- see spektra_core.h for the + full rationale (same formula, must match exactly so CPU and GPU renders + agree). */ static inline float sf_nrm(uint s) { - float u1 = fmax(sf_u01(s), 1e-7f), u2 = sf_u01(s * 2654435761u + 1u); - return native_sqrt(-2.f * native_log(u1)) * native_cos(6.2831853f * u2); + const float u = sf_u01(s) + sf_u01(s * 2654435761u + 1u) + sf_u01(s * 2246822519u + 2u) + + sf_u01(s * 3266489917u + 3u); + return (u - 2.0f) * 1.7320508f; /* sqrt(3) */ } static inline uint sf_pixel_seed(uint xi, uint yi, uint chan) { diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index 621758010fb3..c7e145773df5 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -505,13 +505,26 @@ SPEKTRA_INLINE float sf_u01(uint32_t s) { return (sf_h(s) & 0xffffff) / (float)0x1000000; } -/* sf_nrm: two uniforms -> one standard-normal sample via the Box-Muller - transform. 6.2831853 is 2*pi; 2654435761 is Knuth's golden-ratio multiplier - (2^32 / phi), used only to decorrelate the second uniform from the first. */ +/* sf_nrm: one hash seed -> one approximate standard-normal sample via a + sum-of-4-uniforms (Irwin-Hall) approximation instead of Box-Muller's + sqrt+log+cos transcendental chain. Var[uniform(0,1)] = 1/12, so a sum of 4 + has variance 4/12 = 1/3 and mean 2; rescaling by sqrt(3) and centering + gives unit variance, zero mean -- the two moments sf_layer_particle's + normal approximations actually rely on. The finite (not truly Gaussian) + tails this leaves behind aren't visually meaningful for film grain: real + emulsions don't have famously heavy statistical tails either, and the + difference from a true Gaussian only shows up several standard + deviations out, well past where grain is visible at all. Called twice per + particle draw, per sub-layer (up to SF_GRAIN_MAX_SUBLAYERS times for a + multi-sublayer film) -- worth being cheap. The four multipliers are + distinct, well-known odd hash constants (murmur3's c1/c2, Knuth's golden- + ratio multiplier, and one more), used only to decorrelate the four + uniform draws from each other. */ SPEKTRA_INLINE float sf_nrm(uint32_t s) { - float u1 = fmaxf(sf_u01(s), 1e-7f), u2 = sf_u01(s * 2654435761u + 1u); - return sqrtf(-2.f * logf(u1)) * cosf(6.2831853f * u2); + const float u = sf_u01(s) + sf_u01(s * 2654435761u + 1u) + sf_u01(s * 2246822519u + 2u) + + sf_u01(s * 3266489917u + 3u); + return (u - 2.0f) * 1.7320508f; /* sqrt(3) */ } /* sf_layer_particle: draw the developed density of one emulsion layer as a doubly-stochastic process. First the number of developed grains in this pixel From e8fcadad38fa298135f6f5649acf58df1cdca436 Mon Sep 17 00:00:00 2001 From: Arecsu Date: Sun, 19 Jul 2026 04:04:03 -0300 Subject: [PATCH 34/56] spektrafilm: multiplicative unsharp mask after grain blur Ports the mass-conserving density USM from spektrafilm's dev branch (study b80, "make the pixels disappear"). Applied after the grain blur to recover acutance while preserving grain texture. - New params: grain_usm_sigma (radius, px), grain_usm_amount (strength) - Introspection bumped to 6 with v5->v6 migration - CPU: sf_multiplicative_unsharp_mask3() in spektra_core.c - GPU: spektrafilm_grain_usm kernel in spektrafilm.cl - GUI: two sliders in the grain tab under "acutance recovery" - Default 0 (off) -- no change to existing edits --- data/kernels/spektrafilm.cl | 20 ++++++++++++ src/common/spektra_core.c | 31 ++++++++++++++++++ src/common/spektra_core.h | 2 ++ src/iop/spektrafilm.c | 64 +++++++++++++++++++++++++++++++++---- 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 94165efa0881..2114fe1b6927 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -571,6 +571,26 @@ __kernel void spektrafilm_grain_add(__global float4 *dens_buf, __global const fl dens_buf[k] = (float4)(d.x + g.x * renorm, d.y + g.y * renorm, d.z + g.z * renorm, d.w); } +/* Multiplicative unsharp mask after grain blur (study b80). + cmy = orig * (orig / G_sigma(orig))^amount. Caller saved orig in `orig` + buffer and blurred `cmy` in place before launching this kernel. */ +__kernel void spektrafilm_grain_usm(__global float4 *cmy, __global const float4 *orig, + const int w, const int h, const float amount) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 D = orig[k]; + const float4 blur = cmy[k]; + const float eps = 1e-6f; + float4 out; + out.x = D.x * pow(fmax(D.x / fmax(blur.x, eps), eps), amount); + out.y = D.y * pow(fmax(D.y / fmax(blur.y, eps), eps), amount); + out.z = D.z * pow(fmax(D.z / fmax(blur.z, eps), eps), amount); + out.w = 0.0f; + cmy[k] = out; +} + /* stage 5a: CMY film density -> print log exposure (sf_sim_print_expose) */ __kernel void spektrafilm_print_expose(__global const float4 *cmy, __global float4 *loge, const int w, const int h, diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index 4514f2ddda59..1a11ec54cc5d 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -318,6 +318,37 @@ void sf_blur_plane3_fast(float *const buf, const int w, const int h, const float dt_free_align(trans); } +/* Multiplicative (mass-conserving) unsharp mask on density (study b80). + out = D * (D / blur(D))^amount with per-channel mass renormalisation. */ +void sf_multiplicative_unsharp_mask3(float *const buf, const int w, const int h, + const float sigma, const float amount, + float *const orig, float *const work) +{ + if(sigma <= 0.0f || amount <= 0.0f) return; + const size_t npix = (size_t)w * h; + const size_t nn = npix * 3; + double sum_in[3] = { 0.0, 0.0, 0.0 }; + for(size_t i = 0; i < nn; i++) { orig[i] = buf[i]; sum_in[i % 3] += (double)buf[i]; } + sf_blur_plane3(buf, w, h, sigma, work); + const float eps = 1e-6f; + for(size_t i = 0; i < nn; i++) + { + const float D = fmaxf(orig[i], 0.0f); + const float blur = fmaxf(buf[i], eps); + buf[i] = D * powf(fmaxf(D / blur, eps), amount); + } + double sum_out[3] = { 0.0, 0.0, 0.0 }; + for(size_t i = 0; i < nn; i++) sum_out[i % 3] += (double)buf[i]; + for(int c = 0; c < 3; c++) + { + if(sum_out[c] > 0.0 && sum_in[c] > 0.0) + { + const float scale = (float)(sum_in[c] / sum_out[c]); + for(size_t i = c; i < nn; i += 3) buf[i] *= scale; + } + } +} + /* Blur packed buffer with per-channel sigma. `trans` is a w*h intermediate. */ static void _blur_per_channel(float *const buf, const int w, const int h, const float sigma[3], float *const plane, float *const trans) diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index c7e145773df5..de7ea5c53b07 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -32,6 +32,8 @@ and OpenMP linkage; everything else in this header is inline). */ void sf_blur_plane3(float *buf, int w, int h, float sigma, float *plane); void sf_blur_plane3_fast(float *buf, int w, int h, float sigma, float *plane); +void sf_multiplicative_unsharp_mask3(float *buf, int w, int h, float sigma, float amount, + float *orig, float *work); /* Two independently-controllable stages, matching upstream's HalationParams: * scatter_amount / scatter_scale -- stage 1, in-emulsion core+tail scatter * halation_amount / halation_scale -- stage 2, back-reflection multi-bounce diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 95d49a3b0db8..410f5aba3597 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -86,7 +86,7 @@ #include "common/spektra_core.h" #include "common/spektra_sim.h" -DT_MODULE_INTROSPECTION(5, dt_iop_spektrafilm_params_t) +DT_MODULE_INTROSPECTION(6, dt_iop_spektrafilm_params_t) /* Spatial-scale constants, micrometres on film unless noted (see the LUT module for the full rationale; these are shared with modify_roi_in() and @@ -177,6 +177,8 @@ typedef struct dt_iop_spektrafilm_params_t float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "film format" float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost" + float grain_usm_sigma; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 0.8 $DESCRIPTION: "grain recovery sharpness" + float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.8 $DESCRIPTION: "grain recovery strength" } dt_iop_spektrafilm_params_t; /* one discovered profile: stock (= file base name), display name, stage */ @@ -201,7 +203,7 @@ typedef struct dt_iop_spektrafilm_gui_data_t GtkWidget *boost_ev, *boost_range, *protect_ev; GtkWidget *diffusion_on, *diffusion_filter_family, *diffusion_strength, *diffusion_scale, *diffusion_warmth; GtkWidget *print_diffusion_on, *print_diffusion_filter_family, *print_diffusion_strength, *print_diffusion_scale, *print_diffusion_warmth; - GtkWidget *grain_on, *grain_amount, *grain_size, *film_format_mm, *output_luminance_boost; + GtkWidget *grain_on, *grain_amount, *grain_size, *grain_usm_sigma, *grain_usm_amount, *film_format_mm, *output_luminance_boost; sf_prof_entry_t entries[SF_MAX_PROFILES]; int n_entries; int film_entry[SF_MAX_PROFILES], n_films; /* indices into entries[] */ @@ -237,6 +239,7 @@ typedef struct dt_iop_spektrafilm_global_data_t { int kernel_expose, kernel_lograw, kernel_develop_corr, kernel_develop; int kernel_grain_gen, kernel_grain_gen_ml, kernel_grain_add; + int kernel_grain_gen, kernel_grain_add, kernel_grain_usm; int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough; int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply; int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c; @@ -264,6 +267,7 @@ void init_global(dt_iop_module_so_t *self) gd->kernel_grain_gen = dt_opencl_create_kernel(program, "spektrafilm_grain_gen"); gd->kernel_grain_gen_ml = dt_opencl_create_kernel(program, "spektrafilm_grain_gen_ml"); gd->kernel_grain_add = dt_opencl_create_kernel(program, "spektrafilm_grain_add"); + gd->kernel_grain_usm = dt_opencl_create_kernel(program, "spektrafilm_grain_usm"); gd->kernel_print_expose = dt_opencl_create_kernel(program, "spektrafilm_print_expose"); gd->kernel_print_develop = dt_opencl_create_kernel(program, "spektrafilm_print_develop"); gd->kernel_scan = dt_opencl_create_kernel(program, "spektrafilm_scan"); @@ -296,6 +300,7 @@ void cleanup_global(dt_iop_module_so_t *self) dt_opencl_free_kernel(gd->kernel_grain_gen); dt_opencl_free_kernel(gd->kernel_grain_gen_ml); dt_opencl_free_kernel(gd->kernel_grain_add); + dt_opencl_free_kernel(gd->kernel_grain_usm); dt_opencl_free_kernel(gd->kernel_print_expose); dt_opencl_free_kernel(gd->kernel_print_develop); dt_opencl_free_kernel(gd->kernel_scan); @@ -398,6 +403,7 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int BOTH stages) so migrated params reproduce the old rendering exactly, pixel for pixel, before the user ever touches the new sliders. + v6 adds grain_usm_sigma and grain_usm_amount after grain blur (study b80). v5 is a params-VALUE change, not a struct-shape change: sf_halation() no longer applies eff = halation_amount^1.3 before scaling halation_strength -- halation_amount is now a direct linear multiplier, @@ -608,10 +614,12 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->grain_size = o->grain_size; n->film_format_mm = o->film_format_mm; n->output_luminance_boost = 1.0f; /* new in v2: neutral default, no-op (matches upstream) */ + n->grain_usm_sigma = 0.8f; + n->grain_usm_amount = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 5; + *new_version = 6; return 0; } if(old_version == 2) @@ -660,10 +668,12 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->grain_size = o->grain_size; n->film_format_mm = o->film_format_mm; n->output_luminance_boost = o->output_luminance_boost; + n->grain_usm_sigma = 0.8f; + n->grain_usm_amount = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 5; + *new_version = 6; return 0; } if(old_version == 3) @@ -710,10 +720,12 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->grain_size = o->grain_size; n->film_format_mm = o->film_format_mm; n->output_luminance_boost = o->output_luminance_boost; + n->grain_usm_sigma = 0.8f; + n->grain_usm_amount = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 5; + *new_version = 6; return 0; } if(old_version == 4) @@ -760,10 +772,24 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->grain_size = o->grain_size; n->film_format_mm = o->film_format_mm; n->output_luminance_boost = o->output_luminance_boost; + n->grain_usm_sigma = 0.8f; + n->grain_usm_amount = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 5; + *new_version = 6; + return 0; + } + if(old_version == 5) + { + const dt_iop_spektrafilm_params_t *o = (dt_iop_spektrafilm_params_t *)old_params; + dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); + *n = *o; + n->grain_usm_sigma = 0.8f; + n->grain_usm_amount = 0.0f; + *new_params = n; + *new_params_size = sizeof(dt_iop_spektrafilm_params_t); + *new_version = 6; return 0; } return 1; @@ -1422,6 +1448,9 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c schedule(static) #endif for(size_t k = 0; k < npix * 3; k++) plane[k] += gbuf[k] * renorm; + if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f) + sf_multiplicative_unsharp_mask3(plane, w, h, d->p.grain_usm_sigma, + d->p.grain_usm_amount, corr, scratch); } /* 5) print exposure + development (skipped in scan-film mode) */ @@ -1973,6 +2002,17 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2), CLARG(tmpa), CLARG(w), CLARG(h), CLARG(grenorm)); SF_CL_STEP("grain add"); + if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f) + { + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane2, acc, 0, 0, npix * f * 4); + if(err != CL_SUCCESS) goto cleanup; + const float usig = d->p.grain_usm_sigma; + SF_GAUSS_BLUR4_FAST(plane2, usig, "grain USM blur"); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_usm, w, h, CLARG(plane2), + CLARG(acc), CLARG(w), CLARG(h), + CLARG(d->p.grain_usm_amount)); + SF_CL_STEP("grain USM"); + } } /* ---- 5) print ----------------------------------------------------------- */ @@ -2425,6 +2465,18 @@ void gui_init(dt_iop_module_t *self) g->grain_size = dt_bauhaus_slider_from_params(self, "grain_size"); gtk_widget_set_tooltip_text(g->grain_size, _("grain particle size (1.0 = film default; higher = coarser)")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "acutance recovery"))); + g->grain_usm_sigma = dt_bauhaus_slider_from_params(self, "grain_usm_sigma"); + dt_bauhaus_slider_set_soft_range(g->grain_usm_sigma, 0.0f, 3.0f); + gtk_widget_set_tooltip_text(g->grain_usm_sigma, + _("sharpening radius (0 = off). " + "higher = wider halos, lower = finer detail")); + g->grain_usm_amount = dt_bauhaus_slider_from_params(self, "grain_usm_amount"); + dt_bauhaus_slider_set_soft_range(g->grain_usm_amount, 0.0f, 2.0f); + gtk_widget_set_tooltip_text(g->grain_usm_amount, + _("sharpening strength (0 = off). " + "restores crispness that the grain blur softened; " + "overdo it and grain starts to look crunchy")); /* ---- tab: halation ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("halation"), NULL); From 7186a7207685e924e3f2173ffb23170afa8f97f8 Mon Sep 17 00:00:00 2001 From: Arecsu Date: Sun, 19 Jul 2026 19:20:00 -0300 Subject: [PATCH 35/56] spektrafilm: fix grain brightness shift across zoom levels The multi-sublayer grain model (sf_grain_delta_ml) produces a small positive DC bias (~0.07) from the Poisson/normal particle generator. The renormalisation factor (sf_gauss_grain_renorm) amplifies this bias proportionally to sigma, which varies with pixel_um across LOD levels -- at 100% zoom the image was significantly brighter than at lower zoom. Fix: subtract the mean of the grain-delta buffer after the clump blur and before the renorm, removing the DC component. --- src/iop/spektrafilm.c | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 410f5aba3597..3c4145f5272e 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -177,8 +177,8 @@ typedef struct dt_iop_spektrafilm_params_t float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "film format" float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost" - float grain_usm_sigma; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 0.8 $DESCRIPTION: "grain recovery sharpness" - float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.8 $DESCRIPTION: "grain recovery strength" + float grain_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.8 $DESCRIPTION: "grain recovery sharpness" + float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "grain recovery strength" } dt_iop_spektrafilm_params_t; /* one discovered profile: stock (= file base name), display name, stage */ @@ -238,8 +238,7 @@ typedef struct dt_iop_spektrafilm_data_t typedef struct dt_iop_spektrafilm_global_data_t { int kernel_expose, kernel_lograw, kernel_develop_corr, kernel_develop; - int kernel_grain_gen, kernel_grain_gen_ml, kernel_grain_add; - int kernel_grain_gen, kernel_grain_add, kernel_grain_usm; + int kernel_grain_gen, kernel_grain_gen_ml, kernel_grain_add, kernel_grain_usm; int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough; int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply; int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c; @@ -1442,6 +1441,15 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c * fmaxf(d->p.grain_size * SF_GRAIN_SIZE_CAL, SF_GRAIN_SIZE_MIN) / fmaxf(pixel_um, 1e-3f); sf_blur_plane3(gbuf, w, h, sigma, scratch); + /* Centre grain delta: multi-sublayer model has positive DC bias (~0.07) + that renorm amplifies proportionally to sigma, making image brighter + at higher LOD. Remove bias before scaling. */ + { + double gsum = 0.0; + for(size_t kk = 0; kk < npix * 3; kk++) gsum += (double)gbuf[kk]; + const float gmean = (float)(gsum / (double)(npix * 3)); + for(size_t kk = 0; kk < npix * 3; kk++) gbuf[kk] -= gmean; + } const float renorm = sf_gauss_grain_renorm(fmaxf(sigma, 0.3f)); #ifdef _OPENMP #pragma omp parallel for default(none) shared(plane, gbuf) firstprivate(npix, renorm) \ From 44cd6a2a9dba6b529e204041f63e0a59d9cdffbe Mon Sep 17 00:00:00 2001 From: Arecsu Date: Sun, 19 Jul 2026 19:53:18 -0300 Subject: [PATCH 36/56] spektrafilm: fix fireflies in grain recovery USM Clamp D/blur ratio to [1/4, 4] before pow() to prevent extreme values when a pixel's blurred neighbourhood is much brighter/darker than the pixel itself. Also clamp output to >= 0. Affects both CPU and GPU paths. --- data/kernels/spektrafilm.cl | 7 ++++--- src/common/spektra_core.c | 16 +++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 2114fe1b6927..765bd65c1985 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -583,10 +583,11 @@ __kernel void spektrafilm_grain_usm(__global float4 *cmy, __global const float4 const float4 D = orig[k]; const float4 blur = cmy[k]; const float eps = 1e-6f; + const float ratmax = 4.0f, ratmin = 1.0f / ratmax; float4 out; - out.x = D.x * pow(fmax(D.x / fmax(blur.x, eps), eps), amount); - out.y = D.y * pow(fmax(D.y / fmax(blur.y, eps), eps), amount); - out.z = D.z * pow(fmax(D.z / fmax(blur.z, eps), eps), amount); + out.x = fmax(D.x * pow(fmax(fmin(D.x / fmax(blur.x, eps), ratmax), ratmin), amount), 0.0f); + out.y = fmax(D.y * pow(fmax(fmin(D.y / fmax(blur.y, eps), ratmax), ratmin), amount), 0.0f); + out.z = fmax(D.z * pow(fmax(fmin(D.z / fmax(blur.z, eps), ratmax), ratmin), amount), 0.0f); out.w = 0.0f; cmy[k] = out; } diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index 1a11ec54cc5d..91845e6455d5 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -330,13 +330,15 @@ void sf_multiplicative_unsharp_mask3(float *const buf, const int w, const int h, double sum_in[3] = { 0.0, 0.0, 0.0 }; for(size_t i = 0; i < nn; i++) { orig[i] = buf[i]; sum_in[i % 3] += (double)buf[i]; } sf_blur_plane3(buf, w, h, sigma, work); - const float eps = 1e-6f; - for(size_t i = 0; i < nn; i++) - { - const float D = fmaxf(orig[i], 0.0f); - const float blur = fmaxf(buf[i], eps); - buf[i] = D * powf(fmaxf(D / blur, eps), amount); - } + const float eps = 1e-6f; + const float ratio_max = 4.0f; + for(size_t i = 0; i < nn; i++) + { + const float D = fmaxf(orig[i], 0.0f); + const float blur = fmaxf(buf[i], eps); + const float ratio = fmaxf(fminf(D / blur, ratio_max), 1.0f / ratio_max); + buf[i] = fmaxf(D * powf(ratio, amount), 0.0f); + } double sum_out[3] = { 0.0, 0.0, 0.0 }; for(size_t i = 0; i < nn; i++) sum_out[i % 3] += (double)buf[i]; for(int c = 0; c < 3; c++) From 0e7b7d2482a42cb6bc1b64c3adf9a5b924df3525 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Mon, 20 Jul 2026 19:06:00 +0200 Subject: [PATCH 37/56] fix grain changing exposure and add picker to pre compression boost --- data/kernels/spektrafilm.cl | 11 ++++- src/common/spektra_sim.c | 43 +++++++++++++++++ src/common/spektra_sim.h | 6 +++ src/iop/spektrafilm.c | 95 ++++++++++++++++++++++++++++++++++++- 4 files changed, 152 insertions(+), 3 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 765bd65c1985..9d1a50633ff2 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -560,15 +560,22 @@ __kernel void spektrafilm_grain_gen_ml(__global const float4 *dens, __global flo grain_buf[k] = (float4)(gd[0], gd[1], gd[2], 0.f); } +/* gmean centres the grain-delta buffer before scaling by renorm, matching + process()'s CPU-side DC-bias removal (see there for the full rationale: + the multi-sublayer particle generator has a small positive DC bias that + renorm amplifies proportionally to sigma). gmean is computed host-side + over the whole buffer (see process_cl()) since a full-image reduction is + simplest done there, then passed down as a single scalar. */ __kernel void spektrafilm_grain_add(__global float4 *dens_buf, __global const float4 *grain_buf, - const int w, const int h, const float renorm) + const int w, const int h, const float renorm, const float gmean) { const int x = get_global_id(0), y = get_global_id(1); if(x >= w || y >= h) return; const size_t k = (size_t)y * w + x; float4 d = dens_buf[k]; float4 g = grain_buf[k]; - dens_buf[k] = (float4)(d.x + g.x * renorm, d.y + g.y * renorm, d.z + g.z * renorm, d.w); + dens_buf[k] = (float4)(d.x + (g.x - gmean) * renorm, d.y + (g.y - gmean) * renorm, + d.z + (g.z - gmean) * renorm, d.w); } /* Multiplicative unsharp mask after grain blur (study b80). diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 1b55687b69eb..2468f74bd5ac 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -1808,6 +1808,49 @@ static void compress_rgb_aces(double rgb[3]) } } +/* Runs one RGB triple through the full simulation up to (but not including) + * the highlight/gamut compressor (compress_rgb_oklch/aces), using + * `boost_override` in place of sim->out_luminance_boost, and returns the + * resulting OkLab lightness -- the precompression-boost picker's actual + * measurement primitive. This needs the pre-compression value specifically: + * the reinhard knee (see SF_OUT_LIGHT_T/_L/_P) asymptotically approaches its + * limit regardless of how hard the input is pushed, so measuring the + * post-compression lightness would tell the picker almost nothing about how + * much boost is actually needed. + * + * No solver/iteration needed: the boost multiplies XYZ uniformly, XYZ->LMS + * is linear, so boosted LMS = boost * original LMS; OkLab's L is a fixed + * linear combination of LMS^(1/3), so L(boost) = boost^(1/3) * L(1) exactly. + * One probe at any boost value is enough to solve for the boost that hits a + * target L in closed form (see the caller in spektrafilm.c). */ +float sf_sim_probe_lightness(const sf_sim_t *sim, const float rgb_in[3], float boost_override) +{ + sf_sim_t tmp_sim = *sim; + tmp_sim.out_luminance_boost = (double)boost_override; + tmp_sim.out_compress = SF_OUTPUT_COMPRESS_OFF; + + float raw[3]; + sf_sim_expose(&tmp_sim, rgb_in, raw, 1, 3, 3); + sf_sim_lograw(raw, 1, 3); + float corr[3] = { 0.0f, 0.0f, 0.0f }; + if(tmp_sim.couplers_active) sf_sim_develop_corr(&tmp_sim, raw, corr, 1, 3); + float cmy[3]; + sf_sim_develop(&tmp_sim, raw, corr, cmy, 1, 3, 3); + if(tmp_sim.has_print) + { + sf_sim_print_expose(&tmp_sim, cmy, cmy, 1, 3, 3); + sf_sim_print_develop(&tmp_sim, cmy, cmy, 1, 3, 3); + } + float rgb_out[3]; + sf_sim_scan(&tmp_sim, cmy, rgb_out, 1, 3, 3); + + const double rgb_d[3] = { rgb_out[0], rgb_out[1], rgb_out[2] }; + double xyz[3], lab[3]; + mat3_mulv(xyz, tmp_sim.out_rgb2xyz, rgb_d); + xyz_to_oklab(xyz, lab); + return (float)lab[0]; +} + /* ------------------------------------------------------------------------ */ /* build */ /* ------------------------------------------------------------------------ */ diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index 6d30d9340057..5b8c0d2e0fbd 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -387,6 +387,12 @@ void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, void sf_sim_process(const sf_sim_t *sim, const float *rgb_in, float *rgb_out, size_t npix, int nch_in, int nch_out); +/* pre-compression OkLab lightness a single RGB triple would land at, using + * boost_override in place of the sim's own out_luminance_boost -- see + * spektra_sim.c for the full rationale (used by the precompression-boost + * picker in spektrafilm.c). */ +float sf_sim_probe_lightness(const sf_sim_t *sim, const float rgb_in[3], float boost_override); + #ifdef __cplusplus } #endif diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 3c4145f5272e..684926cba44d 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -67,6 +67,7 @@ #include "common/opencl.h" #include "common/gaussian.h" #include "gui/accelerators.h" +#include "gui/color_picker_proxy.h" #include "gui/gtk.h" #include "iop/iop_api.h" @@ -2007,8 +2008,35 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ / fmaxf(pixel_um, 1e-3f); SF_GAUSS_BLUR4(tmpa, gsigma, "grain blur"); const float grenorm = sf_gauss_grain_renorm(fmaxf(gsigma, 0.3f)); + /* Centre grain delta (matches process()'s CPU-side fix exactly -- the + multi-sublayer particle generator has a small positive DC bias that + renorm amplifies proportionally to sigma; this correction only ever + existed on the CPU path before, so anyone using OpenCL never got it). + Reading the whole buffer back host-side is the simplest way to get a + full-image reduction; this runs once per grain stage, not per pixel. */ + float gmean = 0.0f; + if(err == CL_SUCCESS) + { + float *const tmpa_host = dt_alloc_align_float(npix * 4); + if(tmpa_host) + { + err = dt_opencl_read_buffer_from_device(devid, tmpa_host, tmpa, 0, npix * f * 4, TRUE); + if(err == CL_SUCCESS) + { + double gsum = 0.0; + for(size_t kk = 0; kk < npix; kk++) + gsum += (double)tmpa_host[kk * 4] + (double)tmpa_host[kk * 4 + 1] + + (double)tmpa_host[kk * 4 + 2]; + gmean = (float)(gsum / (double)(npix * 3)); + } + dt_free_align(tmpa_host); + } + else + err = CL_MEM_OBJECT_ALLOCATION_FAILURE; + } err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2), - CLARG(tmpa), CLARG(w), CLARG(h), CLARG(grenorm)); + CLARG(tmpa), CLARG(w), CLARG(h), CLARG(grenorm), + CLARG(gmean)); SF_CL_STEP("grain add"); if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f) { @@ -2372,6 +2400,67 @@ void gui_update(dt_iop_module_t *self) _update_print_sensitivity(self); } +void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpipe_t *pipe) +{ + dt_iop_spektrafilm_gui_data_t *g = self->gui_data; + if(picker != g->output_luminance_boost) return; + + const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_work_profile_info(pipe); + if(!work_profile) return; + + /* Build a standalone sim from the module's current live params: the + picker runs independently of any specific piece's cached data, so this + is a one-off build for this measurement, not the pipe's own d->sim + (which _ensure_sim also caches on -- see there for what gets set). */ + dt_iop_spektrafilm_data_t d_tmp; + memset(&d_tmp, 0, sizeof(d_tmp)); + d_tmp.p = *(dt_iop_spektrafilm_params_t *)self->params; + dt_pthread_mutex_init(&d_tmp.lock, NULL); + sf_sim_t *sim = _ensure_sim(&d_tmp, work_profile); + if(!sim) + { + dt_pthread_mutex_destroy(&d_tmp.lock); + return; + } + + /* the brightest tone in the picked area is what determines whether the + compressor's knee engages usefully; picked_color_max is already an + area-mode min/max/mean pick (see dt_color_picker_new(..., DT_COLOR_PICKER_AREA, ...) + above in gui_init). */ + const float rgb_max[3] = { self->picked_color_max[0], self->picked_color_max[1], + self->picked_color_max[2] }; + const float current_boost = fmaxf(d_tmp.p.output_luminance_boost, 0.5f); + const float measured_L = sf_sim_probe_lightness(sim, rgb_max, current_boost); + + /* Target: land well past the compressor's knee threshold (SF_OUT_LIGHT_T + = 0.7 in spektra_sim.c), close to but not at its asymptotic limit + (1.0). 0.80 (only 0.10 above the threshold) turned out too + conservative in practice -- left visible unused headroom in the + histogram and read as noticeably dark, since the knee's own + compression only really starts doing useful work well above its + threshold. 0.90 leaves a smaller (0.10) margin before the limit + instead, using more of the available range; the knee handles any + input gracefully by design, so there's no hard-clipping risk in + pushing closer to it. No iteration needed: L scales as boost^(1/3) + exactly (see sf_sim_probe_lightness), so a single probe plus this + closed-form solve is enough. */ + const float target_L = 0.95f; + float new_boost = current_boost; + if(measured_L > 1e-4f) new_boost = current_boost * powf(target_L / measured_L, 3.0f); + new_boost = CLAMP(new_boost, 0.5f, 4.0f); + + if(d_tmp.gpu) sf_sim_gpu_free(d_tmp.gpu); + if(d_tmp.sim) sf_sim_free(d_tmp.sim); + dt_pthread_mutex_destroy(&d_tmp.lock); + + dt_iop_spektrafilm_params_t *p = self->params; + p->output_luminance_boost = new_boost; + DT_ENTER_GUI_UPDATE(); + dt_bauhaus_slider_set(g->output_luminance_boost, new_boost); + DT_LEAVE_GUI_UPDATE(); + dt_dev_add_history_item(darktable.develop, self, TRUE); +} + void gui_init(dt_iop_module_t *self) { dt_iop_spektrafilm_gui_data_t *g = IOP_GUI_ALLOC(spektrafilm); @@ -2565,6 +2654,10 @@ void gui_init(dt_iop_module_t *self) _("pre-compression boost: multiplies XYZ luminance before the" " OkLCh gamut compressor, pushing the histogram right while" " preserving the film's natural shoulder rolloff")); + dt_color_picker_new(self, DT_COLOR_PICKER_AREA, g->output_luminance_boost); + dt_bauhaus_widget_set_quad_tooltip(g->output_luminance_boost, + _("pick brightest tone in the selected area and set the" + " boost so it lands just past the compressor's knee")); self->widget = sf_main_box; } From 505a5b14edb5df08b348d6ade8ca9df4f3d62d2c Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Mon, 20 Jul 2026 19:18:48 +0200 Subject: [PATCH 38/56] fix comment --- src/iop/spektrafilm.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 684926cba44d..e16263c518a1 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -2438,12 +2438,12 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpi conservative in practice -- left visible unused headroom in the histogram and read as noticeably dark, since the knee's own compression only really starts doing useful work well above its - threshold. 0.90 leaves a smaller (0.10) margin before the limit - instead, using more of the available range; the knee handles any - input gracefully by design, so there's no hard-clipping risk in - pushing closer to it. No iteration needed: L scales as boost^(1/3) - exactly (see sf_sim_probe_lightness), so a single probe plus this - closed-form solve is enough. */ + threshold. 0.90 still left some headroom on further testing; 0.95 + (only 0.05 short of the limit) uses close to the full available + range. The knee handles any input gracefully by design, so there's no + hard-clipping risk in pushing this close to it. No iteration needed: + L scales as boost^(1/3) exactly (see sf_sim_probe_lightness), so a + single probe plus this closed-form solve is enough. */ const float target_L = 0.95f; float new_boost = current_boost; if(measured_L > 1e-4f) new_boost = current_boost * powf(target_L / measured_L, 3.0f); From 128c2bb309d625412377f27f8819c396b2a23c7e Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Tue, 21 Jul 2026 06:46:56 +0200 Subject: [PATCH 39/56] add development section with push pull and gamma settings --- src/common/spektra_sim.c | 320 +++++++++++++++++++++++++++++++++++---- src/common/spektra_sim.h | 7 + src/iop/spektrafilm.c | 309 ++++++++++++++++++++++++++++++++++++- 3 files changed, 599 insertions(+), 37 deletions(-) diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 2468f74bd5ac..3ddfa130a182 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -275,6 +275,14 @@ struct sf_sim_t double curves_before[SF_NLE][3]; float curves_norm_f[SF_NLE][3]; /* float copies for fast per-pixel path */ float curves_before_f[SF_NLE][3]; + /* per-sublayer, post-chemistry-morph raw (un-floored) curve values, valid + * only when film->curves_model.n_layers > 0 -- the layer-resolved sibling + * of curves_norm, built alongside it in the same call so grain (which + * needs sublayers) stays consistent with whatever chemistry the total + * curve reflects, matching upstream's own build_film_curves_layers / + * apply_print_curves_morph_with_layers intent. */ + double film_curve_layers[SF_NLE][SF_GRAIN_MAX_SUBLAYERS][3]; + bool film_morph_applied; /* true when film_curve_layers holds valid (morphed) data */ double gamma[3]; double couplers_M[3][3]; /* Langmuir saturating couplers (spektrafilm dev/0.4+); K = INFINITY keeps @@ -915,6 +923,9 @@ void sf_sim_params_defaults(sf_sim_params_t *p) p->morph_active = false; p->morph_gamma = p->morph_gamma_fast = p->morph_gamma_slow = 1.0; p->morph_gamma_r = p->morph_gamma_g = p->morph_gamma_b = 1.0; + p->film_morph_active = false; + p->film_morph_gamma = p->film_morph_gamma_fast = p->film_morph_gamma_slow = 1.0; + p->film_morph_developer_exhaustion = 0.0; p->scan_film = false; p->lut_steps = 0; p->input_gamut_compress = true; @@ -1479,31 +1490,52 @@ static void _sf_build_grain_layers(sf_sim_t *s, const sf_profile_t *film, return; } - /* multi-sublayer case: evaluate each sub-layer's CDF term separately (no - morph -- the film side has no live chemistry re-evaluation, only the - print does, see build_print_curves), matching grain.py's - interp_density_cmy_layers_channel/_coarsest_area_from_curves inputs. */ + /* multi-sublayer case: use the (possibly chemistry-morphed) per-sublayer + curves computed alongside curves_norm when the film-side chemistry + morph is active, matching upstream's "regenerating the grain + sublayers from the same morphed params so grain stays consistent"; + otherwise evaluate each sub-layer's raw CDF term directly, matching + grain.py's interp_density_cmy_layers_channel/_coarsest_area_from_curves + inputs. */ const int positive = s->film_positive; double layer_curve[SF_NLE][SF_GRAIN_MAX_SUBLAYERS][3]; double layer_max_raw[SF_GRAIN_MAX_SUBLAYERS][3]; - for(int c = 0; c < 3; c++) + if(s->film_morph_applied) { - double centers[8], amps[8], sigmas[8]; - memcpy(centers, m->centers[c], sizeof(double) * nl); - memcpy(amps, m->amplitudes[c], sizeof(double) * nl); - memcpy(sigmas, m->sigmas[c], sizeof(double) * nl); - for(int l = 0; l < nl; l++) + for(int c = 0; c < 3; c++) + for(int l = 0; l < nl; l++) + { + double mx = -1e300; + for(int i = 0; i < SF_NLE; i++) + { + const double v = s->film_curve_layers[i][l][c]; + layer_curve[i][l][c] = v; + if(v > mx) mx = v; + } + layer_max_raw[l][c] = mx; + } + } + else + { + for(int c = 0; c < 3; c++) { - double mx = -1e300; - for(int i = 0; i < SF_NLE; i++) + double centers[8], amps[8], sigmas[8]; + memcpy(centers, m->centers[c], sizeof(double) * nl); + memcpy(amps, m->amplitudes[c], sizeof(double) * nl); + memcpy(sigmas, m->sigmas[c], sizeof(double) * nl); + for(int l = 0; l < nl; l++) { - double z = (film->log_exposure[i] - centers[l]) / sigmas[l]; - if(positive) z = -z; - const double v = amps[l] * norm_cdf(z); - layer_curve[i][l][c] = v; - if(v > mx) mx = v; + double mx = -1e300; + for(int i = 0; i < SF_NLE; i++) + { + double z = (film->log_exposure[i] - centers[l]) / sigmas[l]; + if(positive) z = -z; + const double v = amps[l] * norm_cdf(z); + layer_curve[i][l][c] = v; + if(v > mx) mx = v; + } + layer_max_raw[l][c] = mx; } - layer_max_raw[l][c] = mx; } } @@ -1564,6 +1596,206 @@ static void _sf_build_grain_layers(sf_sim_t *s, const sf_profile_t *film, for(int l = 0; l < nl; l++) s->grain_particle_scale[l] = particle_scale[l]; } +/* [mc] Gumbel-max CDF, width/location matched to give a plausible + * "exhausted developer" shoulder shape when blended with the fitted + * norm_cdfs model (matches morph_curves.py's _gumbel_matched_cdf). */ +static double _sf_gumbel_matched_cdf(double z) +{ + const double location = -log(log(2.0)); + const double width = 0.5 * log(2.0) * sqrt(2.0 * M_PI); + return exp(-exp(-(z / width + location))); +} + +/* norm_cdf blended toward the Gumbel shoulder by gumbel_mix in [0,1] -- the + * per-layer building block for developer exhaustion (matches + * morph_curves.py's _layer_cdf; `z` already sign-flipped for positive + * stocks by the caller, matching eval_cdfs_channel's own convention). */ +static double _sf_layer_cdf_mixed(double z, double gumbel_mix) +{ + double cdf = norm_cdf(z); + if(gumbel_mix > 0.0) cdf = (1.0 - gumbel_mix) * cdf + gumbel_mix * _sf_gumbel_matched_cdf(z); + return cdf; +} + +/* Summed channel density at one exposure point, given explicit per-layer + * centers/amplitudes/sigmas and an optional per-layer gumbel_mix (NULL == + * all zero) -- the evaluation primitive the exhaustion offset solver below + * needs (matches morph_curves.py's _evaluate_channel_density, at a single + * point since that's all the solver needs). */ +static double _sf_channel_density_at(double x, const double *centers, const double *amps, + const double *sigmas, int n_layers, + const double *gumbel_mix, int positive) +{ + double total = 0.0; + for(int l = 0; l < n_layers; l++) + { + double z = (x - centers[l]) / sigmas[l]; + if(positive) z = -z; + total += amps[l] * _sf_layer_cdf_mixed(z, gumbel_mix ? gumbel_mix[l] : 0.0); + } + return total; +} + +/* Find the horizontal (center) offset that keeps D(0) -- density at zero + * log-exposure, i.e. midgray/fog -- unchanged once developer exhaustion + * (a gumbel blend toward a matched shoulder) is applied, so exhaustion + * changes shoulder shape without shifting midgray. Matches + * morph_curves.py's _developer_exhaustion_center_offset (same bracket + * [-0.25, 0.25], doubled up to 12 times looking for a sign change), but + * bisection instead of Brent's method: standard C, no extra dependency, + * and precision matters far more than speed here since this runs once per + * channel per sim build, never per pixel. Returns 0 if gumbel_mix is zero + * or no sign change is found (matching upstream's own fallback-to-zero). */ +static double _sf_developer_exhaustion_offset(const double *centers, const double *amps, + const double *sigmas, int n_layers, + double gumbel_mix, int positive) +{ + if(gumbel_mix <= 0.0) return 0.0; + + const double target_d0 = _sf_channel_density_at(0.0, centers, amps, sigmas, n_layers, NULL, positive); + double gmix[SF_GRAIN_MAX_SUBLAYERS]; + for(int l = 0; l < n_layers; l++) gmix[l] = gumbel_mix; + + double shifted[SF_GRAIN_MAX_SUBLAYERS]; + double lo = -0.25, hi = 0.25; + for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + lo; + double r_lo = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + hi; + double r_hi = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + if(r_lo == 0.0) return lo; + if(r_hi == 0.0) return hi; + + int bracketed = 0; + for(int iter = 0; iter < 12; iter++) + { + if(r_lo * r_hi < 0.0) { bracketed = 1; break; } + lo *= 2.0; + hi *= 2.0; + for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + lo; + r_lo = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + hi; + r_hi = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + if(r_lo == 0.0) return lo; + if(r_hi == 0.0) return hi; + } + if(!bracketed) return 0.0; + + double mid = 0.0; + for(int iter = 0; iter < 60; iter++) + { + mid = 0.5 * (lo + hi); + for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + mid; + const double r_mid = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + if(r_mid == 0.0 || (hi - lo) < 1e-12) break; + if((r_lo < 0.0) == (r_mid < 0.0)) { lo = mid; r_lo = r_mid; } + else hi = mid; + } + return mid; +} + +/* Apply the s023 coupled-gamma + developer-exhaustion morph to one + * channel's fitted curve-model parameters (matches morph_curves.py's + * _morph_channel_params). Amplitudes are unchanged by this morph -- only + * centers/sigmas move, plus the per-layer gumbel_mix output (uniformly + * `exhaustion` across every layer, matching upstream). Layer speed order + * (fast/mid/slow) is by ascending center, same as _sf_build_grain_layers' + * own convention elsewhere in this file, and the per-channel skew term + * (alpha) upstream's fit can carry isn't modeled here since this file's + * curves_model has no alpha field to begin with -- eval_cdfs_channel + * already made that same simplification for the print side. */ +static void _sf_morph_channel(const double centers_in[], const double sigmas_in[], int nl, + int positive, double gamma, double gamma_fast, double gamma_slow, + double exhaustion, const double amps[], + double centers_out[], double sigmas_out[], double gumbel_mix_out[]) +{ + int order[SF_GRAIN_MAX_SUBLAYERS]; + for(int i = 0; i < nl; i++) order[i] = i; + for(int i = 0; i < nl; i++) + for(int j = i + 1; j < nl; j++) + if(centers_in[order[j]] < centers_in[order[i]]) + { + const int t = order[i]; + order[i] = order[j]; + order[j] = t; + } + const int i_fast = order[0], i_mid = order[nl / 2], i_slow = order[nl - 1]; + + const double g_fast = gamma * gamma_fast; + const double g_mid = gamma * gamma_slow; /* [mc]: mid intentionally uses the "slow" factor */ + const double g_slow = g_mid; + + for(int i = 0; i < nl; i++) + { + centers_out[i] = centers_in[i]; + sigmas_out[i] = sigmas_in[i]; + gumbel_mix_out[i] = exhaustion; + } + sigmas_out[i_fast] = fmax(sigmas_in[i_fast] / g_fast, SF_SIGMA_FLOOR); + centers_out[i_fast] = centers_in[i_fast] / g_fast; + sigmas_out[i_mid] = fmax(sigmas_in[i_mid] / g_mid, SF_SIGMA_FLOOR); + centers_out[i_mid] = centers_in[i_mid] / g_mid; + sigmas_out[i_slow] = fmax(sigmas_in[i_slow] / g_slow, SF_SIGMA_FLOOR); + centers_out[i_slow] = centers_in[i_slow] / g_slow; + + if(exhaustion > 0.0) + { + const double offset = _sf_developer_exhaustion_offset(centers_out, amps, sigmas_out, nl, + exhaustion, positive); + for(int i = 0; i < nl; i++) centers_out[i] += offset; + } +} + +/* Film-side counterpart of build_print_curves: applies the s023 chemistry + * morph (gamma / fast / slow / developer exhaustion) to the film's own + * fitted density-curve model, and -- since grain needs to stay consistent + * with whatever curve chemistry produces (matches upstream's own + * "regenerating the grain sublayers from the same morphed params") -- + * also writes the per-sublayer breakdown to layers_out. Only called when + * film->curves_model.n_layers > 0; the caller falls back to the profile's + * static density_curves array otherwise (matching upstream's own check). */ +static void build_film_curves(double (*curves)[3], double (*layers_out)[SF_GRAIN_MAX_SUBLAYERS][3], + const sf_profile_t *film, const sf_sim_params_t *p) +{ + const int positive = (film->type && strcmp(film->type, "positive") == 0); + const sf_curves_model_t *m = &film->curves_model; + const int nl = m->n_layers; + + for(int c = 0; c < 3; c++) + { + double centers[SF_GRAIN_MAX_SUBLAYERS], amps[SF_GRAIN_MAX_SUBLAYERS], sigmas[SF_GRAIN_MAX_SUBLAYERS]; + memcpy(centers, m->centers[c], sizeof(double) * nl); + memcpy(amps, m->amplitudes[c], sizeof(double) * nl); + memcpy(sigmas, m->sigmas[c], sizeof(double) * nl); + + double mcenters[SF_GRAIN_MAX_SUBLAYERS], msigmas[SF_GRAIN_MAX_SUBLAYERS], + gmix[SF_GRAIN_MAX_SUBLAYERS]; + if(p->film_morph_active) + _sf_morph_channel(centers, sigmas, nl, positive, p->film_morph_gamma, + p->film_morph_gamma_fast, p->film_morph_gamma_slow, + p->film_morph_developer_exhaustion, amps, mcenters, msigmas, gmix); + else + { + memcpy(mcenters, centers, sizeof(double) * nl); + memcpy(msigmas, sigmas, sizeof(double) * nl); + for(int i = 0; i < nl; i++) gmix[i] = 0.0; + } + + for(int i = 0; i < SF_NLE; i++) + { + double total = 0.0; + for(int l = 0; l < nl; l++) + { + double z = (film->log_exposure[i] - mcenters[l]) / msigmas[l]; + if(positive) z = -z; + const double v = amps[l] * _sf_layer_cdf_mixed(z, gmix[l]); + layers_out[i][l][c] = v; + total += v; + } + curves[i][c] = total; + } + } +} + static void build_print_curves(double (*curves)[3], const sf_profile_t *print, const sf_sim_params_t *p) { @@ -2172,23 +2404,51 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, s->le_step = (film->log_exposure[SF_NLE - 1] - film->log_exposure[0]) / (SF_NLE - 1); s->inv_le_step = (float)(1.0 / s->le_step); for(int c = 0; c < 3; c++) s->gamma[c] = p->density_curve_gamma; - for(int c = 0; c < 3; c++) + if(p->film_morph_active && film->curves_model.n_layers > 0) { - double mn = INFINITY, mx = -INFINITY; - for(int i = 0; i < SF_NLE; i++) + double curves_tmp[SF_NLE][3]; + build_film_curves(curves_tmp, s->film_curve_layers, film, p); + for(int c = 0; c < 3; c++) { - const double v = film->density_curves[i][c]; - if(v < mn) mn = v; - if(v > mx) mx = v; + double mn = INFINITY, mx = -INFINITY; + for(int i = 0; i < SF_NLE; i++) + { + const double v = curves_tmp[i][c]; + if(v < mn) mn = v; + if(v > mx) mx = v; + } + for(int i = 0; i < SF_NLE; i++) + { + const double v = curves_tmp[i][c] - mn; + s->curves_norm[i][c] = v; + s->curves_norm_f[i][c] = (float)v; + } + s->film_dmax[c] = mx - mn; + s->film_dmin[c] = mn; } - for(int i = 0; i < SF_NLE; i++) + s->film_morph_applied = true; + } + else + { + for(int c = 0; c < 3; c++) { - const double v = film->density_curves[i][c] - mn; - s->curves_norm[i][c] = v; - s->curves_norm_f[i][c] = (float)v; + double mn = INFINITY, mx = -INFINITY; + for(int i = 0; i < SF_NLE; i++) + { + const double v = film->density_curves[i][c]; + if(v < mn) mn = v; + if(v > mx) mx = v; + } + for(int i = 0; i < SF_NLE; i++) + { + const double v = film->density_curves[i][c] - mn; + s->curves_norm[i][c] = v; + s->curves_norm_f[i][c] = (float)v; + } + s->film_dmax[c] = mx - mn; + s->film_dmin[c] = mn; } - s->film_dmax[c] = mx - mn; - s->film_dmin[c] = mn; + s->film_morph_applied = false; } /* [cp] per-film grain catalogue data (film_render_defaults[stock].grain); falls back to spektrafilm's original single fixed profile when the pack diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index 5b8c0d2e0fbd..5827db885dc5 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -303,6 +303,13 @@ typedef struct sf_sim_params_t double morph_gamma, morph_gamma_fast, morph_gamma_slow; double morph_gamma_r, morph_gamma_g, morph_gamma_b; + /* film curve chemistry (s023) — same morph as print's, applied to the + * film's own fitted density-curve model instead, plus developer + * exhaustion (not yet wired for print). Identity at defaults. */ + bool film_morph_active; + double film_morph_gamma, film_morph_gamma_fast, film_morph_gamma_slow; + double film_morph_developer_exhaustion; + /* scanning / output */ bool scan_film; /* false: full negative→print→scan chain */ int lut_steps; /* 0 = exact spectral per pixel; diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index e16263c518a1..75e6bae90804 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -87,7 +87,7 @@ #include "common/spektra_core.h" #include "common/spektra_sim.h" -DT_MODULE_INTROSPECTION(6, dt_iop_spektrafilm_params_t) +DT_MODULE_INTROSPECTION(8, dt_iop_spektrafilm_params_t) /* Spatial-scale constants, micrometres on film unless noted (see the LUT module for the full rationale; these are shared with modify_roi_in() and @@ -113,6 +113,18 @@ DT_MODULE_INTROSPECTION(6, dt_iop_spektrafilm_params_t) * safely conservative. */ #define SF_GRAIN_STRENGTH_CAL 1.20f #define SF_GRAIN_SIZE_CAL 0.65f +/* Push/pull processing is really two things happening together: shooting + * at an effective ISO different from box speed (already modeled via + * exposure_ev), plus extended/reduced development time, which increases + * or decreases contrast -- the gamma knob. There's no single fixed + * physical constant for how much contrast one stop of push buys (it + * depends on the specific film/developer combination, which isn't + * modeled here), so this is a documented approximation: each stop + * multiplies gamma by this factor, a commonly-cited rule of thumb + * (roughly a 15% contrast increase per stop). Compounds naturally across + * multiple stops (push 2 = factor^2), which suits gamma being a + * multiplicative quantity in this model to begin with. */ +#define SF_PUSH_PULL_GAMMA_PER_STOP 1.15f #define SF_HALO_SIGMAS 4.0f #define SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM 950.0f /* DIR coupler inhibitor diffusion; spektrafilm params_schema @@ -180,6 +192,11 @@ typedef struct dt_iop_spektrafilm_params_t float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost" float grain_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.8 $DESCRIPTION: "grain recovery sharpness" float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "grain recovery strength" + float film_gamma_factor; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "development gamma" + float film_gamma_factor_fast; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "fast layer gamma" + float film_gamma_factor_slow; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "slow layer gamma" + float film_developer_exhaustion; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "developer exhaustion" + float push_pull_stops; // $MIN: -4.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "push/pull" } dt_iop_spektrafilm_params_t; /* one discovered profile: stock (= file base name), display name, stage */ @@ -198,6 +215,9 @@ typedef struct dt_iop_spektrafilm_gui_data_t { GtkWidget *film, *paper; GtkWidget *exposure_ev, *print_exposure_ev, *print_auto_exposure, *print_contrast, *filter_m, *filter_y; + GtkWidget *film_gamma_factor, *film_gamma_factor_fast, *film_gamma_factor_slow, + *film_developer_exhaustion; + GtkWidget *push_pull_stops; GtkWidget *couplers_amount, *scan_film, *quality; GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift; GtkWidget *halation_on, *scatter_amount, *scatter_scale, *halation_amount, *halation_scale; @@ -565,6 +585,96 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int float output_luminance_boost; } dt_iop_spektrafilm_params_v4_t; + typedef struct dt_iop_spektrafilm_params_v6_t + { + uint32_t film_hash; + uint32_t paper_hash; + float exposure_ev; + float print_exposure_ev; + gboolean print_auto_exposure; + float print_contrast; + float filter_m; + float filter_y; + float couplers_amount; + float preflash_exposure; + float preflash_m_shift; + float preflash_y_shift; + gboolean scan_film; + dt_iop_spektrafilm_quality_t quality; + gboolean halation_on; + float scatter_amount; + float scatter_scale; + float halation_amount; + float halation_scale; + float boost_ev; + float boost_range; + float protect_ev; + gboolean diffusion_on; + dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; + float diffusion_strength; + float diffusion_scale; + float diffusion_warmth; + gboolean print_diffusion_on; + dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; + float print_diffusion_strength; + float print_diffusion_scale; + float print_diffusion_warmth; + gboolean grain_on; + float grain_amount; + float grain_size; + float film_format_mm; + float output_luminance_boost; + float grain_usm_sigma; + float grain_usm_amount; + } dt_iop_spektrafilm_params_v6_t; + + typedef struct dt_iop_spektrafilm_params_v7_t + { + uint32_t film_hash; + uint32_t paper_hash; + float exposure_ev; + float print_exposure_ev; + gboolean print_auto_exposure; + float print_contrast; + float filter_m; + float filter_y; + float couplers_amount; + float preflash_exposure; + float preflash_m_shift; + float preflash_y_shift; + gboolean scan_film; + dt_iop_spektrafilm_quality_t quality; + gboolean halation_on; + float scatter_amount; + float scatter_scale; + float halation_amount; + float halation_scale; + float boost_ev; + float boost_range; + float protect_ev; + gboolean diffusion_on; + dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; + float diffusion_strength; + float diffusion_scale; + float diffusion_warmth; + gboolean print_diffusion_on; + dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; + float print_diffusion_strength; + float print_diffusion_scale; + float print_diffusion_warmth; + gboolean grain_on; + float grain_amount; + float grain_size; + float film_format_mm; + float output_luminance_boost; + float grain_usm_sigma; + float grain_usm_amount; + float film_gamma_factor; + float film_gamma_factor_fast; + float film_gamma_factor_slow; + float film_developer_exhaustion; + } dt_iop_spektrafilm_params_v7_t; + if(old_version == 1) { const dt_iop_spektrafilm_params_v1_t *o = (dt_iop_spektrafilm_params_v1_t *)old_params; @@ -616,10 +726,15 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->output_luminance_boost = 1.0f; /* new in v2: neutral default, no-op (matches upstream) */ n->grain_usm_sigma = 0.8f; n->grain_usm_amount = 0.0f; + n->film_gamma_factor = 1.0f; + n->film_gamma_factor_fast = 1.0f; + n->film_gamma_factor_slow = 1.0f; + n->film_developer_exhaustion = 0.0f; + n->push_pull_stops = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 6; + *new_version = 8; return 0; } if(old_version == 2) @@ -670,10 +785,15 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->output_luminance_boost = o->output_luminance_boost; n->grain_usm_sigma = 0.8f; n->grain_usm_amount = 0.0f; + n->film_gamma_factor = 1.0f; + n->film_gamma_factor_fast = 1.0f; + n->film_gamma_factor_slow = 1.0f; + n->film_developer_exhaustion = 0.0f; + n->push_pull_stops = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 6; + *new_version = 8; return 0; } if(old_version == 3) @@ -722,10 +842,15 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->output_luminance_boost = o->output_luminance_boost; n->grain_usm_sigma = 0.8f; n->grain_usm_amount = 0.0f; + n->film_gamma_factor = 1.0f; + n->film_gamma_factor_fast = 1.0f; + n->film_gamma_factor_slow = 1.0f; + n->film_developer_exhaustion = 0.0f; + n->push_pull_stops = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 6; + *new_version = 8; return 0; } if(old_version == 4) @@ -774,10 +899,15 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->output_luminance_boost = o->output_luminance_boost; n->grain_usm_sigma = 0.8f; n->grain_usm_amount = 0.0f; + n->film_gamma_factor = 1.0f; + n->film_gamma_factor_fast = 1.0f; + n->film_gamma_factor_slow = 1.0f; + n->film_developer_exhaustion = 0.0f; + n->push_pull_stops = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 6; + *new_version = 8; return 0; } if(old_version == 5) @@ -787,9 +917,124 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int *n = *o; n->grain_usm_sigma = 0.8f; n->grain_usm_amount = 0.0f; + n->film_gamma_factor = 1.0f; + n->film_gamma_factor_fast = 1.0f; + n->film_gamma_factor_slow = 1.0f; + n->film_developer_exhaustion = 0.0f; + n->push_pull_stops = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 6; + *new_version = 8; + return 0; + } + if(old_version == 6) + { + const dt_iop_spektrafilm_params_v6_t *o = (dt_iop_spektrafilm_params_v6_t *)old_params; + dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); + + n->film_hash = o->film_hash; + n->paper_hash = o->paper_hash; + n->exposure_ev = o->exposure_ev; + n->print_exposure_ev = o->print_exposure_ev; + n->print_auto_exposure = o->print_auto_exposure; + n->print_contrast = o->print_contrast; + n->filter_m = o->filter_m; + n->filter_y = o->filter_y; + n->couplers_amount = o->couplers_amount; + n->preflash_exposure = o->preflash_exposure; + n->preflash_m_shift = o->preflash_m_shift; + n->preflash_y_shift = o->preflash_y_shift; + n->scan_film = o->scan_film; + n->quality = o->quality; + n->halation_on = o->halation_on; + n->scatter_amount = o->scatter_amount; + n->scatter_scale = o->scatter_scale; + n->halation_amount = o->halation_amount; + n->halation_scale = o->halation_scale; + n->boost_ev = o->boost_ev; + n->boost_range = o->boost_range; + n->protect_ev = o->protect_ev; + n->diffusion_on = o->diffusion_on; + n->diffusion_filter_family = o->diffusion_filter_family; + n->diffusion_strength = o->diffusion_strength; + n->diffusion_scale = o->diffusion_scale; + n->diffusion_warmth = o->diffusion_warmth; + n->print_diffusion_on = o->print_diffusion_on; + n->print_diffusion_filter_family = o->print_diffusion_filter_family; + n->print_diffusion_strength = o->print_diffusion_strength; + n->print_diffusion_scale = o->print_diffusion_scale; + n->print_diffusion_warmth = o->print_diffusion_warmth; + n->grain_on = o->grain_on; + n->grain_amount = o->grain_amount; + n->grain_size = o->grain_size; + n->film_format_mm = o->film_format_mm; + n->output_luminance_boost = o->output_luminance_boost; + n->grain_usm_sigma = o->grain_usm_sigma; + n->grain_usm_amount = o->grain_usm_amount; + n->film_gamma_factor = 1.0f; + n->film_gamma_factor_fast = 1.0f; + n->film_gamma_factor_slow = 1.0f; + n->film_developer_exhaustion = 0.0f; + n->push_pull_stops = 0.0f; + + *new_params = n; + *new_params_size = sizeof(dt_iop_spektrafilm_params_t); + *new_version = 8; + return 0; + } + if(old_version == 7) + { + const dt_iop_spektrafilm_params_v7_t *o = (dt_iop_spektrafilm_params_v7_t *)old_params; + dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); + + n->film_hash = o->film_hash; + n->paper_hash = o->paper_hash; + n->exposure_ev = o->exposure_ev; + n->print_exposure_ev = o->print_exposure_ev; + n->print_auto_exposure = o->print_auto_exposure; + n->print_contrast = o->print_contrast; + n->filter_m = o->filter_m; + n->filter_y = o->filter_y; + n->couplers_amount = o->couplers_amount; + n->preflash_exposure = o->preflash_exposure; + n->preflash_m_shift = o->preflash_m_shift; + n->preflash_y_shift = o->preflash_y_shift; + n->scan_film = o->scan_film; + n->quality = o->quality; + n->halation_on = o->halation_on; + n->scatter_amount = o->scatter_amount; + n->scatter_scale = o->scatter_scale; + n->halation_amount = o->halation_amount; + n->halation_scale = o->halation_scale; + n->boost_ev = o->boost_ev; + n->boost_range = o->boost_range; + n->protect_ev = o->protect_ev; + n->diffusion_on = o->diffusion_on; + n->diffusion_filter_family = o->diffusion_filter_family; + n->diffusion_strength = o->diffusion_strength; + n->diffusion_scale = o->diffusion_scale; + n->diffusion_warmth = o->diffusion_warmth; + n->print_diffusion_on = o->print_diffusion_on; + n->print_diffusion_filter_family = o->print_diffusion_filter_family; + n->print_diffusion_strength = o->print_diffusion_strength; + n->print_diffusion_scale = o->print_diffusion_scale; + n->print_diffusion_warmth = o->print_diffusion_warmth; + n->grain_on = o->grain_on; + n->grain_amount = o->grain_amount; + n->grain_size = o->grain_size; + n->film_format_mm = o->film_format_mm; + n->output_luminance_boost = o->output_luminance_boost; + n->grain_usm_sigma = o->grain_usm_sigma; + n->grain_usm_amount = o->grain_usm_amount; + n->film_gamma_factor = o->film_gamma_factor; + n->film_gamma_factor_fast = o->film_gamma_factor_fast; + n->film_gamma_factor_slow = o->film_gamma_factor_slow; + n->film_developer_exhaustion = o->film_developer_exhaustion; + n->push_pull_stops = 0.0f; + + *new_params = n; + *new_params_size = sizeof(dt_iop_spektrafilm_params_t); + *new_version = 8; return 0; } return 1; @@ -1033,6 +1278,11 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, key = _mix64(key, &p->scan_film, sizeof p->scan_film); key = _mix64(key, &p->quality, sizeof p->quality); key = _mix64(key, &p->output_luminance_boost, sizeof p->output_luminance_boost); + key = _mix64(key, &p->film_gamma_factor, sizeof p->film_gamma_factor); + key = _mix64(key, &p->film_gamma_factor_fast, sizeof p->film_gamma_factor_fast); + key = _mix64(key, &p->film_gamma_factor_slow, sizeof p->film_gamma_factor_slow); + key = _mix64(key, &p->film_developer_exhaustion, sizeof p->film_developer_exhaustion); + key = _mix64(key, &p->push_pull_stops, sizeof p->push_pull_stops); key = _mix64(key, m_in, sizeof m_in); key = _mix64(key, m_out, sizeof m_out); @@ -1133,7 +1383,7 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, { sf_sim_params_t sp; sf_sim_params_defaults(&sp); - sp.exposure_comp_ev = p->exposure_ev; + sp.exposure_comp_ev = p->exposure_ev - p->push_pull_stops; sp.print_exposure = powf(2.0f, p->print_exposure_ev); sp.print_exposure_compensation = p->print_auto_exposure; /* normalize_print_exposure stays at sf_sim_params_defaults' true — that combination @@ -1156,6 +1406,17 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, sp.morph_active = true; sp.morph_gamma = p->print_contrast; } + if(p->film_gamma_factor != 1.0f || p->film_gamma_factor_fast != 1.0f + || p->film_gamma_factor_slow != 1.0f || p->film_developer_exhaustion != 0.0f + || p->push_pull_stops != 0.0f) + { + sp.film_morph_active = true; + sp.film_morph_gamma = p->film_gamma_factor + * powf(SF_PUSH_PULL_GAMMA_PER_STOP, p->push_pull_stops); + sp.film_morph_gamma_fast = p->film_gamma_factor_fast; + sp.film_morph_gamma_slow = p->film_gamma_factor_slow; + sp.film_morph_developer_exhaustion = p->film_developer_exhaustion; + } /* darktable pipeline XYZ is D50-relative; the work profile matrices map work RGB <-> that XYZ, so both engine whites are D50 */ static const double d50_xy[2] = { 0.3457, 0.3585 }; @@ -2499,6 +2760,40 @@ void gui_init(dt_iop_module_t *self) g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film"); gtk_widget_set_tooltip_text(g->scan_film, _("view the developed film directly (no print stage)")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "development"))); + g->push_pull_stops = dt_bauhaus_slider_from_params(self, "push_pull_stops"); + dt_bauhaus_slider_set_format(g->push_pull_stops, _(" stops")); + gtk_widget_set_tooltip_text( + g->push_pull_stops, + _("push (positive) or pull (negative) processing: shoot at an effective ISO" + " different from box speed, then under- or over-develop to compensate --" + " combines an exposure shift with a derived contrast increase/decrease" + " (approximate: the exact relationship depends on the specific film/developer" + " combination, which isn't modeled here). Stacks with the granular gamma" + " controls below for further fine-tuning")); + g->film_gamma_factor = dt_bauhaus_slider_from_params(self, "film_gamma_factor"); + dt_bauhaus_slider_set_soft_range(g->film_gamma_factor, 0.25f, 2.0f); + gtk_widget_set_tooltip_text( + g->film_gamma_factor, + _("overall development contrast (morphs the film's density curves) -- extended or" + " reduced development time, as in push/pull processing; 1.0 = normal development")); + g->film_gamma_factor_fast = dt_bauhaus_slider_from_params(self, "film_gamma_factor_fast"); + dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_fast, 0.25f, 2.0f); + gtk_widget_set_tooltip_text( + g->film_gamma_factor_fast, + _("contrast of the fastest (most light-sensitive) emulsion sub-layer only --" + " independent of the slow layer, since push/pull processing doesn't always affect" + " every sub-layer equally")); + g->film_gamma_factor_slow = dt_bauhaus_slider_from_params(self, "film_gamma_factor_slow"); + dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_slow, 0.25f, 2.0f); + gtk_widget_set_tooltip_text( + g->film_gamma_factor_slow, + _("contrast of the mid and slow emulsion sub-layers")); + g->film_developer_exhaustion = dt_bauhaus_slider_from_params(self, "film_developer_exhaustion"); + gtk_widget_set_tooltip_text( + g->film_developer_exhaustion, + _("local developer depletion in dense (highly-exposed) areas: blends the highlight" + " shoulder toward a self-limiting rolloff without shifting midgray (0 = off)")); dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print"))); g->print_exposure_ev = dt_bauhaus_slider_from_params(self, "print_exposure_ev"); dt_bauhaus_slider_set_format(g->print_exposure_ev, _(" EV")); From f838f63dd0c8f2c1837c2caba2f7777c3891bd28 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Tue, 21 Jul 2026 12:32:44 +0200 Subject: [PATCH 40/56] turn auto print exposure off by default and move development and preflash to advanced --- src/iop/spektrafilm.c | 108 +++++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 75e6bae90804..f9e6bff9fba3 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -157,7 +157,7 @@ typedef struct dt_iop_spektrafilm_params_t uint32_t paper_hash; // $DEFAULT: 0 (0 = the film's target print stock) float exposure_ev; // $MIN: -4.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "film exposure" float print_exposure_ev; // $MIN: -3.0 $MAX: 3.0 $DEFAULT: 0.0 $DESCRIPTION: "print exposure" - gboolean print_auto_exposure; // $DEFAULT: TRUE $DESCRIPTION: "auto print exposure" + gboolean print_auto_exposure; // $DEFAULT: FALSE $DESCRIPTION: "auto print exposure" float print_contrast; // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print contrast" float filter_m; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration M" float filter_y; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration Y" @@ -2760,40 +2760,6 @@ void gui_init(dt_iop_module_t *self) g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film"); gtk_widget_set_tooltip_text(g->scan_film, _("view the developed film directly (no print stage)")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "development"))); - g->push_pull_stops = dt_bauhaus_slider_from_params(self, "push_pull_stops"); - dt_bauhaus_slider_set_format(g->push_pull_stops, _(" stops")); - gtk_widget_set_tooltip_text( - g->push_pull_stops, - _("push (positive) or pull (negative) processing: shoot at an effective ISO" - " different from box speed, then under- or over-develop to compensate --" - " combines an exposure shift with a derived contrast increase/decrease" - " (approximate: the exact relationship depends on the specific film/developer" - " combination, which isn't modeled here). Stacks with the granular gamma" - " controls below for further fine-tuning")); - g->film_gamma_factor = dt_bauhaus_slider_from_params(self, "film_gamma_factor"); - dt_bauhaus_slider_set_soft_range(g->film_gamma_factor, 0.25f, 2.0f); - gtk_widget_set_tooltip_text( - g->film_gamma_factor, - _("overall development contrast (morphs the film's density curves) -- extended or" - " reduced development time, as in push/pull processing; 1.0 = normal development")); - g->film_gamma_factor_fast = dt_bauhaus_slider_from_params(self, "film_gamma_factor_fast"); - dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_fast, 0.25f, 2.0f); - gtk_widget_set_tooltip_text( - g->film_gamma_factor_fast, - _("contrast of the fastest (most light-sensitive) emulsion sub-layer only --" - " independent of the slow layer, since push/pull processing doesn't always affect" - " every sub-layer equally")); - g->film_gamma_factor_slow = dt_bauhaus_slider_from_params(self, "film_gamma_factor_slow"); - dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_slow, 0.25f, 2.0f); - gtk_widget_set_tooltip_text( - g->film_gamma_factor_slow, - _("contrast of the mid and slow emulsion sub-layers")); - g->film_developer_exhaustion = dt_bauhaus_slider_from_params(self, "film_developer_exhaustion"); - gtk_widget_set_tooltip_text( - g->film_developer_exhaustion, - _("local developer depletion in dense (highly-exposed) areas: blends the highlight" - " shoulder toward a self-limiting rolloff without shifting midgray (0 = off)")); dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print"))); g->print_exposure_ev = dt_bauhaus_slider_from_params(self, "print_exposure_ev"); dt_bauhaus_slider_set_format(g->print_exposure_ev, _(" EV")); @@ -2819,25 +2785,6 @@ void gui_init(dt_iop_module_t *self) gtk_widget_set_tooltip_text(g->couplers_amount, _("DIR coupler strength: inter-layer inhibition drives saturation" " and edge effects (1.0 = film-accurate, 0 = off)")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "preflash"))); - g->preflash_exposure = dt_bauhaus_slider_from_params(self, "preflash_exposure"); - gtk_widget_set_tooltip_text( - g->preflash_exposure, - _("preflash exposure: a brief, uniform pre-exposure of the print through" - " the film's base density, before the main print exposure -- lifts" - " shadows and reduces contrast (0 = off)")); - g->preflash_m_shift = dt_bauhaus_slider_from_params(self, "preflash_m_shift"); - dt_bauhaus_slider_set_format(g->preflash_m_shift, _(" CC")); - gtk_widget_set_tooltip_text(g->preflash_m_shift, - _("magenta filtration for the preflash exposure only, Kodak CC" - " units from neutral -- independent of the main enlarger" - " filtration above")); - g->preflash_y_shift = dt_bauhaus_slider_from_params(self, "preflash_y_shift"); - dt_bauhaus_slider_set_format(g->preflash_y_shift, _(" CC")); - gtk_widget_set_tooltip_text(g->preflash_y_shift, - _("yellow filtration for the preflash exposure only, Kodak CC" - " units from neutral -- independent of the main enlarger" - " filtration above")); dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "format"))); g->film_format_mm = dt_bauhaus_slider_from_params(self, "film_format_mm"); dt_bauhaus_slider_set_format(g->film_format_mm, _(" mm")); @@ -2940,6 +2887,59 @@ void gui_init(dt_iop_module_t *self) /* ---- tab: advanced ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("advanced"), NULL); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "development"))); + g->push_pull_stops = dt_bauhaus_slider_from_params(self, "push_pull_stops"); + dt_bauhaus_slider_set_format(g->push_pull_stops, _(" stops")); + gtk_widget_set_tooltip_text( + g->push_pull_stops, + _("push (positive) or pull (negative) processing: shoot at an effective ISO" + " different from box speed, then under- or over-develop to compensate --" + " combines an exposure shift with a derived contrast increase/decrease" + " (approximate: the exact relationship depends on the specific film/developer" + " combination, which isn't modeled here). Stacks with the granular gamma" + " controls below for further fine-tuning")); + g->film_gamma_factor = dt_bauhaus_slider_from_params(self, "film_gamma_factor"); + dt_bauhaus_slider_set_soft_range(g->film_gamma_factor, 0.25f, 2.0f); + gtk_widget_set_tooltip_text( + g->film_gamma_factor, + _("overall development contrast (morphs the film's density curves) -- extended or" + " reduced development time, as in push/pull processing; 1.0 = normal development")); + g->film_gamma_factor_fast = dt_bauhaus_slider_from_params(self, "film_gamma_factor_fast"); + dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_fast, 0.25f, 2.0f); + gtk_widget_set_tooltip_text( + g->film_gamma_factor_fast, + _("contrast of the fastest (most light-sensitive) emulsion sub-layer only --" + " independent of the slow layer, since push/pull processing doesn't always affect" + " every sub-layer equally")); + g->film_gamma_factor_slow = dt_bauhaus_slider_from_params(self, "film_gamma_factor_slow"); + dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_slow, 0.25f, 2.0f); + gtk_widget_set_tooltip_text( + g->film_gamma_factor_slow, + _("contrast of the mid and slow emulsion sub-layers")); + g->film_developer_exhaustion = dt_bauhaus_slider_from_params(self, "film_developer_exhaustion"); + gtk_widget_set_tooltip_text( + g->film_developer_exhaustion, + _("local developer depletion in dense (highly-exposed) areas: blends the highlight" + " shoulder toward a self-limiting rolloff without shifting midgray (0 = off)")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "preflash"))); + g->preflash_exposure = dt_bauhaus_slider_from_params(self, "preflash_exposure"); + gtk_widget_set_tooltip_text( + g->preflash_exposure, + _("preflash exposure: a brief, uniform pre-exposure of the print through" + " the film's base density, before the main print exposure -- lifts" + " shadows and reduces contrast (0 = off)")); + g->preflash_m_shift = dt_bauhaus_slider_from_params(self, "preflash_m_shift"); + dt_bauhaus_slider_set_format(g->preflash_m_shift, _(" CC")); + gtk_widget_set_tooltip_text(g->preflash_m_shift, + _("magenta filtration for the preflash exposure only, Kodak CC" + " units from neutral -- independent of the main enlarger" + " filtration above")); + g->preflash_y_shift = dt_bauhaus_slider_from_params(self, "preflash_y_shift"); + dt_bauhaus_slider_set_format(g->preflash_y_shift, _(" CC")); + gtk_widget_set_tooltip_text(g->preflash_y_shift, + _("yellow filtration for the preflash exposure only, Kodak CC" + " units from neutral -- independent of the main enlarger" + " filtration above")); g->quality = dt_bauhaus_combobox_from_params(self, "quality"); gtk_widget_set_tooltip_text(g->quality, _("spectral accuracy vs speed; the tables are PCHIP-interpolated" From 7491a63f45ae00fdd6bd1db8c1bd5b2ff311b31b Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Tue, 21 Jul 2026 12:37:44 +0200 Subject: [PATCH 41/56] rename film and print to media --- src/iop/spektrafilm.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index f9e6bff9fba3..1be5faecc6ed 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -2749,7 +2749,7 @@ void gui_init(dt_iop_module_t *self) dt_gui_box_add(sf_main_box, GTK_WIDGET(g->notebook)); /* ---- tab: film and print ---- */ - self->widget = dt_ui_notebook_page(g->notebook, N_("film and print"), NULL); + self->widget = dt_ui_notebook_page(g->notebook, N_("media"), NULL); dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "film"))); g->exposure_ev = dt_bauhaus_slider_from_params(self, "exposure_ev"); dt_bauhaus_slider_set_format(g->exposure_ev, _(" EV")); From c47fc783ff9443021ca6aae32a6edec75f8706ee Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Tue, 21 Jul 2026 14:38:36 +0200 Subject: [PATCH 42/56] fix picker segfaulting --- src/iop/spektrafilm.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 1be5faecc6ed..a919cd1d8ce6 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -2666,6 +2666,16 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpi dt_iop_spektrafilm_gui_data_t *g = self->gui_data; if(picker != g->output_luminance_boost) return; + /* picked_color_min/max start at sentinel values (+FLT_MAX / -FLT_MAX) + until a real area pick has actually landed; if this callback fires + before that (e.g. some other programmatic trigger of the picker + path), max stays below min and using it directly would feed garbage + (-FLT_MAX for every channel) into the simulation -- that propagates + into a wildly out-of-range LUT lookup and segfaults. Standard + darktable idiom for this check, matching e.g. exposure.c's own + color_picker_apply. */ + if(self->picked_color_max[0] < self->picked_color_min[0]) return; + const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_work_profile_info(pipe); if(!work_profile) return; From 8bba8f9c60b9d2a936580ac88fdfb735016fda3c Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Wed, 22 Jul 2026 09:43:31 +0200 Subject: [PATCH 43/56] fix grain color cast --- data/kernels/spektrafilm.cl | 11 +++++++---- src/iop/spektrafilm.c | 33 ++++++++++++++++++++++----------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 9d1a50633ff2..57d9193032d6 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -565,17 +565,20 @@ __kernel void spektrafilm_grain_gen_ml(__global const float4 *dens, __global flo the multi-sublayer particle generator has a small positive DC bias that renorm amplifies proportionally to sigma). gmean is computed host-side over the whole buffer (see process_cl()) since a full-image reduction is - simplest done there, then passed down as a single scalar. */ + simplest done there, then passed down PER CHANNEL as a float4 -- a + single pooled scalar across R+G+B would leave each channel's own bias + minus the pooled average as an uncorrected residual (a color cast, not + just a brightness bug). */ __kernel void spektrafilm_grain_add(__global float4 *dens_buf, __global const float4 *grain_buf, - const int w, const int h, const float renorm, const float gmean) + const int w, const int h, const float renorm, const float4 gmean) { const int x = get_global_id(0), y = get_global_id(1); if(x >= w || y >= h) return; const size_t k = (size_t)y * w + x; float4 d = dens_buf[k]; float4 g = grain_buf[k]; - dens_buf[k] = (float4)(d.x + (g.x - gmean) * renorm, d.y + (g.y - gmean) * renorm, - d.z + (g.z - gmean) * renorm, d.w); + dens_buf[k] = (float4)(d.x + (g.x - gmean.x) * renorm, d.y + (g.y - gmean.y) * renorm, + d.z + (g.z - gmean.z) * renorm, d.w); } /* Multiplicative unsharp mask after grain blur (study b80). diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index a919cd1d8ce6..0cfd825c422d 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -1705,12 +1705,21 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c sf_blur_plane3(gbuf, w, h, sigma, scratch); /* Centre grain delta: multi-sublayer model has positive DC bias (~0.07) that renorm amplifies proportionally to sigma, making image brighter - at higher LOD. Remove bias before scaling. */ + at higher LOD. Remove bias before scaling. + IMPORTANT: this must be a per-channel mean, not one pooled scalar + across R+G+B -- the per-channel bias differs (channel-dependent + particle counts/scale in the multi-sublayer model), so a single + pooled mean leaves each channel's own bias minus the pooled average + as an uncorrected residual, i.e. a color cast wherever grain is + visually significant. */ { - double gsum = 0.0; - for(size_t kk = 0; kk < npix * 3; kk++) gsum += (double)gbuf[kk]; - const float gmean = (float)(gsum / (double)(npix * 3)); - for(size_t kk = 0; kk < npix * 3; kk++) gbuf[kk] -= gmean; + double gsum[3] = { 0.0, 0.0, 0.0 }; + for(size_t kk = 0; kk < npix; kk++) + for(int c = 0; c < 3; c++) gsum[c] += (double)gbuf[kk * 3 + c]; + float gmean[3]; + for(int c = 0; c < 3; c++) gmean[c] = (float)(gsum[c] / (double)npix); + for(size_t kk = 0; kk < npix; kk++) + for(int c = 0; c < 3; c++) gbuf[kk * 3 + c] -= gmean[c]; } const float renorm = sf_gauss_grain_renorm(fmaxf(sigma, 0.3f)); #ifdef _OPENMP @@ -2274,8 +2283,11 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ renorm amplifies proportionally to sigma; this correction only ever existed on the CPU path before, so anyone using OpenCL never got it). Reading the whole buffer back host-side is the simplest way to get a - full-image reduction; this runs once per grain stage, not per pixel. */ - float gmean = 0.0f; + full-image reduction; this runs once per grain stage, not per pixel. + Must be a per-channel mean, not one pooled scalar across R+G+B -- + see the matching comment in process() for why a pooled mean leaves + a per-channel residual (a color cast) uncorrected. */ + float gmean[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; if(err == CL_SUCCESS) { float *const tmpa_host = dt_alloc_align_float(npix * 4); @@ -2284,11 +2296,10 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ err = dt_opencl_read_buffer_from_device(devid, tmpa_host, tmpa, 0, npix * f * 4, TRUE); if(err == CL_SUCCESS) { - double gsum = 0.0; + double gsum[3] = { 0.0, 0.0, 0.0 }; for(size_t kk = 0; kk < npix; kk++) - gsum += (double)tmpa_host[kk * 4] + (double)tmpa_host[kk * 4 + 1] - + (double)tmpa_host[kk * 4 + 2]; - gmean = (float)(gsum / (double)(npix * 3)); + for(int c = 0; c < 3; c++) gsum[c] += (double)tmpa_host[kk * 4 + c]; + for(int c = 0; c < 3; c++) gmean[c] = (float)(gsum[c] / (double)npix); } dt_free_align(tmpa_host); } From 7dd8790c0a3886fbf3e3b5bf6fc4b0c1298abf08 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Wed, 22 Jul 2026 19:47:04 +0200 Subject: [PATCH 44/56] fix module reset not always resetting to default parameters the first time --- src/iop/spektrafilm.c | 50 +++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 0cfd825c422d..45e54f995f1e 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -2441,23 +2441,19 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) if(fi < 0) return; const sf_prof_entry_t *e = &g->entries[fi]; p->film_hash = e->hash; - /* Keep the "default" scan_film following this film's own positive/negative - type too, not just the live params -- so a double-click reset (which - resets to self->default_params, whether via darktable's own bauhaus - reset or a checkbox-reset mechanism for this field) means "what this - film actually needs" rather than the module's one-size-fits-all factory - default (FALSE). Without this, resetting scan_film on a positive/ - reversal film would silently break it, since that film has no print - stage at all. */ - if(self->default_params) - ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = e->positive; /* The core checkbox-reset mechanism (darktable-core-toggle-reset.patch) doesn't re-read self->default_params live at reset time -- it captures each checkbox's default once, at widget-creation time, as opaque - "dt-toggle-default" data on the button itself. So the update above - alone doesn't reach it; poke the widget's own cached value too, - otherwise a reset still uses whatever default_params->scan_film was - when the module GUI first built (before any film was ever selected). */ + "dt-toggle-default" data on the button itself. Poke that cached value + here so a right-click/scroll reset on just this checkbox means "what + this film actually needs" (slides/reversal stocks: scan; negatives: + print) rather than the module's one-size-fits-all factory default + (FALSE). This must NOT also write self->default_params->scan_film: + that field is darktable-core's stable factory default, read directly + by memcpy() on a whole-module reset -- mutating it here made a + whole-module reset copy whatever film was last selected instead of + the true factory default, requiring two resets in a row to actually + converge (confirmed via gui_update tracing across a stack-paste). */ if(g->scan_film) g_object_set_data(G_OBJECT(g->scan_film), "dt-toggle-default", GINT_TO_POINTER(e->positive)); /* scan-film follows the film's natural mode on a film switch: slides and @@ -2627,23 +2623,25 @@ void gui_update(dt_iop_module_t *self) on darktable.gui->reset, which gui_update runs under, so programmatic loads don't get treated as user edits / spawn spurious history items). That means its scan_film "what should a reset target" bookkeeping -- - self->default_params->scan_film and the checkbox's own cached - "dt-toggle-default" -- never gets re-baselined on a fresh module load, - only when the user actually interacts with the film combobox. Left - alone, both stay at the compiled FALSE default after e.g. closing and - reopening darktable on an image using a positive/reversal film (which - has no print stage and needs scan_film TRUE): the checkbox itself still - shows correctly checked here (synced from p->scan_film below), but a - later double-click reset on it would silently flip scan_film back off. - Re-baseline both here too, exactly like _film_changed does -- but + the checkbox's own cached "dt-toggle-default" -- never gets + re-baselined on a fresh module load, only when the user actually + interacts with the film combobox. Left alone, it stays at the compiled + FALSE default after e.g. closing and reopening darktable on an image + using a positive/reversal film (which has no print stage and needs + scan_film TRUE): the checkbox itself still shows correctly checked + here (synced from p->scan_film below), but a later right-click/scroll + reset on just this checkbox would silently flip scan_film back off. + Re-baseline it here too, exactly like _film_changed does -- but WITHOUT touching p->scan_film itself, since the just-loaded value may be a deliberate user override away from the film's natural mode and - must be preserved on load; only the reset target needs fixing. */ + must be preserved on load; only the reset target needs fixing. Do NOT + also write self->default_params->scan_film here: that field is + darktable-core's stable factory default, read directly by memcpy() on + a whole-module reset -- mutating it made a whole-module reset copy + whatever film was last selected instead of the true factory default. */ if(fi < g->n_films) { const sf_prof_entry_t *e = &g->entries[g->film_entry[fi]]; - if(self->default_params) - ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = e->positive; if(g->scan_film) g_object_set_data(G_OBJECT(g->scan_film), "dt-toggle-default", GINT_TO_POINTER(e->positive)); } From bd2eb33cd8262c6744642d0590ce738afb5d2971 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Thu, 23 Jul 2026 07:41:11 +0200 Subject: [PATCH 45/56] use long side for film format and default to 35mm --- src/iop/spektrafilm.c | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 45e54f995f1e..4fa435f2988d 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -188,7 +188,7 @@ typedef struct dt_iop_spektrafilm_params_t gboolean grain_on; // $DEFAULT: TRUE $DESCRIPTION: "enable grain" float grain_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain strength" float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" - float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "film format" + float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 35.0 $DESCRIPTION: "film format" float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost" float grain_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.8 $DESCRIPTION: "grain recovery sharpness" float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "grain recovery strength" @@ -1506,8 +1506,13 @@ void modify_roi_in(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, *roi_in = *roi_out; const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data; if(!d) return; - const float full_w = fmaxf((float)piece->buf_in.width * roi_out->scale, 1.0f); - const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; + /* film_format_mm is the format's long-edge dimension; buf_in.width alone + is the SHORT edge for portrait-oriented images (post-orientation), so + using it directly here would under-scale pixel_um (and every halo/grain/ + halation size derived from it) by the aspect ratio for portrait shots. */ + const float full_long_edge + = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_out->scale, 1.0f); + const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge; const int halo = (int)ceilf(SF_HALO_SIGMAS * _max_halo_sigma(&d->p, pixel_um)); if(halo <= 0) return; const int img_w = (int)roundf((float)piece->buf_in.width * roi_out->scale); @@ -1529,8 +1534,10 @@ void tiling_callback(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, dt_develop_tiling_t *tiling) { const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data; - const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f); - const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; + /* see modify_roi_in: film_format_mm is the long-edge dimension */ + const float full_long_edge + = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f); + const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge; tiling->factor = 2.5f; /* 4 float4 buffers, but they alias in practice */ tiling->factor_cl = 4.0f; /* + gtmp4 (1 float4) + plane1 and gtmp1 (1ch each, 1/4 float4) */ tiling->maxbuf = 1.0f; @@ -1579,9 +1586,11 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c return; } - /* physical micrometres per pixel at this pipe resolution */ - const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f); - const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; + /* physical micrometres per pixel at this pipe resolution; film_format_mm + is the long-edge dimension, see modify_roi_in */ + const float full_long_edge + = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f); + const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge; float *plane = dt_alloc_align_float(npix * 3); /* raw / lograw / cmy, in place */ float *corr = dt_alloc_align_float(npix * 3); /* DIR coupler correction field */ @@ -1810,8 +1819,10 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(oh), CLARG(ox), CLARG(oy)); if(!g) return DT_OPENCL_DEFAULT_ERROR; /* exact quality etc. -> CPU fallback */ - const float full_w = fmaxf((float)piece->buf_in.width * roi_in->scale, 1.0f); - const float pixel_um = d->p.film_format_mm * 1000.0f / full_w; + /* film_format_mm is the long-edge dimension, see modify_roi_in */ + const float full_long_edge + = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f); + const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge; /* ---- table uploads (read-only buffers) -------------------------------- */ /* packed matrix block: layout must match the SF_M_* offsets in the .cl */ @@ -2808,7 +2819,7 @@ void gui_init(dt_iop_module_t *self) g->film_format_mm = dt_bauhaus_slider_from_params(self, "film_format_mm"); dt_bauhaus_slider_set_format(g->film_format_mm, _(" mm")); gtk_widget_set_tooltip_text(g->film_format_mm, - _("physical film width; sets the scale of grain and halation")); + _("physical film format, long side; sets the scale of grain and halation")); /* ---- tab: grain ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("grain"), NULL); From 476c0006d2a73ecfb9831ffb489fd297c2effb48 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Thu, 23 Jul 2026 07:42:23 +0200 Subject: [PATCH 46/56] make grain adhere to reference implementation and set default parameters --- data/kernels/spektrafilm.cl | 224 ++++++++++------------ src/common/spektra_core.c | 64 +++---- src/common/spektra_core.h | 34 ++-- src/common/spektra_sim.c | 43 ++++- src/common/spektra_sim.h | 18 +- src/iop/spektrafilm.c | 360 +++++++++++++++++++++++++----------- 6 files changed, 453 insertions(+), 290 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 57d9193032d6..5dfebc1b104a 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -402,61 +402,6 @@ __kernel void spektrafilm_develop(__global const float4 *lograw, __global const cmy[k] = (float4)(out[0], out[1], out[2], lg.w); } -/* stage 4: grain delta on the developed CMY density (spektra_core model); - delta is blurred host-side, then added back by spektrafilm_grain_add */ -__kernel void spektrafilm_grain_gen(__global const float4 *dens, __global float4 *grain_buf, - const int w, const int h, const float grain_amount, - const int roi_x, const int roi_y, const int mono, - const float dmax0, const float dmax1, const float dmax2, - const float dmin0, const float dmin1, const float dmin2, - const float rms0, const float rms1, const float rms2, - const float unf0, const float unf1, const float unf2) -{ - const int x = get_global_id(0), y = get_global_id(1); - if(x >= w || y >= h) return; - const size_t k = (size_t)y * w + x; - const float4 d4 = dens[k]; - const float dmn[3] = { dmin0, dmin1, dmin2 }; - /* per-film emulsion D-max: a hardcoded colour-negative 2.2 saturates the - particle model in dense slide areas and tints them channel-dependently */ - const float dmc[3] = { fmax(dmax0, 1e-3f), fmax(dmax1, 1e-3f), fmax(dmax2, 1e-3f) }; - /* per-film catalogue grain (rms-granularity, uniformity) from - film_render_defaults[stock].grain — replaces the earlier one-size-fits-all - constants so e.g. Portra 400 and Tri-X render distinct grain */ - const float rms[3] = { rms0, rms1, rms2 }, unf[3] = { unf0, unf1, unf2 }; - const float A48 = 3.14159265f * 24.0f * 24.0f; - const float ref_um = 10.0f, pix = ref_um * ref_um; - const float dd[3] = { d4.x, d4.y, d4.z }; - float gd[3]; - if(mono) /* B&W stock: one achromatic grain realisation for all channels */ - { - const float dm = (dd[0] + dd[1] + dd[2]) / 3.0f; - const float dmax = dmc[1] + dmn[1], d_ref = 1.0f + dmn[1]; - const float sig = rms[1] / 1000.0f; - const float denom = fmax(d_ref * (dmax - unf[1] * d_ref), 1e-6f); - const float a_grain = sig * sig * A48 / denom; - const float npart = pix / fmax(a_grain, 1e-4f); - const float din = dm + dmn[1]; - uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), 0u); - const float gval = sf_layer_particle(din, dmax, npart, unf[1], seed) - dmn[1]; - const float dl = (gval - dm) * grain_amount; - grain_buf[k] = (float4)(dl, dl, dl, 0.f); - return; - } - for(int c = 0; c < 3; c++) - { - float dmax = dmc[c] + dmn[c], din = dd[c] + dmn[c]; - float d_ref = 1.0f + dmn[c], sig = rms[c] / 1000.0f; - float denom = fmax(d_ref * (dmax - unf[c] * d_ref), 1e-6f); - float a_grain = sig * sig * A48 / denom; - float npart = pix / fmax(a_grain, 1e-4f); - uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), (uint)c); - float g = sf_layer_particle(din, dmax, npart, unf[c], seed) - dmn[c]; - gd[c] = (g - dd[c]) * grain_amount; - } - grain_buf[k] = (float4)(gd[0], gd[1], gd[2], 0.f); -} - /* Inverse-lookup: given the already-computed NET total density `target` for * one channel, find its fractional position on the [0, n) exposure-grid * axis by searching `arr` (assumed monotonic -- guaranteed for a sum of @@ -493,92 +438,117 @@ static float sf_cl_grain_curve_sample(__global const float *arr, int n, int stri return arr[i0 * stride] * (1.0f - frac) + arr[(i0 + 1) * stride] * frac; } -/* Multi-sublayer grain delta (see sf_grain_delta_ml, spektra_sim.c): only - * dispatched when n_sub > 1 -- a film whose own fitted density-curve model - * has more than one emulsion sub-layer. For n_sub<=1 the host dispatches - * spektrafilm_grain_gen above instead, unchanged. Sums each sub-layer's - * independent particle draw, recovering that sub-layer's own density by - * inverse-interpolating the self-consistent summed sub-layer curve at the - * exposure position the already-computed total density corresponds to. - * layer_curve is [nle][max_sub][3] row-major, layer_curve_total is - * [nle][3]; max_sub (SF_GRAIN_MAX_SUBLAYERS host-side) is the table's - * fixed stride regardless of how many sub-layers n_sub actually uses. */ -__kernel void spektrafilm_grain_gen_ml(__global const float4 *dens, __global float4 *grain_buf, - const int w, const int h, const float grain_amount, - const int roi_x, const int roi_y, const int mono, - const int n_sub, const int nle, const int max_sub, - const float dmin0, const float dmin1, const float dmin2, - const float unf0, const float unf1, const float unf2, - __global const float *layer_dmax, - __global const float *layer_npart, - __global const float *layer_dmin, - __global const float *layer_curve_total, - __global const float *layer_curve) +/* stage 4: grain. Restructured into three kernels (raw sub-layer sample, + accumulate, finalize) instead of one that directly produced the final + combined delta, so upstream's per-sub-layer dye-cloud blur + (layer_particle_model's blur_particle, grain.py) can run on each raw + sub-layer buffer independently between sampling and combining -- see + the matching comment in process()'s CPU path for the full rationale. + channel_idx is 1 for mono (matching the old kernels' convention of + using channel 1's curve/params for the achromatic draw) or 0/1/2 for + color; seed_ch is 0 for mono (so seeding stays sl*10, not 1+sl*10) or + the real channel index for color. layer_curve is [nle][max_sub][3] + row-major, layer_curve_total is [nle][3]; max_sub (SF_GRAIN_MAX_SUBLAYERS + host-side) is the table's fixed stride regardless of how many + sub-layers n_sub actually uses. Outputs the RAW (un-combined, + pre-dmin-subtraction) sample for ONE sub-layer into a flat w*h buffer -- + called once per sub-layer, unlike the old kernels which looped every + sub-layer internally in one dispatch. */ +__kernel void spektrafilm_grain_gen_raw_sl(__global const float4 *dens, __global float *raw_out, + const int w, const int h, + const int roi_x, const int roi_y, const int mono, + const int channel_idx, const int seed_ch, + const int sl_idx, const int nle, const int max_sub, + const float unif_ch, const float npart_scale, + __global const float *layer_dmax, + __global const float *layer_npart, + __global const float *layer_dmin, + __global const float *layer_curve_total, + __global const float *layer_curve) { const int x = get_global_id(0), y = get_global_id(1); if(x >= w || y >= h) return; const size_t k = (size_t)y * w + x; const float4 d4 = dens[k]; - const float dd[3] = { d4.x, d4.y, d4.z }; - const float dmn[3] = { dmin0, dmin1, dmin2 }; - const float unf[3] = { unf0, unf1, unf2 }; + const float density = mono ? (d4.x + d4.y + d4.z) / 3.0f + : (channel_idx == 0 ? d4.x : (channel_idx == 1 ? d4.y : d4.z)); const int lstride = max_sub * 3; - - if(mono) - { - const float dm = (dd[0] + dd[1] + dd[2]) / 3.0f; - const float pos = sf_cl_grain_curve_inverse(layer_curve_total + 1, nle, 3, dm); - float total_abs = 0.0f; - for(int sl = 0; sl < n_sub; sl++) - { - const float raw = sf_cl_grain_curve_sample(layer_curve + sl * 3 + 1, nle, lstride, pos); - const float d_abs = raw + layer_dmin[sl * 3 + 1]; - const uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), (uint)(sl * 10)); - total_abs += sf_layer_particle(d_abs, layer_dmax[sl * 3 + 1], layer_npart[sl * 3 + 1], unf[1], seed); - } - const float gval = total_abs - dmn[1]; - const float dl = (gval - dm) * grain_amount; - grain_buf[k] = (float4)(dl, dl, dl, 0.f); - return; - } - - float gd[3]; - for(int c = 0; c < 3; c++) - { - const float pos = sf_cl_grain_curve_inverse(layer_curve_total + c, nle, 3, dd[c]); - float total_abs = 0.0f; - for(int sl = 0; sl < n_sub; sl++) - { - const float raw = sf_cl_grain_curve_sample(layer_curve + sl * 3 + c, nle, lstride, pos); - const float d_abs = raw + layer_dmin[sl * 3 + c]; - const uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), (uint)(c + sl * 10)); - total_abs += sf_layer_particle(d_abs, layer_dmax[sl * 3 + c], layer_npart[sl * 3 + c], unf[c], seed); - } - const float g = total_abs - dmn[c]; - gd[c] = (g - dd[c]) * grain_amount; - } - grain_buf[k] = (float4)(gd[0], gd[1], gd[2], 0.f); -} - -/* gmean centres the grain-delta buffer before scaling by renorm, matching + const int idx = sl_idx * 3 + channel_idx; + const float pos = sf_cl_grain_curve_inverse(layer_curve_total + channel_idx, nle, 3, density); + const float raw = sf_cl_grain_curve_sample(layer_curve + idx, nle, lstride, pos); + const float d_abs = raw + layer_dmin[idx]; + const uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), + (uint)(seed_ch + sl_idx * 10)); + raw_out[k] = sf_layer_particle(d_abs, layer_dmax[idx], layer_npart[idx] * npart_scale, + unif_ch, seed); +} + +/* Sums sub-layer buffers (each already dye-cloud-blurred by the host) into + a single accumulator, one sub-layer at a time: reset=1 initializes the + accumulator with the first sub-layer instead of requiring a separate + zero-fill dispatch, reset=0 adds subsequent ones. */ +__kernel void spektrafilm_grain_accumulate_1c(__global float *acc, __global const float *src, + const int w, const int h, const int reset) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + if(reset) acc[k] = src[k]; + else acc[k] += src[k]; +} + +/* Subtracts the density floor and the original clean density from the + summed (already dye-blurred) sub-layers, scales by grain strength, and + writes into grain_buf's out_ch component (mono broadcasts to all + three). grain_buf is read-modify-write: for color, this kernel runs + once per output channel with the SAME buffer, each call only touching + its own component. */ +__kernel void spektrafilm_grain_finalize_channel(__global float4 *grain_buf, + __global const float *acc, + __global const float4 *dens, + const int w, const int h, const int mono, + const int channel_idx, const int out_ch, + const float dmin_ch, const float amount) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 d4 = dens[k]; + const float density = mono ? (d4.x + d4.y + d4.z) / 3.0f + : (channel_idx == 0 ? d4.x : (channel_idx == 1 ? d4.y : d4.z)); + const float g = acc[k] - dmin_ch; + const float delta = (g - density) * amount; + float4 gv = mono || out_ch == 0 ? (float4)(0.f, 0.f, 0.f, 0.f) : grain_buf[k]; + if(mono) gv = (float4)(delta, delta, delta, 0.f); + else if(out_ch == 0) gv.x = delta; + else if(out_ch == 1) gv.y = delta; + else gv.z = delta; + grain_buf[k] = gv; +} + +/* gmean centres the grain-delta buffer before adding it back, matching process()'s CPU-side DC-bias removal (see there for the full rationale: - the multi-sublayer particle generator has a small positive DC bias that - renorm amplifies proportionally to sigma). gmean is computed host-side - over the whole buffer (see process_cl()) since a full-image reduction is - simplest done there, then passed down PER CHANNEL as a float4 -- a - single pooled scalar across R+G+B would leave each channel's own bias - minus the pooled average as an uncorrected residual (a color cast, not - just a brightness bug). */ + the multi-sublayer particle generator has a small positive DC bias). + gmean is computed host-side over the whole buffer (see process_cl()) + since a full-image reduction is simplest done there, then passed down + PER CHANNEL as a float4 -- a single pooled scalar across R+G+B would + leave each channel's own bias minus the pooled average as an + uncorrected residual (a color cast, not just a brightness bug). + No variance-restoration renorm here (upstream's own grain finalization, + _finalize_grain in grain.py, has none either -- it just blurs and lets + the natural contrast reduction stand, matching real optical clumping; + restoring full pre-blur variance made grain visibly higher-contrast, + and therefore visually coarser, than upstream at any matching sigma). */ __kernel void spektrafilm_grain_add(__global float4 *dens_buf, __global const float4 *grain_buf, - const int w, const int h, const float renorm, const float4 gmean) + const int w, const int h, const float4 gmean) { const int x = get_global_id(0), y = get_global_id(1); if(x >= w || y >= h) return; const size_t k = (size_t)y * w + x; float4 d = dens_buf[k]; float4 g = grain_buf[k]; - dens_buf[k] = (float4)(d.x + (g.x - gmean.x) * renorm, d.y + (g.y - gmean.y) * renorm, - d.z + (g.z - gmean.z) * renorm, d.w); + dens_buf[k] = (float4)(d.x + (g.x - gmean.x), d.y + (g.y - gmean.y), + d.z + (g.z - gmean.z), d.w); } /* Multiplicative unsharp mask after grain blur (study b80). diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index 91845e6455d5..ec81351c3230 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -91,25 +91,6 @@ int sf_gauss_kernel_1d(const float sigma, float *const kernel, const int max_rad return radius; } -/* Exact grain-clump renormalization: blurring the grain-delta buffer - necessarily reduces its variance (that's what turns per-pixel noise into - soft "clumps"), so the result needs scaling back up to keep the grain at - its intended RMS after clumping. For a normalized kernel k, convolving - white noise reduces variance by sum(k^2) along that axis; since the clump - blur is separable (the same kernel applied along both axes), the full 2D - variance factor is sum(k^2)^2, and the amplitude factor that undoes it is - 1/sum(k^2). Computed directly from the same kernel actually used for the - blur, so this is exact rather than an assumed closed-form approximation. */ -float sf_gauss_grain_renorm(const float sigma) -{ - if(sigma < 1e-6f) return 1.0f; - float kernel[2 * SF_GAUSS_MAX_RADIUS + 1]; - const int radius = sf_gauss_kernel_1d(sigma, kernel, SF_GAUSS_MAX_RADIUS); - double ss = 0.0; - for(int i = 0; i < 2 * radius + 1; i++) ss += (double)kernel[i] * kernel[i]; - return (float)(1.0 / ss); -} - /* SF_GAUSS_EXACT_MAX_SIGMA / SF_GAUSS_SIGMA_CORRECTION are defined in spektra_core.h, shared with spektrafilm.c's GPU macros. */ @@ -215,14 +196,9 @@ static void _sf_transpose(const float *const src, float *const dst, is a w*h intermediate buffer for the transpose (may be NULL: the row/col passes then operate directly without the transpose, which is fine for small buffers where cache locality matters less). */ -static void _blur_channel(float *const buf, const int w, const int h, const int c, - const float sigma, float *const plane, float *const trans, - const int exact_only) +static void _blur_flat_inplace(float *const plane, const int w, const int h, + const float sigma, float *const trans, const int exact_only) { - if(sigma < 1e-6f) return; - const size_t npix = (size_t)w * h; - for(size_t i = 0; i < npix; i++) plane[i] = buf[i * 3 + c]; - const int use_iir = !exact_only && sigma >= SF_GAUSS_EXACT_MAX_SIGMA; float kernel[2 * SF_GAUSS_MAX_RADIUS + 1]; int radius = 0; @@ -288,15 +264,24 @@ static void _blur_channel(float *const buf, const int w, const int h, const int } dt_free_align(row_tmp); } +} + +static void _blur_channel(float *const buf, const int w, const int h, const int c, + const float sigma, float *const plane, float *const trans, + const int exact_only) +{ + if(sigma < 1e-6f) return; + const size_t npix = (size_t)w * h; + for(size_t i = 0; i < npix; i++) plane[i] = buf[i * 3 + c]; + _blur_flat_inplace(plane, w, h, sigma, trans, exact_only); for(size_t i = 0; i < npix; i++) buf[i * 3 + c] = plane[i]; } /* Blur all three channels with the same sigma (grain clumps). Always uses - the exact kernel regardless of sigma: sf_gauss_grain_renorm computes the - variance-restoration factor from that exact kernel's own coefficients, so - the blur actually applied must match it precisely -- the IIR fast path's - renormalization behavior hasn't been separately verified, so grain stays - on the one path its own math is derived from. Allocates trans buffer. */ + the exact kernel regardless of sigma: the fast IIR path has a known + ~18% effective-width error at the small sigmas grain's clump blur + typically uses, which would make grain visibly the wrong size relative + to upstream's own (exact-shape) Gaussian blur. Allocates trans buffer. */ void sf_blur_plane3(float *const buf, const int w, const int h, const float sigma, float *const plane) { if(sigma < 0.3f) return; @@ -305,6 +290,23 @@ void sf_blur_plane3(float *const buf, const int w, const int h, const float sigm dt_free_align(trans); } +/* Same exact-kernel blur as sf_blur_plane3, but for one flat w*h buffer + already in place -- used for grain's per-sublayer dye-cloud blur, where + each (channel, sub-layer) has its own sigma. No 0.3px floor (unlike + sf_blur_plane3's guard for the visible clump blur): the dye-cloud sigma + is often well under a pixel and still meaningfully softens the raw + particle draw, matching upstream's plain `> 0` check. Caller-provided + plane IS buf (in place); trans may be NULL for small buffers. */ +void sf_blur_plane1(float *const buf, const int w, const int h, const float sigma, + float *const plane, float *const trans) +{ + if(sigma < 1e-6f) return; + (void)plane; /* kept in the signature for API symmetry with sf_blur_plane3; unused: this + variant blurs buf in place, it doesn't need a separate extract/write-back + staging buffer the way the interleaved 3-channel path does. */ + _blur_flat_inplace(buf, w, h, sigma, trans, /*exact_only=*/1); +} + /* Same as sf_blur_plane3, but allows the fast IIR path at large sigma: for callers with no downstream dependency on the exact kernel's shape (DIR coupler correction-field diffusion, unlike grain, isn't renormalizing diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index de7ea5c53b07..102b0b04462f 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -32,6 +32,15 @@ and OpenMP linkage; everything else in this header is inline). */ void sf_blur_plane3(float *buf, int w, int h, float sigma, float *plane); void sf_blur_plane3_fast(float *buf, int w, int h, float sigma, float *plane); +/* Same exact-kernel blur as sf_blur_plane3, but operating directly on a + single flat w*h buffer (no 3-channel interleave) -- used for the + per-sublayer dye-cloud blur inside grain generation, where each + (channel, sub-layer) has its own sigma and needs its own buffer rather + than sharing one interleaved 3-channel pass. No lower sigma cutoff + (unlike sf_blur_plane3's 0.3px guard for the visible clump blur): the + dye-cloud sigma is often well under a pixel and still meaningfully + softens the raw particle draw, matching upstream's plain `> 0` check. */ +void sf_blur_plane1(float *buf, int w, int h, float sigma, float *plane, float *trans); void sf_multiplicative_unsharp_mask3(float *buf, int w, int h, float sigma, float amount, float *orig, float *work); /* Two independently-controllable stages, matching upstream's HalationParams: @@ -589,13 +598,6 @@ SPEKTRA_INLINE uint32_t sf_pixel_seed(uint32_t xi, uint32_t yi, uint32_t chan) * (spektrafilm.c's process_cl) build the identical kernel for a given sigma. */ int sf_gauss_kernel_1d(float sigma, float *kernel, int max_radius); -/* Exact grain-clump variance-restoration factor for a separable 2D Gaussian - * blur of the given sigma (see _sf_gauss_kernel_1d / sf_gauss_grain_renorm - * in spektra_core.c): blurring the grain-delta buffer necessarily shrinks - * its variance, and this is the exact factor (1/sum(kernel^2), computed - * from the actual kernel used) that restores it to the intended RMS. */ -float sf_gauss_grain_renorm(float sigma); - #define SF_FILTRATION_TO_DENSITY 0.30f /* full filtration slider == 0.30 density */ SPEKTRA_INLINE void sf_apply_print_grading(float density[3], float d_ref, float print_exposure, float print_contrast, float filtration_m, @@ -683,7 +685,7 @@ SPEKTRA_INLINE void sf_grain_px(float dens[3], float pixel_um, float amount, flo /* SF_GRAIN_REF_UM (defined above, with sf_grain_px) is reused here for the same fine-generation reference scale. */ SPEKTRA_INLINE void sf_grain_delta_dmax(const float dens[3], float amount, float out_delta[3], - uint32_t xi, uint32_t yi, int mono, + uint32_t xi, uint32_t yi, int mono, float pixel_um, const float dmax_c[3], const float dmin_c[3], const float rms_c[3], const float unif_c[3]) { @@ -693,13 +695,17 @@ SPEKTRA_INLINE void sf_grain_delta_dmax(const float dens[3], float amount, float /* Latest spektrafilm grain model (study a90): per-channel particle area from catalogue RMS-granularity (sigma_48 through a 48um aperture, ISO 6328): a_grain = (rms/1000)^2 * A48 / (D_ref (Dmax - u D_ref)), D_ref = 1 + d_min. - N = pixel_area / a_grain. Generated at a fine fixed reference scale; the blur - afterwards sets visible clump size. rms/unif come from the film stock's own - catalogue data (see header comment) rather than one shared constant. */ + N = pixel_area / a_grain. pixel_area is the REAL physical pixel area + (film_format_mm-derived pixel_um, squared) -- matching upstream's + n_particles_per_pixel = pixel_size_um**2 * ... exactly, so raw grain + density/variance (not just visible clump size, which the separate blur + step still sets) tracks the real resolution and film format. rms/unif + come from the film stock's own catalogue data (see header comment) + rather than one shared constant. */ const float rms[3] = { rms_c[0], rms_c[1], rms_c[2] }; const float unif[3] = { unif_c[0], unif_c[1], unif_c[2] }; const float A48 = 3.14159265f * 24.0f * 24.0f; - const float ref_um = SF_GRAIN_REF_UM, pix = ref_um * ref_um; + const float pix = pixel_um * pixel_um; /* mono (B&W / combined): the three channels carry the same value, so grain must be ACHROMATIC — one grain realisation applied identically to all channels. Per-channel independent grain (the colour path) would otherwise paint colour @@ -745,12 +751,12 @@ SPEKTRA_INLINE void sf_grain_delta_dmax(const float dens[3], float amount, float #define SF_GRAIN_LEGACY_UNIFORMITY { 0.97f, 0.97f, 0.97f } SPEKTRA_INLINE void sf_grain_delta(const float dens[3], float amount, float out_delta[3], - uint32_t xi, uint32_t yi, int mono) + uint32_t xi, uint32_t yi, int mono, float pixel_um) { const float legacy_dmax[3] = SF_GRAIN_LEGACY_DMAX; const float legacy_dmin[3] = SF_GRAIN_LEGACY_DMIN; const float legacy_rms[3] = SF_GRAIN_LEGACY_RMS; const float legacy_unif[3] = SF_GRAIN_LEGACY_UNIFORMITY; - sf_grain_delta_dmax(dens, amount, out_delta, xi, yi, mono, legacy_dmax, legacy_dmin, + sf_grain_delta_dmax(dens, amount, out_delta, xi, yi, mono, pixel_um, legacy_dmax, legacy_dmin, legacy_rms, legacy_unif); } diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 3ddfa130a182..00c906de7b5a 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -3398,7 +3398,7 @@ static float _sf_grain_curve_sample(const float *arr, int n, int stride, float p * calibrated against, rather than diluting it the way an average would. */ void sf_grain_delta_ml(const sf_grain_layers_t *layers, const float dens[3], float amount, float out_delta[3], uint32_t xi, uint32_t yi, int mono, - const float dmin_c[3], const float unif_c[3]) + const float dmin_c[3], const float unif_c[3], float npart_scale) { const int nsub = layers->n; const int nle = SF_NLE; @@ -3413,7 +3413,8 @@ void sf_grain_delta_ml(const sf_grain_layers_t *layers, const float dens[3], flo { const float raw = _sf_grain_curve_sample(&layers->layer_curve[0][sl][1], nle, lstride, pos); const float d_abs = raw + (float)layers->layer_dmin[sl][1]; - total_abs += sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][1], (float)layers->layer_npart[sl][1], + total_abs += sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][1], + (float)layers->layer_npart[sl][1] * npart_scale, unif_c[1], sf_pixel_seed(xi, yi, (uint32_t)(sl * 10))); } const float g = total_abs - dmin_c[1]; @@ -3430,7 +3431,8 @@ void sf_grain_delta_ml(const sf_grain_layers_t *layers, const float dens[3], flo { const float raw = _sf_grain_curve_sample(&layers->layer_curve[0][sl][c], nle, lstride, pos); const float d_abs = raw + (float)layers->layer_dmin[sl][c]; - total_abs += sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][c], (float)layers->layer_npart[sl][c], + total_abs += sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][c], + (float)layers->layer_npart[sl][c] * npart_scale, unif_c[c], sf_pixel_seed(xi, yi, (uint32_t)(c + sl * 10))); } const float g = total_abs - dmin_c[c]; @@ -3438,3 +3440,38 @@ void sf_grain_delta_ml(const sf_grain_layers_t *layers, const float dens[3], flo } } +/* Same per-sub-layer sampling as sf_grain_delta_ml, but returns each + * sub-layer's RAW absolute sample (before dmin subtraction, before + * summing across sub-layers) into raw_out[0..layers->n-1], so the caller + * can run the per-sub-layer dye-cloud blur (upstream's + * layer_particle_model blur_particle) on each one independently before + * combining -- a spatial blur needs a whole-image buffer per sub-layer, + * which this single-pixel function can't do itself, so it just hands + * back the un-combined per-sub-layer values for the caller to buffer, + * blur, and combine afterward. channel_idx is 1 for mono (matching + * sf_grain_delta_ml's own convention of using channel 1's curve/params + * for the achromatic draw) or 0/1/2 for color; seed_ch is the seed + * component sf_grain_delta_ml adds on top of sl*10 (0 for mono, the + * real channel index for color) -- kept as a separate parameter rather + * than reusing channel_idx so the mono case still seeds like sl*10, not + * (1 + sl*10), matching sf_grain_delta_ml exactly. */ +void sf_grain_raw_samples_ml(const sf_grain_layers_t *layers, float density, int channel_idx, + int seed_ch, uint32_t xi, uint32_t yi, float unif_c, + float npart_scale, float *raw_out) +{ + const int nsub = layers->n; + const int nle = SF_NLE; + const int lstride = SF_GRAIN_MAX_SUBLAYERS * 3; + const float pos = _sf_grain_curve_inverse(&layers->layer_curve_total[0][channel_idx], nle, 3, + density); + for(int sl = 0; sl < nsub; sl++) + { + const float raw = _sf_grain_curve_sample(&layers->layer_curve[0][sl][channel_idx], nle, + lstride, pos); + const float d_abs = raw + (float)layers->layer_dmin[sl][channel_idx]; + raw_out[sl] = sf_layer_particle(d_abs, (float)layers->layer_dmax[sl][channel_idx], + (float)layers->layer_npart[sl][channel_idx] * npart_scale, + unif_c, sf_pixel_seed(xi, yi, (uint32_t)(seed_ch + sl * 10))); + } +} + diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index 5827db885dc5..c1f293228392 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -219,10 +219,24 @@ void sf_sim_grain_layers(const sf_sim_t *sim, sf_grain_layers_t *out); /* Multi-sublayer grain delta (see _sf_build_grain_layers / sf_grain_layers_t * above): only call this when sf_sim_grain_layers()'s n > 1 -- for n==1, * calling the existing single-layer sf_grain_delta_dmax() (spektra_core.h) - * directly is both simpler and avoids a redundant lookup round-trip. */ + * directly is both simpler and avoids a redundant lookup round-trip. + * npart_scale rescales the build-time-precomputed layer_npart (built at the + * fixed SF_GRAIN_REF_UM reference scale, since it depends on curve/coupler + * state baked in at sf_sim_build time, not just resolution) up to the live + * pipe's real pixel_um: pass (pixel_um*pixel_um)/(SF_GRAIN_REF_UM*SF_GRAIN_REF_UM). */ void sf_grain_delta_ml(const sf_grain_layers_t *layers, const float dens[3], float amount, float out_delta[3], uint32_t xi, uint32_t yi, int mono, - const float dmin_c[3], const float unif_c[3]); + const float dmin_c[3], const float unif_c[3], float npart_scale); +/* Same per-sub-layer sampling as sf_grain_delta_ml, but returns each + * sub-layer's raw (un-combined, pre-dmin-subtraction) sample into + * raw_out[0..layers->n-1] instead, so the caller can dye-cloud-blur each + * sub-layer's whole-image buffer independently (upstream's + * layer_particle_model blur_particle) before summing them -- see the + * definition in spektra_sim.c for the full rationale. raw_out must have + * room for at least layers->n floats. */ +void sf_grain_raw_samples_ml(const sf_grain_layers_t *layers, float density, int channel_idx, + int seed_ch, uint32_t xi, uint32_t yi, float unif_c, + float npart_scale, float *raw_out); /* SF_GRAIN_MAX_SUBLAYERS is defined earlier in this header, next to SF_NLE (both are needed by sf_sim_gpu_t above, which comes before this point). */ bool sf_pack_film_grain(const sf_pack_t *pack, const char *film_stock, diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 4fa435f2988d..b865bbd11b89 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -99,20 +99,17 @@ DT_MODULE_INTROSPECTION(8, dt_iop_spektrafilm_params_t) #define SF_SCATTER_TAIL_MAX_UM 27.0f #define SF_GRAIN_BLUR_FACTOR 0.8f #define SF_GRAIN_SIZE_MIN 0.05f -/* Calibration against the reference spektrafilm app's own rendered output, - * same film stock (Portra 400 / Portra Endura) and nominal (1.0, 1.0) - * sliders: even with the exact multi-sublayer particle model and exact - * Gaussian convolution, darktable's grain came out visually fainter and - * coarser than the reference at those defaults -- matching a stock-independent - * particle model has no free parameter left to soak up that gap, so these - * are a direct measured correction on the user-facing sliders rather than a - * further formula change. Applied only where grain_amount/grain_size feed - * the actual generation and clump blur (process()/process_cl()); left out of - * _max_halo_sigma's ROI/tiling padding, since a *smaller* effective size only - * needs less padding, not more, so the unscaled (larger) estimate there stays - * safely conservative. */ -#define SF_GRAIN_STRENGTH_CAL 1.20f -#define SF_GRAIN_SIZE_CAL 0.65f +/* Upstream's GrainParams.blur_dye_clouds_um (params_schema.py): a SECOND, + * per-sub-layer blur applied to the raw particle draw INSIDE the particle + * sampler itself (layer_particle_model in grain.py), before the main + * clump blur above ever runs -- sigma = SF_GRAIN_DYE_BLUR_UM * + * sqrt(od_particle), where od_particle = dmax/npart is that sub-layer's + * own per-particle optical density. Passed through verbatim (no + * pixel_um conversion anywhere in the reference's own call chain, + * despite the "_um" name) -- ported as literally as upstream computes it + * rather than second-guessing the naming. No variance-restoration + * afterward either, same as the main clump blur. */ +#define SF_GRAIN_DYE_BLUR_UM 2.0f /* Push/pull processing is really two things happening together: shooting * at an effective ISO different from box speed (already modeled via * exposure_ev), plus extended/reduced development time, which increases @@ -190,8 +187,8 @@ typedef struct dt_iop_spektrafilm_params_t float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 35.0 $DESCRIPTION: "film format" float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost" - float grain_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.8 $DESCRIPTION: "grain recovery sharpness" - float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "grain recovery strength" + float grain_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "grain recovery sharpness" + float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.5 $DESCRIPTION: "grain recovery strength" float film_gamma_factor; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "development gamma" float film_gamma_factor_fast; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "fast layer gamma" float film_gamma_factor_slow; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "slow layer gamma" @@ -259,7 +256,8 @@ typedef struct dt_iop_spektrafilm_data_t typedef struct dt_iop_spektrafilm_global_data_t { int kernel_expose, kernel_lograw, kernel_develop_corr, kernel_develop; - int kernel_grain_gen, kernel_grain_gen_ml, kernel_grain_add, kernel_grain_usm; + int kernel_grain_gen_raw_sl, kernel_grain_accumulate_1c, kernel_grain_finalize_channel, + kernel_grain_add, kernel_grain_usm; int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough; int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply; int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c; @@ -284,8 +282,10 @@ void init_global(dt_iop_module_so_t *self) gd->kernel_lograw = dt_opencl_create_kernel(program, "spektrafilm_lograw"); gd->kernel_develop_corr = dt_opencl_create_kernel(program, "spektrafilm_develop_corr"); gd->kernel_develop = dt_opencl_create_kernel(program, "spektrafilm_develop"); - gd->kernel_grain_gen = dt_opencl_create_kernel(program, "spektrafilm_grain_gen"); - gd->kernel_grain_gen_ml = dt_opencl_create_kernel(program, "spektrafilm_grain_gen_ml"); + gd->kernel_grain_gen_raw_sl = dt_opencl_create_kernel(program, "spektrafilm_grain_gen_raw_sl"); + gd->kernel_grain_accumulate_1c = dt_opencl_create_kernel(program, "spektrafilm_grain_accumulate_1c"); + gd->kernel_grain_finalize_channel + = dt_opencl_create_kernel(program, "spektrafilm_grain_finalize_channel"); gd->kernel_grain_add = dt_opencl_create_kernel(program, "spektrafilm_grain_add"); gd->kernel_grain_usm = dt_opencl_create_kernel(program, "spektrafilm_grain_usm"); gd->kernel_print_expose = dt_opencl_create_kernel(program, "spektrafilm_print_expose"); @@ -317,8 +317,9 @@ void cleanup_global(dt_iop_module_so_t *self) dt_opencl_free_kernel(gd->kernel_lograw); dt_opencl_free_kernel(gd->kernel_develop_corr); dt_opencl_free_kernel(gd->kernel_develop); - dt_opencl_free_kernel(gd->kernel_grain_gen); - dt_opencl_free_kernel(gd->kernel_grain_gen_ml); + dt_opencl_free_kernel(gd->kernel_grain_gen_raw_sl); + dt_opencl_free_kernel(gd->kernel_grain_accumulate_1c); + dt_opencl_free_kernel(gd->kernel_grain_finalize_channel); dt_opencl_free_kernel(gd->kernel_grain_add); dt_opencl_free_kernel(gd->kernel_grain_usm); dt_opencl_free_kernel(gd->kernel_print_expose); @@ -1678,39 +1679,117 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c { float *gbuf = corr; /* corr is free now — reuse as the grain delta buffer */ const int roi_x = roi_in->x, roi_y = roi_in->y; - const float amount = d->p.grain_amount * SF_GRAIN_STRENGTH_CAL; + const float amount = d->p.grain_amount; const int mono = sf_sim_film_bw(sim); /* B&W: achromatic grain */ - float gdmax[3], grms[3], gunif[3], gdmin[3]; - sf_sim_film_dmax3(sim, gdmax); /* the emulsion's own D-max: slide film - exceeds the colour-negative 2.2 default, - which would bias (tint) dense areas */ + /* sf_grain_delta_ml's layer_npart is precomputed at sf_sim_build time + against the fixed SF_GRAIN_REF_UM reference scale (it depends on + curve/coupler state baked in at build time, not just resolution); + rescale it live to the real pixe_um here, matching what + sf_grain_delta_dmax now does directly for the single-layer case. */ + const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM); + float grms[3], gunif[3], gdmin[3]; sf_sim_film_grain3(sim, grms, gunif, gdmin); /* per-film catalogue grain (rms-granularity, uniformity, density floor) — Portra 400 no longer shares Tri-X's grain signature */ sf_grain_layers_t layers; - sf_sim_grain_layers(sim, &layers); /* n==1 for a single-layer curve fit: - the exact same case sf_grain_delta_dmax - already handles, called unchanged below - rather than routed through the - multi-sublayer lookup for no reason */ + sf_sim_grain_layers(sim, &layers); /* n==1 for a single-layer curve fit + is already valid data (see the + function's own comment): unified + through this mechanism for every + stock rather than branching to the + separate sf_grain_delta_dmax path. */ + const int nsub = layers.n; + + /* Upstream's per-sub-layer dye-cloud blur (layer_particle_model's + blur_particle, grain.py) runs INSIDE the particle sampler, on each + sub-layer's raw draw independently, before the main clump blur + below ever sees it. Its sigma depends only on that sub-layer's own + per-particle optical density (dmax/npart) -- constant across the + whole image for a given (channel, sub-layer), so compute it once + here rather than per pixel. */ + float dye_sigma[3][SF_GRAIN_MAX_SUBLAYERS]; + for(int c = 0; c < 3; c++) + for(int sl = 0; sl < nsub; sl++) + { + const float npart_c = (float)layers.layer_npart[sl][c] * npart_scale; + const float od_particle = (float)layers.layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f); + dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * sqrtf(fmaxf(od_particle, 0.0f)); + } + + float *raw[SF_GRAIN_MAX_SUBLAYERS] = { 0 }; + gboolean raw_ok = TRUE; + for(int sl = 0; sl < nsub; sl++) + { + raw[sl] = dt_alloc_align_float(npix); + if(!raw[sl]) raw_ok = FALSE; + } + + if(!raw_ok) + memset(gbuf, 0, npix * 3 * sizeof(float)); /* allocation failed: skip grain gracefully */ + else + { + const int n_out_ch = mono ? 1 : 3; + for(int oc = 0; oc < n_out_ch; oc++) + { + const int channel_idx = mono ? 1 : oc; /* mono uses channel 1's curve/params for the + achromatic draw, matching sf_grain_delta_ml */ + const int seed_ch = mono ? 0 : oc; /* mono seeds as sl*10 (no channel term), matching + sf_grain_delta_ml's own convention exactly */ + const float unif_ch = gunif[channel_idx]; #ifdef _OPENMP -#pragma omp parallel for default(none) shared(plane, gbuf, gdmax, grms, gunif, gdmin, layers) \ - firstprivate(w, npix, roi_x, roi_y, amount, mono) schedule(static) +#pragma omp parallel for default(none) \ + shared(plane, raw, layers) firstprivate(w, npix, roi_x, roi_y, mono, channel_idx, seed_ch, \ + unif_ch, npart_scale, nsub) schedule(static) #endif - for(size_t k = 0; k < npix; k++) - { - const int x = (int)(k % (size_t)w), y = (int)(k / (size_t)w); - if(layers.n > 1) - sf_grain_delta_ml(&layers, plane + k * 3, amount, gbuf + k * 3, (uint32_t)(x + roi_x), - (uint32_t)(y + roi_y), mono, gdmin, gunif); - else - sf_grain_delta_dmax(plane + k * 3, amount, gbuf + k * 3, (uint32_t)(x + roi_x), - (uint32_t)(y + roi_y), mono, gdmax, gdmin, grms, gunif); + for(size_t k = 0; k < npix; k++) + { + const int x = (int)(k % (size_t)w), y = (int)(k / (size_t)w); + const float density = mono ? (plane[k * 3 + 0] + plane[k * 3 + 1] + plane[k * 3 + 2]) + / 3.0f + : plane[k * 3 + channel_idx]; + float samp[SF_GRAIN_MAX_SUBLAYERS]; + sf_grain_raw_samples_ml(&layers, density, channel_idx, seed_ch, (uint32_t)(x + roi_x), + (uint32_t)(y + roi_y), unif_ch, npart_scale, samp); + for(int sl = 0; sl < nsub; sl++) raw[sl][k] = samp[sl]; + } + /* dye-cloud blur: each sub-layer independently, no variance + restoration (matching upstream: layer_particle_model doesn't + renormalize after its blur_particle pass either). */ + for(int sl = 0; sl < nsub; sl++) + sf_blur_plane1(raw[sl], w, h, dye_sigma[channel_idx][sl], NULL, scratch); + /* combine: sum sub-layers, subtract the density floor and the + original clean density, scale by strength. */ +#ifdef _OPENMP +#pragma omp parallel for default(none) shared(plane, gbuf, raw) \ + firstprivate(npix, nsub, channel_idx, mono, oc, amount, gdmin) schedule(static) +#endif + for(size_t k = 0; k < npix; k++) + { + float total = 0.0f; + for(int sl = 0; sl < nsub; sl++) total += raw[sl][k]; + const float g = total - gdmin[channel_idx]; + const float density = mono ? (plane[k * 3 + 0] + plane[k * 3 + 1] + plane[k * 3 + 2]) + / 3.0f + : plane[k * 3 + channel_idx]; + const float delta = (g - density) * amount; + if(mono) gbuf[k * 3 + 0] = gbuf[k * 3 + 1] = gbuf[k * 3 + 2] = delta; + else gbuf[k * 3 + oc] = delta; + } + } } - const float sigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM - * fmaxf(d->p.grain_size * SF_GRAIN_SIZE_CAL, SF_GRAIN_SIZE_MIN) - / fmaxf(pixel_um, 1e-3f); + for(int sl = 0; sl < nsub; sl++) + if(raw[sl]) dt_free_align(raw[sl]); + /* Upstream's grain blur (GrainParams.blur, params_schema.py) is a + literal FIXED pixel sigma (0.8), independent of pixel_um/resolution/ + film_format_mm -- confirmed empirically: measuring the real + reference's noise autocorrelation at two different resolutions + (87.5 and 35 um/px) gave near-identical radial profiles. Physical + scaling lives entirely in particle DENSITY (now pixel_um-driven + above), not in this smoothing pass. SF_GRAIN_SIZE_CAL is gone: it + was calibrated against the old pixel_um-scaled formula and no + longer applies. */ + const float sigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN); sf_blur_plane3(gbuf, w, h, sigma, scratch); /* Centre grain delta: multi-sublayer model has positive DC bias (~0.07) that renorm amplifies proportionally to sigma, making image brighter @@ -1730,12 +1809,17 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c for(size_t kk = 0; kk < npix; kk++) for(int c = 0; c < 3; c++) gbuf[kk * 3 + c] -= gmean[c]; } - const float renorm = sf_gauss_grain_renorm(fmaxf(sigma, 0.3f)); + /* No variance-restoration renorm here -- upstream's own grain + finalization (_finalize_grain in grain.py) has none either; it just + blurs and lets the natural contrast reduction stand, matching real + optical clumping. Restoring full pre-blur variance made grain + visibly higher-contrast, and therefore visually coarser, than + upstream at any matching sigma. */ #ifdef _OPENMP -#pragma omp parallel for default(none) shared(plane, gbuf) firstprivate(npix, renorm) \ +#pragma omp parallel for default(none) shared(plane, gbuf) firstprivate(npix) \ schedule(static) #endif - for(size_t k = 0; k < npix * 3; k++) plane[k] += gbuf[k] * renorm; + for(size_t k = 0; k < npix * 3; k++) plane[k] += gbuf[k]; if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f) sf_multiplicative_unsharp_mask3(plane, w, h, d->p.grain_usm_sigma, d->p.grain_usm_amount, corr, scratch); @@ -1930,9 +2014,11 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ darktable's fast recursive blur (corrected -- see SF_GAUSS_SIGMA_CORRECTION) instead of the exact row/col kernels: for callers with no downstream dependency on the exact kernel's shape (unlike grain's SF_GAUSS_BLUR4 - above, which stays on the exact path unconditionally since - sf_gauss_grain_renorm is computed from it), this recovers most of the - O(radius) cost the exact kernel pays at large sigma. */ + above, which stays on the exact path unconditionally -- the fast + recursive approximation's own known ~18% effective-width error would + reintroduce the same size mismatch against upstream that the exact + kernel was adopted to fix), this recovers most of the O(radius) cost the + exact kernel pays at large sigma. */ #define SF_GAUSS_BLUR4_FAST(buf, _sg, label) do { \ if(err == CL_SUCCESS) \ { \ @@ -2228,76 +2314,125 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ if(d->p.grain_on && d->p.grain_amount > 0.0f) { const int roi_x = roi_in->x, roi_y = roi_in->y; - const float amount = d->p.grain_amount * SF_GRAIN_STRENGTH_CAL; + const float amount = d->p.grain_amount; const int mono = g->film_bw; /* B&W: achromatic grain */ - if(g->grain_n_sublayers > 1) + /* Unified through the multi-sublayer table for every stock (nsub can be + 1) rather than branching to a separate single-layer kernel -- see the + matching comment in process()'s CPU path for why that's valid: the + build-time layer table already has correct n==1 data for single-layer + stocks. Built once per d->gpu (see d->grain_cl_built_for above) + rather than re-uploaded on every process_cl() call: tiled processing + calls this once per tile, and this data never changes between tiles + of the same image, so re-uploading it per tile was pure overhead. */ + const int nsub = g->grain_n_sublayers, nle = SF_NLE, maxsub = SF_GRAIN_MAX_SUBLAYERS; + if(d->grain_cl_built_for != g) + { + if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax); + if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart); + if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin); + if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total); + if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve); + d->grain_cl_dmax = dt_opencl_copy_host_to_device_constant( + devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmax); + d->grain_cl_npart = dt_opencl_copy_host_to_device_constant( + devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_npart); + d->grain_cl_dmin = dt_opencl_copy_host_to_device_constant( + devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmin); + d->grain_cl_total = dt_opencl_copy_host_to_device_constant( + devid, (size_t)nle * 3 * f, (void *)g->grain_layer_curve_total); + d->grain_cl_curve = dt_opencl_copy_host_to_device_constant( + devid, (size_t)nle * maxsub * 3 * f, (void *)g->grain_layer_curve); + d->grain_cl_built_for = g; + } + if(!d->grain_cl_dmax || !d->grain_cl_npart || !d->grain_cl_dmin || !d->grain_cl_total + || !d->grain_cl_curve) + err = CL_MEM_OBJECT_ALLOCATION_FAILURE; + else { - /* multi-sublayer grain (see spektrafilm_grain_gen_ml, spektrafilm.cl): - a film whose own fitted density-curve model has more than one - emulsion sub-layer. Built once per d->gpu (see d->grain_cl_built_for - above) rather than re-uploaded on every process_cl() call: tiled - processing calls this once per tile, and this data never changes - between tiles of the same image, so re-uploading it per tile was - pure overhead -- exactly the kind that shows up as slower tiled - rendering, not slower per-pixel compute. */ - const int nsub = g->grain_n_sublayers, nle = SF_NLE, maxsub = SF_GRAIN_MAX_SUBLAYERS; - if(d->grain_cl_built_for != g) + /* layer_npart (uploaded above) is precomputed at sf_sim_build time + against the fixed SF_GRAIN_REF_UM reference scale; rescale it live + to the real pixel_um here. */ + const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM); + /* Upstream's per-sub-layer dye-cloud blur (layer_particle_model's + blur_particle, grain.py): sigma depends only on that sub-layer's + own per-particle optical density (dmax/npart), which is constant + across the whole image -- computed host-side once here, same as + the CPU path, rather than per-pixel on the device. */ + float dye_sigma[3][SF_GRAIN_MAX_SUBLAYERS]; + for(int c = 0; c < 3; c++) + for(int sl = 0; sl < nsub; sl++) + { + const float npart_c = g->grain_layer_npart[sl][c] * npart_scale; + const float od_particle = g->grain_layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f); + dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * sqrtf(fmaxf(od_particle, 0.0f)); + } + + cl_mem raw_buf[SF_GRAIN_MAX_SUBLAYERS] = { NULL }; + cl_mem acc_buf = dt_opencl_alloc_device_buffer(devid, npix * f); + gboolean raw_ok = acc_buf != NULL; + for(int sl = 0; sl < nsub; sl++) { - if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax); - if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart); - if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin); - if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total); - if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve); - d->grain_cl_dmax = dt_opencl_copy_host_to_device_constant( - devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmax); - d->grain_cl_npart = dt_opencl_copy_host_to_device_constant( - devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_npart); - d->grain_cl_dmin = dt_opencl_copy_host_to_device_constant( - devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmin); - d->grain_cl_total = dt_opencl_copy_host_to_device_constant( - devid, (size_t)nle * 3 * f, (void *)g->grain_layer_curve_total); - d->grain_cl_curve = dt_opencl_copy_host_to_device_constant( - devid, (size_t)nle * maxsub * 3 * f, (void *)g->grain_layer_curve); - d->grain_cl_built_for = g; + raw_buf[sl] = dt_opencl_alloc_device_buffer(devid, npix * f); + if(!raw_buf[sl]) raw_ok = FALSE; } - if(!d->grain_cl_dmax || !d->grain_cl_npart || !d->grain_cl_dmin || !d->grain_cl_total - || !d->grain_cl_curve) + if(!raw_ok) err = CL_MEM_OBJECT_ALLOCATION_FAILURE; else - err = dt_opencl_enqueue_kernel_2d_args( - devid, gd->kernel_grain_gen_ml, w, h, CLARG(plane2), CLARG(tmpa), CLARG(w), CLARG(h), - CLARG(amount), CLARG(roi_x), CLARG(roi_y), CLARG(mono), CLARG(nsub), CLARG(nle), - CLARG(maxsub), CLARG(g->grain_dmin[0]), CLARG(g->grain_dmin[1]), CLARG(g->grain_dmin[2]), - CLARG(g->grain_uniformity[0]), CLARG(g->grain_uniformity[1]), - CLARG(g->grain_uniformity[2]), CLARG(d->grain_cl_dmax), CLARG(d->grain_cl_npart), - CLARG(d->grain_cl_dmin), CLARG(d->grain_cl_total), CLARG(d->grain_cl_curve)); + { + const int n_out_ch = mono ? 1 : 3; + for(int oc = 0; oc < n_out_ch && err == CL_SUCCESS; oc++) + { + const int channel_idx = mono ? 1 : oc; + const int seed_ch = mono ? 0 : oc; + const float unif_ch = g->grain_uniformity[channel_idx]; + for(int sl = 0; sl < nsub && err == CL_SUCCESS; sl++) + { + err = dt_opencl_enqueue_kernel_2d_args( + devid, gd->kernel_grain_gen_raw_sl, w, h, CLARG(plane2), CLARG(raw_buf[sl]), + CLARG(w), CLARG(h), CLARG(roi_x), CLARG(roi_y), CLARG(mono), CLARG(channel_idx), + CLARG(seed_ch), CLARG(sl), CLARG(nle), CLARG(maxsub), CLARG(unif_ch), + CLARG(npart_scale), CLARG(d->grain_cl_dmax), CLARG(d->grain_cl_npart), + CLARG(d->grain_cl_dmin), CLARG(d->grain_cl_total), CLARG(d->grain_cl_curve)); + if(err == CL_SUCCESS && dye_sigma[channel_idx][sl] > 1e-6f) + SF_GAUSS_BLUR1_L(raw_buf[sl], dye_sigma[channel_idx][sl]); + } + for(int sl = 0; sl < nsub && err == CL_SUCCESS; sl++) + { + const int reset = sl == 0; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_accumulate_1c, w, h, + CLARG(acc_buf), CLARG(raw_buf[sl]), CLARG(w), + CLARG(h), CLARG(reset)); + } + if(err == CL_SUCCESS) + { + const float dmin_ch = g->grain_dmin[channel_idx]; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_finalize_channel, w, h, + CLARG(tmpa), CLARG(acc_buf), CLARG(plane2), + CLARG(w), CLARG(h), CLARG(mono), + CLARG(channel_idx), CLARG(oc), CLARG(dmin_ch), + CLARG(amount)); + } + } + } + for(int sl = 0; sl < nsub; sl++) + if(raw_buf[sl]) dt_opencl_release_mem_object(raw_buf[sl]); + if(acc_buf) dt_opencl_release_mem_object(acc_buf); } - else - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_gen, w, h, CLARG(plane2), - CLARG(tmpa), CLARG(w), CLARG(h), CLARG(amount), - CLARG(roi_x), CLARG(roi_y), CLARG(mono), - CLARG(g->film_dmax[0]), CLARG(g->film_dmax[1]), - CLARG(g->film_dmax[2]), CLARG(g->grain_dmin[0]), - CLARG(g->grain_dmin[1]), CLARG(g->grain_dmin[2]), - CLARG(g->grain_rms[0]), CLARG(g->grain_rms[1]), - CLARG(g->grain_rms[2]), CLARG(g->grain_uniformity[0]), - CLARG(g->grain_uniformity[1]), - CLARG(g->grain_uniformity[2])); SF_CL_STEP("grain gen"); - const float gsigma = SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM - * fmaxf(d->p.grain_size * SF_GRAIN_SIZE_CAL, SF_GRAIN_SIZE_MIN) - / fmaxf(pixel_um, 1e-3f); + /* fixed pixel sigma, matching process()'s CPU-side fix -- see comment + there for the empirical validation. */ + const float gsigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN); SF_GAUSS_BLUR4(tmpa, gsigma, "grain blur"); - const float grenorm = sf_gauss_grain_renorm(fmaxf(gsigma, 0.3f)); /* Centre grain delta (matches process()'s CPU-side fix exactly -- the - multi-sublayer particle generator has a small positive DC bias that - renorm amplifies proportionally to sigma; this correction only ever - existed on the CPU path before, so anyone using OpenCL never got it). - Reading the whole buffer back host-side is the simplest way to get a - full-image reduction; this runs once per grain stage, not per pixel. - Must be a per-channel mean, not one pooled scalar across R+G+B -- - see the matching comment in process() for why a pooled mean leaves - a per-channel residual (a color cast) uncorrected. */ + multi-sublayer particle generator has a small positive DC bias; this + correction only ever existed on the CPU path before, so anyone using + OpenCL never got it). Reading the whole buffer back host-side is the + simplest way to get a full-image reduction; this runs once per grain + stage, not per pixel. Must be a per-channel mean, not one pooled + scalar across R+G+B -- see the matching comment in process() for why + a pooled mean leaves a per-channel residual (a color cast) + uncorrected. No variance-restoration renorm -- see the matching + comment on the spektrafilm_grain_add kernel. */ float gmean[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; if(err == CL_SUCCESS) { @@ -2318,8 +2453,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ err = CL_MEM_OBJECT_ALLOCATION_FAILURE; } err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2), - CLARG(tmpa), CLARG(w), CLARG(h), CLARG(grenorm), - CLARG(gmean)); + CLARG(tmpa), CLARG(w), CLARG(h), CLARG(gmean)); SF_CL_STEP("grain add"); if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f) { From 0c13f59247536f32e0407a312a27fc114c283046 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Thu, 23 Jul 2026 19:47:27 +0200 Subject: [PATCH 47/56] restructure UI on feedback --- src/iop/spektrafilm.c | 405 +++++++++++++++++++++++++++++++----------- 1 file changed, 299 insertions(+), 106 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index b865bbd11b89..0cf97fcb869b 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -185,7 +185,7 @@ typedef struct dt_iop_spektrafilm_params_t gboolean grain_on; // $DEFAULT: TRUE $DESCRIPTION: "enable grain" float grain_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain strength" float grain_size; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain size" - float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 35.0 $DESCRIPTION: "film format" + float film_format_mm; // $MIN: 8.0 $MAX: 130.0 $DEFAULT: 36.0 $DESCRIPTION: "film format" float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost" float grain_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "grain recovery sharpness" float grain_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.5 $DESCRIPTION: "grain recovery strength" @@ -211,24 +211,89 @@ typedef struct sf_prof_entry_t typedef struct dt_iop_spektrafilm_gui_data_t { GtkWidget *film, *paper; - GtkWidget *exposure_ev, *print_exposure_ev, *print_auto_exposure, *print_contrast, *filter_m, *filter_y; - GtkWidget *film_gamma_factor, *film_gamma_factor_fast, *film_gamma_factor_slow, - *film_developer_exhaustion; - GtkWidget *push_pull_stops; - GtkWidget *couplers_amount, *scan_film, *quality; + GtkWidget *output_boost; + GtkWidget *film_format_combo, *film_format_mm_slider; + GtkWidget *exposure_ev, *scan_film; + GtkWidget *push_pull_stops, *film_gamma_factor; + GtkWidget *film_gamma_factor_fast, *film_gamma_factor_slow, *film_developer_exhaustion; + GtkWidget *quality; + GtkWidget *print_exposure_ev, *print_auto_exposure, *print_contrast; + GtkWidget *filter_m, *filter_y, *couplers_amount; GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift; + GtkWidget *grain_on, *grain_amount, *grain_size; + GtkWidget *grain_usm_sigma, *grain_usm_amount; GtkWidget *halation_on, *scatter_amount, *scatter_scale, *halation_amount, *halation_scale; GtkWidget *boost_ev, *boost_range, *protect_ev; GtkWidget *diffusion_on, *diffusion_filter_family, *diffusion_strength, *diffusion_scale, *diffusion_warmth; - GtkWidget *print_diffusion_on, *print_diffusion_filter_family, *print_diffusion_strength, *print_diffusion_scale, *print_diffusion_warmth; - GtkWidget *grain_on, *grain_amount, *grain_size, *grain_usm_sigma, *grain_usm_amount, *film_format_mm, *output_luminance_boost; + GtkWidget *print_diffusion_on, *print_diffusion_filter_family; + GtkWidget *print_diffusion_strength, *print_diffusion_scale, *print_diffusion_warmth; + sf_prof_entry_t entries[SF_MAX_PROFILES]; int n_entries; - int film_entry[SF_MAX_PROFILES], n_films; /* indices into entries[] */ + int film_entry[SF_MAX_PROFILES], n_films; int paper_entry[SF_MAX_PROFILES], n_papers; GtkNotebook *notebook; } dt_iop_spektrafilm_gui_data_t; +static const struct { const char *label; float mm; } _format_presets[] = { + { "half-frame", 24.0f }, { "35mm", 36.0f }, { "6x6", 56.0f }, + { "6x7", 69.0f }, { "6x9", 84.0f }, { "4x5", 120.0f }, + { "8x10", 244.0f }, + { "Super 8", 5.79f }, { "16mm", 10.26f }, { "Super 16", 12.52f }, + { "Super 35", 24.89f }, { "VistaVision", 37.72f }, + { "65mm 5-perf", 52.63f }, { "IMAX 15-perf", 69.6f }, +}; +#define FORMAT_PRESETS_N ((int)(sizeof(_format_presets) / sizeof(_format_presets[0]))) +#define FORMAT_PRESET_CUSTOM FORMAT_PRESETS_N + +static int _format_mm_to_preset(float mm) +{ + for(int i = 0; i < FORMAT_PRESETS_N; i++) + if(fabsf(_format_presets[i].mm - mm) < 0.01f) return i; + return FORMAT_PRESET_CUSTOM; +} + +static void _format_changed(GtkWidget *combo, gpointer user_data) +{ + dt_iop_module_t *self = (dt_iop_module_t *)user_data; + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; + const int pi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film_format_combo)); + if(pi >= 0 && pi < FORMAT_PRESETS_N) + { + DT_ENTER_GUI_UPDATE(); + dt_bauhaus_slider_set(g->film_format_mm_slider, _format_presets[pi].mm); + DT_LEAVE_GUI_UPDATE(); + p->film_format_mm = _format_presets[pi].mm; + dt_dev_add_history_item(darktable.develop, self, TRUE); + } +} + +static void _format_slider_changed(GtkWidget *slider, gpointer user_data) +{ + dt_iop_module_t *self = (dt_iop_module_t *)user_data; + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + const float mm = dt_bauhaus_slider_get(g->film_format_mm_slider); + dt_bauhaus_combobox_set_from_value(g->film_format_combo, _format_mm_to_preset(mm)); +} + +static void _populate_format_combo(dt_iop_module_t *self) +{ + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + dt_bauhaus_combobox_clear(g->film_format_combo); + dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "still")); + for(int i = 0; i < 7; i++) + dt_bauhaus_combobox_add_full(g->film_format_combo, _( _format_presets[i].label ), + DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(i), NULL, TRUE); + dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "cine")); + for(int i = 7; i < FORMAT_PRESETS_N; i++) + dt_bauhaus_combobox_add_full(g->film_format_combo, _( _format_presets[i].label ), + DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(i), NULL, TRUE); + dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "custom")); + dt_bauhaus_combobox_add_full(g->film_format_combo, _("custom"), + DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(FORMAT_PRESETS_N), NULL, TRUE); +} + /* per-piece data: parameter snapshot + a lazily (re)built simulation. The sim depends on the pipe's work profile, which is only reliably known in process(), so the build happens there guarded by a mutex. */ @@ -2650,10 +2715,13 @@ static void _update_print_sensitivity(dt_iop_module_t *self) gtk_widget_set_sensitive(g->filter_m, printing); gtk_widget_set_sensitive(g->filter_y, printing); gtk_widget_set_sensitive(g->print_diffusion_on, printing); - gtk_widget_set_sensitive(g->print_diffusion_filter_family, printing); - gtk_widget_set_sensitive(g->print_diffusion_strength, printing); - gtk_widget_set_sensitive(g->print_diffusion_scale, printing); - gtk_widget_set_sensitive(g->print_diffusion_warmth, printing); + gtk_widget_set_sensitive(g->print_diffusion_filter_family, printing && p->print_diffusion_on); + gtk_widget_set_sensitive(g->print_diffusion_strength, printing && p->print_diffusion_on); + gtk_widget_set_sensitive(g->print_diffusion_scale, printing && p->print_diffusion_on); + gtk_widget_set_sensitive(g->print_diffusion_warmth, printing && p->print_diffusion_on); + gtk_widget_set_sensitive(g->preflash_exposure, printing); + gtk_widget_set_sensitive(g->preflash_m_shift, printing); + gtk_widget_set_sensitive(g->preflash_y_shift, printing); /* toggle_from_params checkboxes keep showing their tick even when made insensitive -- GTK just dims the whole widget, so a checked-but-grayed box can read as "this is still on" when it has no effect at all (no @@ -2670,12 +2738,59 @@ static void _update_print_sensitivity(dt_iop_module_t *self) DT_LEAVE_GUI_UPDATE(); } +/* Grays out each effect's own sub-controls when its master "enable" toggle + is off -- previously only the print-related controls (scan_film -> + _update_print_sensitivity above) got this treatment; halation/grain/ + diffusion sliders stayed clickable-but-inert when their own toggle was + unchecked, which reads as "these still do something" when they don't. */ +static void _toggle_sensitivity(dt_iop_spektrafilm_gui_data_t *g, + dt_iop_spektrafilm_params_t *p) +{ + const gboolean hal = p->halation_on; + gtk_widget_set_sensitive(g->scatter_amount, hal); + gtk_widget_set_sensitive(g->scatter_scale, hal); + gtk_widget_set_sensitive(g->halation_amount, hal); + gtk_widget_set_sensitive(g->halation_scale, hal); + gtk_widget_set_sensitive(g->boost_ev, hal); + gtk_widget_set_sensitive(g->boost_range, hal); + gtk_widget_set_sensitive(g->protect_ev, hal); + + const gboolean grn = p->grain_on; + gtk_widget_set_sensitive(g->grain_amount, grn); + gtk_widget_set_sensitive(g->grain_size, grn); + gtk_widget_set_sensitive(g->grain_usm_sigma, grn); + gtk_widget_set_sensitive(g->grain_usm_amount, grn); + + const gboolean dif = p->diffusion_on; + gtk_widget_set_sensitive(g->diffusion_filter_family, dif); + gtk_widget_set_sensitive(g->diffusion_strength, dif); + gtk_widget_set_sensitive(g->diffusion_scale, dif); + gtk_widget_set_sensitive(g->diffusion_warmth, dif); + + const gboolean pdif = p->print_diffusion_on; + gtk_widget_set_sensitive(g->print_diffusion_filter_family, pdif); + gtk_widget_set_sensitive(g->print_diffusion_strength, pdif); + gtk_widget_set_sensitive(g->print_diffusion_scale, pdif); + gtk_widget_set_sensitive(g->print_diffusion_warmth, pdif); +} + +void gui_reset(dt_iop_module_t *self) +{ + dt_iop_color_picker_reset(self, TRUE); +} + /* called by the core whenever a params-linked widget changed */ void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous) { dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; if(!w || w == g->scan_film) _update_print_sensitivity(self); + if(!w || w == g->halation_on || w == g->grain_on || w == g->diffusion_on + || w == g->print_diffusion_on) + { + _toggle_sensitivity(g, p); + if(w == g->print_diffusion_on) _update_print_sensitivity(self); + } if(w == g->print_auto_exposure && !*(gboolean *)previous && p->print_auto_exposure) { /* print_exposure_ev (manual) and print_auto_exposure (automatic) are @@ -2801,6 +2916,8 @@ void gui_update(dt_iop_module_t *self) } dt_bauhaus_combobox_set_from_value(g->paper, g->paper_entry[pi]); + dt_bauhaus_combobox_set_from_value(g->film_format_combo, _format_mm_to_preset(p->film_format_mm)); + /* toggle_from_params check buttons are NOT auto-synced by dt_bauhaus_update_from_field (it only handles sliders/combos), so set them here or they drift from the params: a stale box makes the first @@ -2812,13 +2929,15 @@ void gui_update(dt_iop_module_t *self) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->diffusion_on), p->diffusion_on); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_diffusion_on), p->print_diffusion_on); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->grain_on), p->grain_on); + + _toggle_sensitivity(g, p); _update_print_sensitivity(self); } void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpipe_t *pipe) { dt_iop_spektrafilm_gui_data_t *g = self->gui_data; - if(picker != g->output_luminance_boost) return; + if(picker != g->output_boost) return; /* picked_color_min/max start at sentinel values (+FLT_MAX / -FLT_MAX) until a real area pick has actually landed; if this callback fires @@ -2881,7 +3000,7 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpi dt_iop_spektrafilm_params_t *p = self->params; p->output_luminance_boost = new_boost; DT_ENTER_GUI_UPDATE(); - dt_bauhaus_slider_set(g->output_luminance_boost, new_boost); + dt_bauhaus_slider_set(g->output_boost, new_boost); DT_LEAVE_GUI_UPDATE(); dt_dev_add_history_item(darktable.develop, self, TRUE); } @@ -2891,30 +3010,69 @@ void gui_init(dt_iop_module_t *self) dt_iop_spektrafilm_gui_data_t *g = IOP_GUI_ALLOC(spektrafilm); self->widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + GtkWidget *sf_main_box = self->widget; + + /* ---- header (always visible) ---- */ + GtkWidget *header_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + dt_gui_box_add(sf_main_box, header_box); + g->film = dt_bauhaus_combobox_new(self); dt_bauhaus_widget_set_label(g->film, NULL, N_("film stock")); gtk_widget_set_tooltip_text(g->film, _("film emulsion (spektrafilm filming profile)")); g_signal_connect(G_OBJECT(g->film), "value-changed", G_CALLBACK(_film_changed), self); - gtk_box_pack_start(GTK_BOX(self->widget), g->film, TRUE, TRUE, 0); + gtk_box_pack_start(GTK_BOX(header_box), g->film, TRUE, TRUE, 0); g->paper = dt_bauhaus_combobox_new(self); dt_bauhaus_widget_set_label(g->paper, NULL, N_("print paper")); gtk_widget_set_tooltip_text(g->paper, _("print/paper stock; defaults to the film's target print")); g_signal_connect(G_OBJECT(g->paper), "value-changed", G_CALLBACK(_paper_changed), self); - gtk_box_pack_start(GTK_BOX(self->widget), g->paper, TRUE, TRUE, 0); + gtk_box_pack_start(GTK_BOX(header_box), g->paper, TRUE, TRUE, 0); + + /* redirect self->widget so from_params widgets pack into header_box */ + self->widget = header_box; + + g->output_boost = dt_bauhaus_slider_from_params(self, "output_luminance_boost"); + gtk_widget_set_tooltip_text(g->output_boost, + _("pre-compression boost: multiplies XYZ luminance before the" + " OkLCh gamut compressor, pushing the histogram right while" + " preserving the film's natural shoulder rolloff")); + dt_color_picker_new(self, DT_COLOR_PICKER_AREA, g->output_boost); + dt_bauhaus_widget_set_quad_tooltip(g->output_boost, + _("pick brightest tone in the selected area and set the" + " boost so it lands just past the compressor's knee")); + + g->film_format_combo = dt_bauhaus_combobox_new(self); + dt_bauhaus_widget_set_label(g->film_format_combo, NULL, N_("format")); + gtk_widget_set_tooltip_text(g->film_format_combo, + _("common film/sensor gate presets; picking one sets the format" + " size slider below (pick \"custom\" to dial in an exact value)")); + _populate_format_combo(self); + g_signal_connect(G_OBJECT(g->film_format_combo), "value-changed", + G_CALLBACK(_format_changed), self); + gtk_box_pack_start(GTK_BOX(header_box), g->film_format_combo, TRUE, TRUE, 0); + + g->film_format_mm_slider = dt_bauhaus_slider_from_params(self, "film_format_mm"); + dt_bauhaus_slider_set_format(g->film_format_mm_slider, _(" mm")); + gtk_widget_set_tooltip_text(g->film_format_mm_slider, + _("physical film format, long side; sets the scale of grain and halation")); + g_signal_connect(G_OBJECT(g->film_format_mm_slider), "value-changed", + G_CALLBACK(_format_slider_changed), self); + + /* restore main widget for the notebook */ + self->widget = sf_main_box; - /* filmic-style tabbed notebook: everything else lives in tabs instead of a - single flat, ever-growing list of sliders. */ - GtkWidget *sf_main_box = self->widget; /* restored after all tab pages below */ + /* ---- notebook / tabs ---- */ static struct dt_action_def_t notebook_def = { }; g->notebook = dt_ui_notebook_new(¬ebook_def); dt_action_define_iop(self, NULL, N_("page"), GTK_WIDGET(g->notebook), ¬ebook_def); dt_gui_box_add(sf_main_box, GTK_WIDGET(g->notebook)); - /* ---- tab: film and print ---- */ - self->widget = dt_ui_notebook_page(g->notebook, N_("media"), NULL); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "film"))); + /* ---- tab 1: film (exposure + development) ---- */ + self->widget = dt_ui_notebook_page(g->notebook, N_("film"), NULL); + + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "exposure"))); + g->exposure_ev = dt_bauhaus_slider_from_params(self, "exposure_ev"); dt_bauhaus_slider_set_format(g->exposure_ev, _(" EV")); gtk_widget_set_tooltip_text( @@ -2924,40 +3082,121 @@ void gui_init(dt_iop_module_t *self) g->scan_film = dt_bauhaus_toggle_from_params(self, "scan_film"); gtk_widget_set_tooltip_text(g->scan_film, _("view the developed film directly (no print stage)")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print"))); + + g->push_pull_stops = dt_bauhaus_slider_from_params(self, "push_pull_stops"); + dt_bauhaus_slider_set_format(g->push_pull_stops, _(" stops")); + gtk_widget_set_tooltip_text( + g->push_pull_stops, + _("push (positive) or pull (negative) processing: shoot at an effective ISO" + " different from box speed, then under- or over-develop to compensate --" + " combines an exposure shift with a derived contrast increase/decrease" + " (approximate: the exact relationship depends on the specific film/developer" + " combination, which isn't modeled here). Stacks with the granular gamma" + " controls below for further fine-tuning")); + + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "chemistry"))); + + g->film_gamma_factor = dt_bauhaus_slider_from_params(self, "film_gamma_factor"); + dt_bauhaus_slider_set_soft_range(g->film_gamma_factor, 0.25f, 2.0f); + gtk_widget_set_tooltip_text( + g->film_gamma_factor, + _("overall development contrast (morphs the film's density curves) -- extended or" + " reduced development time, as in push/pull processing; 1.0 = normal development")); + + g->film_gamma_factor_fast = dt_bauhaus_slider_from_params(self, "film_gamma_factor_fast"); + dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_fast, 0.25f, 2.0f); + gtk_widget_set_tooltip_text( + g->film_gamma_factor_fast, + _("contrast of the fastest (most light-sensitive) emulsion sub-layer only --" + " independent of the slow layer, since push/pull processing doesn't always affect" + " every sub-layer equally")); + + g->film_gamma_factor_slow = dt_bauhaus_slider_from_params(self, "film_gamma_factor_slow"); + dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_slow, 0.25f, 2.0f); + gtk_widget_set_tooltip_text( + g->film_gamma_factor_slow, + _("contrast of the mid and slow emulsion sub-layers")); + + g->film_developer_exhaustion = dt_bauhaus_slider_from_params(self, "film_developer_exhaustion"); + gtk_widget_set_tooltip_text( + g->film_developer_exhaustion, + _("local developer depletion in dense (highly-exposed) areas: blends the highlight" + " shoulder toward a self-limiting rolloff without shifting midgray (0 = off)")); + + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "couplers and quality"))); + + g->couplers_amount = dt_bauhaus_slider_from_params(self, "couplers_amount"); + gtk_widget_set_tooltip_text(g->couplers_amount, + _("DIR coupler strength: inter-layer inhibition drives saturation" + " and edge effects (1.0 = film-accurate, 0 = off)")); + + g->quality = dt_bauhaus_combobox_from_params(self, "quality"); + gtk_widget_set_tooltip_text(g->quality, + _("spectral accuracy vs speed; the tables are PCHIP-interpolated" + " and validated against the reference")); + + /* ---- tab 2: print ---- */ + self->widget = dt_ui_notebook_page(g->notebook, N_("print"), NULL); + + GtkWidget *print_page = self->widget; + g->print_exposure_ev = dt_bauhaus_slider_from_params(self, "print_exposure_ev"); dt_bauhaus_slider_set_format(g->print_exposure_ev, _(" EV")); gtk_widget_set_tooltip_text(g->print_exposure_ev, _("print brightness (enlarger exposure)")); + g->print_auto_exposure = dt_bauhaus_toggle_from_params(self, "print_auto_exposure"); gtk_widget_set_tooltip_text( g->print_auto_exposure, _("automatically compensate print exposure for film exposure changes, as a real" " printer would print to a fixed density; disable for film exposure to affect" " brightness directly, same as a fixed enlarger exposure time")); + g->print_contrast = dt_bauhaus_slider_from_params(self, "print_contrast"); gtk_widget_set_tooltip_text(g->print_contrast, _("print contrast (morphs the paper's density curves)")); + + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "filtration"))); + g->filter_m = dt_bauhaus_slider_from_params(self, "filter_m"); dt_bauhaus_slider_set_format(g->filter_m, _(" CC")); gtk_widget_set_tooltip_text(g->filter_m, _("magenta enlarger filtration, Kodak CC units from neutral")); + g->filter_y = dt_bauhaus_slider_from_params(self, "filter_y"); dt_bauhaus_slider_set_format(g->filter_y, _(" CC")); gtk_widget_set_tooltip_text(g->filter_y, _("yellow enlarger filtration, Kodak CC units from neutral")); - g->couplers_amount = dt_bauhaus_slider_from_params(self, "couplers_amount"); - gtk_widget_set_tooltip_text(g->couplers_amount, - _("DIR coupler strength: inter-layer inhibition drives saturation" - " and edge effects (1.0 = film-accurate, 0 = off)")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "format"))); - g->film_format_mm = dt_bauhaus_slider_from_params(self, "film_format_mm"); - dt_bauhaus_slider_set_format(g->film_format_mm, _(" mm")); - gtk_widget_set_tooltip_text(g->film_format_mm, - _("physical film format, long side; sets the scale of grain and halation")); - /* ---- tab: grain ---- */ + self->widget = print_page; + + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "preflash"))); + + g->preflash_exposure = dt_bauhaus_slider_from_params(self, "preflash_exposure"); + gtk_widget_set_tooltip_text( + g->preflash_exposure, + _("preflash exposure: a brief, uniform pre-exposure of the print through" + " the film's base density, before the main print exposure -- lifts" + " shadows and reduces contrast (0 = off)")); + + g->preflash_m_shift = dt_bauhaus_slider_from_params(self, "preflash_m_shift"); + dt_bauhaus_slider_set_format(g->preflash_m_shift, _(" CC")); + gtk_widget_set_tooltip_text(g->preflash_m_shift, + _("magenta filtration for the preflash exposure only, Kodak CC" + " units from neutral -- independent of the main enlarger" + " filtration above")); + + g->preflash_y_shift = dt_bauhaus_slider_from_params(self, "preflash_y_shift"); + dt_bauhaus_slider_set_format(g->preflash_y_shift, _(" CC")); + gtk_widget_set_tooltip_text(g->preflash_y_shift, + _("yellow filtration for the preflash exposure only, Kodak CC" + " units from neutral -- independent of the main enlarger" + " filtration above")); + + /* ---- tab 3: grain ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("grain"), NULL); + g->grain_on = dt_bauhaus_toggle_from_params(self, "grain_on"); + g->grain_amount = dt_bauhaus_slider_from_params(self, "grain_amount"); dt_bauhaus_slider_set_soft_range(g->grain_amount, 0.0f, 2.0f); gtk_widget_set_tooltip_text(g->grain_amount, @@ -2965,15 +3204,19 @@ void gui_init(dt_iop_module_t *self) " right-click to enter higher values -- useful for pushing" " naturally fine-grained stocks further than their" " catalogue amount allows)")); + g->grain_size = dt_bauhaus_slider_from_params(self, "grain_size"); gtk_widget_set_tooltip_text(g->grain_size, _("grain particle size (1.0 = film default; higher = coarser)")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "acutance recovery"))); + g->grain_usm_sigma = dt_bauhaus_slider_from_params(self, "grain_usm_sigma"); dt_bauhaus_slider_set_soft_range(g->grain_usm_sigma, 0.0f, 3.0f); gtk_widget_set_tooltip_text(g->grain_usm_sigma, _("sharpening radius (0 = off). " "higher = wider halos, lower = finer detail")); + g->grain_usm_amount = dt_bauhaus_slider_from_params(self, "grain_usm_amount"); dt_bauhaus_slider_set_soft_range(g->grain_usm_amount, 0.0f, 2.0f); gtk_widget_set_tooltip_text(g->grain_usm_amount, @@ -2981,40 +3224,52 @@ void gui_init(dt_iop_module_t *self) "restores crispness that the grain blur softened; " "overdo it and grain starts to look crunchy")); - /* ---- tab: halation ---- */ + /* ---- tab 4: halation ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("halation"), NULL); + g->halation_on = dt_bauhaus_toggle_from_params(self, "halation_on"); + g->scatter_amount = dt_bauhaus_slider_from_params(self, "scatter_amount"); gtk_widget_set_tooltip_text(g->scatter_amount, _("in-emulsion light scatter, before the halation bounce" " (1.0 = film-accurate; 0 = off)")); + g->scatter_scale = dt_bauhaus_slider_from_params(self, "scatter_scale"); gtk_widget_set_tooltip_text(g->scatter_scale, _("scatter size: scales the in-emulsion scatter radius" " (1.0 = film-accurate)")); + g->halation_amount = dt_bauhaus_slider_from_params(self, "halation_amount"); dt_bauhaus_slider_set_soft_range(g->halation_amount, 0.0f, 2.0f); gtk_widget_set_tooltip_text(g->halation_amount, _("halation strength (1.0 = film-accurate; drag up to 2," " right-click to enter higher values)")); + g->halation_scale = dt_bauhaus_slider_from_params(self, "halation_scale"); gtk_widget_set_tooltip_text(g->halation_scale, _("halation size: scales the glow radius (1.0 = film-accurate)")); + + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "threshold"))); + g->boost_ev = dt_bauhaus_slider_from_params(self, "boost_ev"); dt_bauhaus_slider_set_format(g->boost_ev, _(" EV")); gtk_widget_set_tooltip_text(g->boost_ev, _("highlight boost: reconstructs clipped highlights so they bloom" " into halation/diffusion (0 = off)")); + g->boost_range = dt_bauhaus_slider_from_params(self, "boost_range"); gtk_widget_set_tooltip_text(g->boost_range, _("range of the highlight boost curve")); + g->protect_ev = dt_bauhaus_slider_from_params(self, "protect_ev"); dt_bauhaus_slider_set_format(g->protect_ev, _(" EV")); gtk_widget_set_tooltip_text(g->protect_ev, _("protect tones below this many stops over mid-grey from the boost")); - /* ---- tab: diffusion ---- */ + /* ---- tab 5: diffusion ---- */ self->widget = dt_ui_notebook_page(g->notebook, N_("diffusion"), NULL); + g->diffusion_on = dt_bauhaus_toggle_from_params(self, "diffusion_on"); + g->diffusion_filter_family = dt_bauhaus_combobox_from_params(self, "diffusion_filter_family"); gtk_widget_set_tooltip_text( g->diffusion_filter_family, @@ -3022,102 +3277,40 @@ void gui_init(dt_iop_module_t *self) " blacks) / glimmerglass (tight, subtle, sharp-preserving) / pro-mist" " (broader, pastel, atmospheric) / cinebloom (frame-wide, slow-decaying" " veil)")); + g->diffusion_strength = dt_bauhaus_slider_from_params(self, "diffusion_strength"); gtk_widget_set_tooltip_text(g->diffusion_strength, _("diffusion filter strength")); + g->diffusion_scale = dt_bauhaus_slider_from_params(self, "diffusion_scale"); gtk_widget_set_tooltip_text(g->diffusion_scale, _("diffusion halo/bloom size")); + g->diffusion_warmth = dt_bauhaus_slider_from_params(self, "diffusion_warmth"); gtk_widget_set_tooltip_text(g->diffusion_warmth, _("diffusion halo warmth: >0 warm outer halo, <0 cool" " (added on top of the selected filter's own warmth bias)")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "print diffusion"))); g->print_diffusion_on = dt_bauhaus_toggle_from_params(self, "print_diffusion_on"); + g->print_diffusion_filter_family = dt_bauhaus_combobox_from_params(self, "print_diffusion_filter_family"); gtk_widget_set_tooltip_text( g->print_diffusion_filter_family, _("print diffusion filter type (same presets as the film-stage filter)")); + g->print_diffusion_strength = dt_bauhaus_slider_from_params(self, "print_diffusion_strength"); gtk_widget_set_tooltip_text(g->print_diffusion_strength, _("print diffusion filter strength")); + g->print_diffusion_scale = dt_bauhaus_slider_from_params(self, "print_diffusion_scale"); gtk_widget_set_tooltip_text(g->print_diffusion_scale, _("print diffusion halo/bloom size")); + g->print_diffusion_warmth = dt_bauhaus_slider_from_params(self, "print_diffusion_warmth"); gtk_widget_set_tooltip_text(g->print_diffusion_warmth, _("print diffusion halo warmth: >0 warm outer halo, <0 cool" " (added on top of the selected filter's own warmth bias)")); - /* ---- tab: advanced ---- */ - self->widget = dt_ui_notebook_page(g->notebook, N_("advanced"), NULL); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "development"))); - g->push_pull_stops = dt_bauhaus_slider_from_params(self, "push_pull_stops"); - dt_bauhaus_slider_set_format(g->push_pull_stops, _(" stops")); - gtk_widget_set_tooltip_text( - g->push_pull_stops, - _("push (positive) or pull (negative) processing: shoot at an effective ISO" - " different from box speed, then under- or over-develop to compensate --" - " combines an exposure shift with a derived contrast increase/decrease" - " (approximate: the exact relationship depends on the specific film/developer" - " combination, which isn't modeled here). Stacks with the granular gamma" - " controls below for further fine-tuning")); - g->film_gamma_factor = dt_bauhaus_slider_from_params(self, "film_gamma_factor"); - dt_bauhaus_slider_set_soft_range(g->film_gamma_factor, 0.25f, 2.0f); - gtk_widget_set_tooltip_text( - g->film_gamma_factor, - _("overall development contrast (morphs the film's density curves) -- extended or" - " reduced development time, as in push/pull processing; 1.0 = normal development")); - g->film_gamma_factor_fast = dt_bauhaus_slider_from_params(self, "film_gamma_factor_fast"); - dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_fast, 0.25f, 2.0f); - gtk_widget_set_tooltip_text( - g->film_gamma_factor_fast, - _("contrast of the fastest (most light-sensitive) emulsion sub-layer only --" - " independent of the slow layer, since push/pull processing doesn't always affect" - " every sub-layer equally")); - g->film_gamma_factor_slow = dt_bauhaus_slider_from_params(self, "film_gamma_factor_slow"); - dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_slow, 0.25f, 2.0f); - gtk_widget_set_tooltip_text( - g->film_gamma_factor_slow, - _("contrast of the mid and slow emulsion sub-layers")); - g->film_developer_exhaustion = dt_bauhaus_slider_from_params(self, "film_developer_exhaustion"); - gtk_widget_set_tooltip_text( - g->film_developer_exhaustion, - _("local developer depletion in dense (highly-exposed) areas: blends the highlight" - " shoulder toward a self-limiting rolloff without shifting midgray (0 = off)")); - dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "preflash"))); - g->preflash_exposure = dt_bauhaus_slider_from_params(self, "preflash_exposure"); - gtk_widget_set_tooltip_text( - g->preflash_exposure, - _("preflash exposure: a brief, uniform pre-exposure of the print through" - " the film's base density, before the main print exposure -- lifts" - " shadows and reduces contrast (0 = off)")); - g->preflash_m_shift = dt_bauhaus_slider_from_params(self, "preflash_m_shift"); - dt_bauhaus_slider_set_format(g->preflash_m_shift, _(" CC")); - gtk_widget_set_tooltip_text(g->preflash_m_shift, - _("magenta filtration for the preflash exposure only, Kodak CC" - " units from neutral -- independent of the main enlarger" - " filtration above")); - g->preflash_y_shift = dt_bauhaus_slider_from_params(self, "preflash_y_shift"); - dt_bauhaus_slider_set_format(g->preflash_y_shift, _(" CC")); - gtk_widget_set_tooltip_text(g->preflash_y_shift, - _("yellow filtration for the preflash exposure only, Kodak CC" - " units from neutral -- independent of the main enlarger" - " filtration above")); - g->quality = dt_bauhaus_combobox_from_params(self, "quality"); - gtk_widget_set_tooltip_text(g->quality, - _("spectral accuracy vs speed; the tables are PCHIP-interpolated" - " and validated against the reference")); - g->output_luminance_boost = dt_bauhaus_slider_from_params(self, "output_luminance_boost"); - gtk_widget_set_tooltip_text(g->output_luminance_boost, - _("pre-compression boost: multiplies XYZ luminance before the" - " OkLCh gamut compressor, pushing the histogram right while" - " preserving the film's natural shoulder rolloff")); - dt_color_picker_new(self, DT_COLOR_PICKER_AREA, g->output_luminance_boost); - dt_bauhaus_widget_set_quad_tooltip(g->output_luminance_boost, - _("pick brightest tone in the selected area and set the" - " boost so it lands just past the compressor's knee")); - + /* restore root widget */ self->widget = sf_main_box; } From c6e5c9386eab686c1136456afc293263844cb844 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Thu, 23 Jul 2026 19:48:30 +0200 Subject: [PATCH 48/56] fix USM oversharpening on zoom out --- src/iop/spektrafilm.c | 47 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 0cf97fcb869b..f3688a281ea9 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -1752,6 +1752,23 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c rescale it live to the real pixe_um here, matching what sf_grain_delta_dmax now does directly for the single-layer case. */ const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM); + /* SF_GRAIN_BLUR_FACTOR/SF_GRAIN_DYE_BLUR_UM/grain_usm_sigma are fixed + pixel radii, validated against upstream at whatever single + resolution each of its own renders happens to use -- upstream has + no notion of "the same image, but at a temporarily reduced preview + resolution for interactive editing speed" the way darktable's + preview pipe does. Without this, the SAME nominal pixel radius + covers a much larger fraction of real scene detail in a + downscaled preview than at full/export resolution (a real image + edge that spans ~30px at full res might span ~4px in a heavily + zoomed-out preview), producing visible over-sharpening ringing on + actual scene content, not just grain texture, that isn't present + at 1:1/export. Capped at 1.0 so zooming in PAST 100% doesn't grow + the radii beyond what was actually validated. Particle density + (npart_scale above) is NOT touched by this -- it's correctly + resolution-dependent via pixel_um already, confirmed against the + reference at multiple different resolutions. */ + const float preview_scale = fminf(roi_in->scale, 1.0f); float grms[3], gunif[3], gdmin[3]; sf_sim_film_grain3(sim, grms, gunif, gdmin); /* per-film catalogue grain (rms-granularity, uniformity, density @@ -1779,7 +1796,7 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c { const float npart_c = (float)layers.layer_npart[sl][c] * npart_scale; const float od_particle = (float)layers.layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f); - dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * sqrtf(fmaxf(od_particle, 0.0f)); + dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * sqrtf(fmaxf(od_particle, 0.0f)) * preview_scale; } float *raw[SF_GRAIN_MAX_SUBLAYERS] = { 0 }; @@ -1853,8 +1870,14 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c scaling lives entirely in particle DENSITY (now pixel_um-driven above), not in this smoothing pass. SF_GRAIN_SIZE_CAL is gone: it was calibrated against the old pixel_um-scaled formula and no - longer applies. */ - const float sigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN); + longer applies. preview_scale is a SEPARATE, darktable-only + correction (see its own comment above): upstream always renders + one real resolution, but darktable's preview pipe renders the same + image at a temporarily reduced resolution for interactive speed, + so this fixed radius needs shrinking there or it over-affects real + scene detail relative to what 1:1/export shows. */ + const float sigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) + * preview_scale; sf_blur_plane3(gbuf, w, h, sigma, scratch); /* Centre grain delta: multi-sublayer model has positive DC bias (~0.07) that renorm amplifies proportionally to sigma, making image brighter @@ -1886,7 +1909,7 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c #endif for(size_t k = 0; k < npix * 3; k++) plane[k] += gbuf[k]; if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f) - sf_multiplicative_unsharp_mask3(plane, w, h, d->p.grain_usm_sigma, + sf_multiplicative_unsharp_mask3(plane, w, h, d->p.grain_usm_sigma * preview_scale, d->p.grain_usm_amount, corr, scratch); } @@ -2381,6 +2404,15 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ const int roi_x = roi_in->x, roi_y = roi_in->y; const float amount = d->p.grain_amount; const int mono = g->film_bw; /* B&W: achromatic grain */ + /* see the matching comment on the CPU path (process()) for the full + rationale: darktable's preview pipe renders at a temporarily + reduced resolution, unlike upstream which always renders one real + resolution, so these fixed pixel radii need shrinking there to + avoid over-affecting real scene detail. Capped at 1.0 so zooming + in past 100% doesn't grow radii beyond what was validated. Does + NOT apply to npart_scale below, which is correctly resolution- + dependent via pixel_um already. */ + const float preview_scale = fminf(roi_in->scale, 1.0f); /* Unified through the multi-sublayer table for every stock (nsub can be 1) rather than branching to a separate single-layer kernel -- see the matching comment in process()'s CPU path for why that's valid: the @@ -2429,7 +2461,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ { const float npart_c = g->grain_layer_npart[sl][c] * npart_scale; const float od_particle = g->grain_layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f); - dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * sqrtf(fmaxf(od_particle, 0.0f)); + dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * sqrtf(fmaxf(od_particle, 0.0f)) * preview_scale; } cl_mem raw_buf[SF_GRAIN_MAX_SUBLAYERS] = { NULL }; @@ -2486,7 +2518,8 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_CL_STEP("grain gen"); /* fixed pixel sigma, matching process()'s CPU-side fix -- see comment there for the empirical validation. */ - const float gsigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN); + const float gsigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) + * preview_scale; SF_GAUSS_BLUR4(tmpa, gsigma, "grain blur"); /* Centre grain delta (matches process()'s CPU-side fix exactly -- the multi-sublayer particle generator has a small positive DC bias; this @@ -2524,7 +2557,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ { err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane2, acc, 0, 0, npix * f * 4); if(err != CL_SUCCESS) goto cleanup; - const float usig = d->p.grain_usm_sigma; + const float usig = d->p.grain_usm_sigma * preview_scale; SF_GAUSS_BLUR4_FAST(plane2, usig, "grain USM blur"); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_usm, w, h, CLARG(plane2), CLARG(acc), CLARG(w), CLARG(h), From ccea8c2e312739146220a2ac461f7ac3822909de Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sat, 25 Jul 2026 21:10:46 +0200 Subject: [PATCH 49/56] safeguard scan button from resetting --- src/iop/spektrafilm.c | 105 ++++++++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 35 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index f3688a281ea9..d1069dec80f3 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -2684,21 +2684,33 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) if(fi < 0) return; const sf_prof_entry_t *e = &g->entries[fi]; p->film_hash = e->hash; - /* The core checkbox-reset mechanism (darktable-core-toggle-reset.patch) - doesn't re-read self->default_params live at reset time -- it captures - each checkbox's default once, at widget-creation time, as opaque - "dt-toggle-default" data on the button itself. Poke that cached value - here so a right-click/scroll reset on just this checkbox means "what - this film actually needs" (slides/reversal stocks: scan; negatives: - print) rather than the module's one-size-fits-all factory default - (FALSE). This must NOT also write self->default_params->scan_film: - that field is darktable-core's stable factory default, read directly - by memcpy() on a whole-module reset -- mutating it here made a - whole-module reset copy whatever film was last selected instead of - the true factory default, requiring two resets in a row to actually - converge (confirmed via gui_update tracing across a stack-paste). */ - if(g->scan_film) - g_object_set_data(G_OBJECT(g->scan_film), "dt-toggle-default", GINT_TO_POINTER(e->positive)); + /* scan_film's checkbox is a plain GtkCheckButton (dt_bauhaus_toggle_from_ + params), not a bauhaus widget -- the real reset mechanism for it + (dt_bauhaus_toggle_widget_reset, develop/imageop_gui.c, part of this + fork's own darktable-core toggle-reset patch) reads + self->default_params live, via pointer offset, at the moment of the + reset -- there is no separate cached value to poke; mutating + default_params->scan_film here IS the mechanism, not a workaround for + it. This covers BOTH of that patch's reset paths: a right-click/ + scroll reset on just this checkbox, and double-clicking the notebook + tab bar (_reset_all_bauhaus -> dt_bauhaus_toggle_widget_reset for + every checkbox on the current page). Without this, a positive/ + reversal film's scan_film (which has no print stage and needs + scan_film TRUE) would silently revert to the compiled FALSE default + on either gesture. + This alone would reintroduce an old bug, though: a later WHOLE-MODULE + reset (_gui_reset_callback -> dt_iop_reload_defaults -> + dt_iop_load_default_params's memcpy(params, default_params, ...)) + copies default_params verbatim, INCLUDING film_hash, which stays + pinned at the compiled default (kodak_portra_400, per gui_update's + own fallback) regardless of what's mutated here -- so a whole-module + reset needs default_params->scan_film to already match THAT film + (FALSE), not whatever film was selected right before the reset was + clicked. See reload_defaults() below, which restores exactly that, + and runs before the memcpy specifically so a single reset click + converges correctly on the first try. */ + if(self->default_params) + ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = e->positive; /* scan-film follows the film's natural mode on a film switch: slides and reversal stocks are viewed directly (scan), negatives go through the print stage. The user can still toggle freely afterwards -- this only @@ -2807,6 +2819,32 @@ static void _toggle_sensitivity(dt_iop_spektrafilm_gui_data_t *g, gtk_widget_set_sensitive(g->print_diffusion_warmth, pdif); } +/* dt_iop_reload_defaults() runs module->reload_defaults() (this function, + if present) BEFORE dt_iop_load_default_params()'s memcpy(params, + default_params, ...) -- see _gui_reset_callback, develop/imageop.c. + _film_changed/gui_update mutate self->default_params->scan_film to + track whatever film is CURRENTLY selected (see the comment in + _film_changed for why that mutation, not a separate cache, is the real + core toggle-reset mechanism for this checkbox). But a whole-module + reset's memcpy also resets film_hash itself, unconditionally, back to + the compiled default (0, which gui_update's own fallback resolves to + kodak_portra_400 -- a negative film, scan_film FALSE) -- regardless of + what film was selected right before the reset was clicked. Without + this, default_params->scan_film could still be TRUE (from a + previously-selected positive film) at the moment the memcpy runs, + copying a value that doesn't match the film_hash it's paired with in + the same struct; only the NEXT reload_defaults() (triggered by that + same reset's own gui_update(), which re-baselines it for the film the + combobox fell back to) would catch up, so the first reset click landed + wrong and a second was needed to actually converge. Reset it back to + the one film_hash=0 always actually means, right before the memcpy + copies it, so the first click is correct. */ +void reload_defaults(dt_iop_module_t *self) +{ + if(self->default_params) + ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = FALSE; +} + void gui_reset(dt_iop_module_t *self) { dt_iop_color_picker_reset(self, TRUE); @@ -2915,28 +2953,25 @@ void gui_update(dt_iop_module_t *self) /* _film_changed() is a no-op during the combobox-set above (it bails out on darktable.gui->reset, which gui_update runs under, so programmatic loads don't get treated as user edits / spawn spurious history items). - That means its scan_film "what should a reset target" bookkeeping -- - the checkbox's own cached "dt-toggle-default" -- never gets - re-baselined on a fresh module load, only when the user actually - interacts with the film combobox. Left alone, it stays at the compiled - FALSE default after e.g. closing and reopening darktable on an image - using a positive/reversal film (which has no print stage and needs - scan_film TRUE): the checkbox itself still shows correctly checked - here (synced from p->scan_film below), but a later right-click/scroll - reset on just this checkbox would silently flip scan_film back off. - Re-baseline it here too, exactly like _film_changed does -- but - WITHOUT touching p->scan_film itself, since the just-loaded value may - be a deliberate user override away from the film's natural mode and - must be preserved on load; only the reset target needs fixing. Do NOT - also write self->default_params->scan_film here: that field is - darktable-core's stable factory default, read directly by memcpy() on - a whole-module reset -- mutating it made a whole-module reset copy - whatever film was last selected instead of the true factory default. */ - if(fi < g->n_films) + That means its self->default_params->scan_film re-baselining (see the + fuller comment there for why this field, not a separate cache, IS the + real core toggle-reset mechanism) never runs on a fresh module load, + only when the user actually interacts with the film combobox. Left + alone, it stays at whatever it was last set to -- possibly from a + PREVIOUSLY loaded image's film -- after e.g. switching to a different + image with a positive/reversal film loaded (which has no print stage + and needs scan_film TRUE): the checkbox itself still shows correctly + checked here (synced from p->scan_film below), but a later + right-click/scroll or tab-bar-double-click reset would silently flip + scan_film back off. Re-baseline it here too, exactly like + _film_changed does -- but WITHOUT touching p->scan_film itself, since + the just-loaded value may be a deliberate user override away from the + film's natural mode and must be preserved on load; only the reset + target needs fixing. */ + if(fi < g->n_films && self->default_params) { const sf_prof_entry_t *e = &g->entries[g->film_entry[fi]]; - if(g->scan_film) - g_object_set_data(G_OBJECT(g->scan_film), "dt-toggle-default", GINT_TO_POINTER(e->positive)); + ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = e->positive; } int pi = 0; const char *target = (fi < g->n_films) ? g->entries[g->film_entry[fi]].target_print : NULL; From 6797d1584507027f719e20a6303ff6d005811699 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 26 Jul 2026 06:28:29 +0200 Subject: [PATCH 50/56] remove stale code, make algorithms adhere to spektrafilm ref code, add missing glare stage and scanner feature set --- data/kernels/spektrafilm.cl | 241 +++++++++++-- src/common/spektra_core.c | 260 ++++++++------ src/common/spektra_core.h | 656 ++++-------------------------------- src/common/spektra_sim.c | 158 +++++---- src/common/spektra_sim.h | 9 +- src/iop/spektrafilm.c | 398 +++++++++++++++++----- 6 files changed, 828 insertions(+), 894 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 5dfebc1b104a..93001a7a432a 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -285,17 +285,36 @@ static inline uint sf_pixel_seed(uint xi, uint yi, uint chan) { return xi * 73856093u ^ yi * 19349663u ^ chan * 83492791u; } +/* Single Poisson draw; must stay in lockstep with sf_poisson in spektra_core.h + (exact below 12, bounded normal above -- see the derivation there). */ +#define SF_POISSON_EXACT_MAX 12.0f +static float sf_poisson(float lam, uint seed) +{ + if(lam <= 0.f) return 0.f; + if(lam < SF_POISSON_EXACT_MAX) + { + const float limit = exp(-lam); + float prod = 1.f; + int k = 0; + do + { + prod *= sf_u01(seed + (uint)k * 0x9e3779b9u); + k++; + } while(prod > limit && k < 64); + return (float)(k - 1); + } + return lam + native_sqrt(lam) * sf_nrm(seed); +} + +/* Binomial(Poisson(lam), p) == Poisson(lam*p) exactly (Poisson thinning), so the + reference's two-stage compound draw is one Poisson here -- exactly unbiased, + no clamping. See sf_layer_particle in spektra_core.h. */ static float sf_layer_particle(float density, float dmax, float npart, float unif, uint seed) { - float p = sf_clampf(density / dmax, 1e-6f, 1.f - 1e-6f), od = dmax / npart, - sat = 1.f - p * unif * (1.f - 1e-6f), lam = npart / sat; - float seeds = lam + native_sqrt(fmax(lam, 0.f)) * sf_nrm(seed * 0x9e3779b9u + 1u); - seeds = fmax(seeds, 0.f); - float mean = seeds * p, var = seeds * p * (1.f - p), - g = mean + native_sqrt(fmax(var, 0.f)) * sf_nrm(seed * 0x85ebca6bU + 7u); - g = fmax(g, 0.f); - g = fmin(g, seeds); - return g * od * sat; + const float p = sf_clampf(density / dmax, 1e-6f, 1.f - 1e-6f); + const float od = dmax / npart; + const float sat = 1.f - p * unif * (1.f - 1e-6f); + return sf_poisson(npart * p / sat, seed * 0x9e3779b9u + 1u) * od * sat; } /* ======================================================================== */ @@ -526,29 +545,22 @@ __kernel void spektrafilm_grain_finalize_channel(__global float4 *grain_buf, grain_buf[k] = gv; } -/* gmean centres the grain-delta buffer before adding it back, matching - process()'s CPU-side DC-bias removal (see there for the full rationale: - the multi-sublayer particle generator has a small positive DC bias). - gmean is computed host-side over the whole buffer (see process_cl()) - since a full-image reduction is simplest done there, then passed down - PER CHANNEL as a float4 -- a single pooled scalar across R+G+B would - leave each channel's own bias minus the pooled average as an - uncorrected residual (a color cast, not just a brightness bug). - No variance-restoration renorm here (upstream's own grain finalization, - _finalize_grain in grain.py, has none either -- it just blurs and lets - the natural contrast reduction stand, matching real optical clumping; - restoring full pre-blur variance made grain visibly higher-contrast, - and therefore visually coarser, than upstream at any matching sigma). */ +/* Add the blurred grain delta back onto the CMY density. No centring pass: the + Poisson sampler in sf_layer_particle is unbiased, so the delta already has + zero mean. No variance-restoration renorm either -- the reference's own grain + finalization (_finalize_grain in grain.py) has none; it just blurs and lets + the natural contrast reduction stand, matching real optical clumping. + Restoring full pre-blur variance made grain visibly higher-contrast, and + therefore visually coarser, than the reference at any matching sigma. */ __kernel void spektrafilm_grain_add(__global float4 *dens_buf, __global const float4 *grain_buf, - const int w, const int h, const float4 gmean) + const int w, const int h) { const int x = get_global_id(0), y = get_global_id(1); if(x >= w || y >= h) return; const size_t k = (size_t)y * w + x; - float4 d = dens_buf[k]; - float4 g = grain_buf[k]; - dens_buf[k] = (float4)(d.x + (g.x - gmean.x), d.y + (g.y - gmean.y), - d.z + (g.z - gmean.z), d.w); + const float4 d = dens_buf[k]; + const float4 g = grain_buf[k]; + dens_buf[k] = (float4)(d.x + g.x, d.y + g.y, d.z + g.z, d.w); } /* Multiplicative unsharp mask after grain blur (study b80). @@ -620,9 +632,11 @@ __kernel void spektrafilm_print_develop(__global const float4 *loge, __global fl OkLCh (mode 1) / ACES RGC (mode 2) gamut compression. Runs on the OUTPUT grid, cropping (ox, oy) from the full-ROI plane and taking alpha from the input image (spektra_sim: sf_sim_scan). */ -__kernel void spektrafilm_scan(__global const float4 *cmy, __read_only image2d_t in, - __write_only image2d_t out, const int w, const int ow, - const int oh, const int ox, const int oy, +/* Scans the full padded ROI into a buffer rather than straight into the output + image: the scanner optics and the glare veil run after this on the scanned + RGB, and they need the padding. spektrafilm_crop_out does the crop. */ +__kernel void spektrafilm_scan(__global const float4 *cmy, __global float4 *rgb_out, + const int w, const int h, __global const float *lut, __global const float *sx, __global const float *sy, __global const float *sz, __global const float *cmn, __global const float *cmx, @@ -635,8 +649,8 @@ __kernel void spektrafilm_scan(__global const float4 *cmy, __read_only image2d_t const int bw_on, const float bw_m, const float bw_q) { const int x = get_global_id(0), y = get_global_id(1); - if(x >= ow || y >= oh) return; - const size_t k = (size_t)(y + oy) * w + (x + ox); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; const float4 c4 = cmy[k]; const float scale = (float)(steps - 1); const float r = (c4.x - lo0) / (hi0 - lo0) * scale; @@ -680,8 +694,56 @@ __kernel void spektrafilm_scan(__global const float4 *cmy, __read_only image2d_t } } + rgb_out[k] = (float4)(rgb.x, rgb.y, rgb.z, 0.0f); +} + +/* Crop the padded ROI down to roi_out and carry the input alpha through. */ +__kernel void spektrafilm_crop_out(__global const float4 *rgb, __read_only image2d_t in, + __write_only image2d_t out, const int w, const int ow, + const int oh, const int ox, const int oy) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= ow || y >= oh) return; + const float4 v = rgb[(size_t)(y + oy) * w + (x + ox)]; const float4 px = read_imagef(in, sampleri, (int2)(x + ox, y + oy)); - write_imagef(out, (int2)(x, y), (float4)(rgb.x, rgb.y, rgb.z, px.w)); + write_imagef(out, (int2)(x, y), (float4)(v.x, v.y, v.z, px.w)); +} + +/* Additive unsharp mask on the scanned RGB (sf_unsharp_mask3): `rgb` holds the + blurred copy on entry, `orig` the unblurred one. */ +__kernel void spektrafilm_scan_usm(__global float4 *rgb, __global const float4 *orig, + const int w, const int h, const float amount) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 D = orig[k], blur = rgb[k]; + rgb[k] = (float4)(D.x + amount * (D.x - blur.x), D.y + amount * (D.y - blur.y), + D.z + amount * (D.z - blur.z), D.w); +} + +/* Viewing-glare field: lognormal of linear-space mean `mean` and shape (s, bias) + precomputed host-side, keyed on absolute image coordinates so the veil is + stable under pan and zoom (sf_glare). Blurred by the caller, then added. */ +__kernel void spektrafilm_glare_gen(__global float4 *field, const int w, const int h, + const int roi_x, const int roi_y, const float mean, + const float s, const float bias) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const uint seed = sf_pixel_seed((uint)(x + roi_x), (uint)(y + roi_y), 0x5eedu); + const float g = mean * exp(bias + s * sf_nrm(seed)); + field[(size_t)y * w + x] = (float4)(g, g, g, 0.0f); +} + +__kernel void spektrafilm_glare_add(__global float4 *rgb, __global const float4 *field, + const int w, const int h) +{ + const int x = get_global_id(0), y = get_global_id(1); + if(x >= w || y >= h) return; + const size_t k = (size_t)y * w + x; + const float4 v = rgb[k], g = field[k]; + rgb[k] = (float4)(v.x + g.x, v.y + g.y, v.z + g.z, v.w); } /* passthrough crop when no sim is available */ @@ -705,6 +767,119 @@ __kernel void spektrafilm_passthrough(__read_only image2d_t in, __write_only ima * inner loop's memory access pattern explicit at the call site. Separate * _1c/_4c variants avoid packing a lone scatter-stage channel into an * otherwise-wasted float4. */ +/* Young-van Vliet order-3 recursive Gaussian, one work-item per line. Same + coefficients and same edge-replicated seeding as sf_gauss_yvv_coeffs / + _sf_gauss_iir_1d on the CPU, so both paths deliver the identical blur above + SF_GAUSS_EXACT_MAX_SIGMA. Launch with global size (h, 1) for rows and + (w, 1) for columns. */ +__kernel void spektrafilm_yvv_row_4c(__global const float4 *src, __global float4 *dst, + const int w, const int h, const float B, const float B1, + const float B2, const float B3) +{ + /* One work-item per line. The launch asks for a second global dimension of + 1, but dt_opencl_enqueue_kernel_2d_args rounds every dimension up to the + device's preferred multiple, so this is entered by a whole row of items + per line -- all of which would otherwise run the same serial recursion + over the same addresses and race. Only item 0 may proceed. */ + const int row = get_global_id(0); + if(row >= h || get_global_id(1) != 0) return; + __global const float4 *s = src + (size_t)row * w; + __global float4 *d = dst + (size_t)row * w; + float4 w1 = s[0], w2 = s[0], w3 = s[0]; + for(int j = 0; j < w; j++) + { + const float4 v = B * s[j] + B1 * w1 + B2 * w2 + B3 * w3; + d[j] = v; w3 = w2; w2 = w1; w1 = v; + } + float4 v1 = d[w - 1], v2 = v1, v3 = v1; + for(int j = w - 1; j >= 0; j--) + { + const float4 v = B * d[j] + B1 * v1 + B2 * v2 + B3 * v3; + d[j] = v; v3 = v2; v2 = v1; v1 = v; + } +} + +__kernel void spektrafilm_yvv_col_4c(__global const float4 *src, __global float4 *dst, + const int w, const int h, const float B, const float B1, + const float B2, const float B3) +{ + /* One work-item per line. The launch asks for a second global dimension of + 1, but dt_opencl_enqueue_kernel_2d_args rounds every dimension up to the + device's preferred multiple, so this is entered by a whole row of items + per line -- all of which would otherwise run the same serial recursion + over the same addresses and race. Only item 0 may proceed. */ + const int col = get_global_id(0); + if(col >= w || get_global_id(1) != 0) return; + float4 w1 = src[col], w2 = w1, w3 = w1; + for(int i = 0; i < h; i++) + { + const size_t k = (size_t)i * w + col; + const float4 v = B * src[k] + B1 * w1 + B2 * w2 + B3 * w3; + dst[k] = v; w3 = w2; w2 = w1; w1 = v; + } + float4 v1 = dst[(size_t)(h - 1) * w + col], v2 = v1, v3 = v1; + for(int i = h - 1; i >= 0; i--) + { + const size_t k = (size_t)i * w + col; + const float4 v = B * dst[k] + B1 * v1 + B2 * v2 + B3 * v3; + dst[k] = v; v3 = v2; v2 = v1; v1 = v; + } +} + +__kernel void spektrafilm_yvv_row_1c(__global const float *src, __global float *dst, + const int w, const int h, const float B, const float B1, + const float B2, const float B3) +{ + /* One work-item per line. The launch asks for a second global dimension of + 1, but dt_opencl_enqueue_kernel_2d_args rounds every dimension up to the + device's preferred multiple, so this is entered by a whole row of items + per line -- all of which would otherwise run the same serial recursion + over the same addresses and race. Only item 0 may proceed. */ + const int row = get_global_id(0); + if(row >= h || get_global_id(1) != 0) return; + __global const float *s = src + (size_t)row * w; + __global float *d = dst + (size_t)row * w; + float w1 = s[0], w2 = s[0], w3 = s[0]; + for(int j = 0; j < w; j++) + { + const float v = B * s[j] + B1 * w1 + B2 * w2 + B3 * w3; + d[j] = v; w3 = w2; w2 = w1; w1 = v; + } + float v1 = d[w - 1], v2 = v1, v3 = v1; + for(int j = w - 1; j >= 0; j--) + { + const float v = B * d[j] + B1 * v1 + B2 * v2 + B3 * v3; + d[j] = v; v3 = v2; v2 = v1; v1 = v; + } +} + +__kernel void spektrafilm_yvv_col_1c(__global const float *src, __global float *dst, + const int w, const int h, const float B, const float B1, + const float B2, const float B3) +{ + /* One work-item per line. The launch asks for a second global dimension of + 1, but dt_opencl_enqueue_kernel_2d_args rounds every dimension up to the + device's preferred multiple, so this is entered by a whole row of items + per line -- all of which would otherwise run the same serial recursion + over the same addresses and race. Only item 0 may proceed. */ + const int col = get_global_id(0); + if(col >= w || get_global_id(1) != 0) return; + float w1 = src[col], w2 = w1, w3 = w1; + for(int i = 0; i < h; i++) + { + const size_t k = (size_t)i * w + col; + const float v = B * src[k] + B1 * w1 + B2 * w2 + B3 * w3; + dst[k] = v; w3 = w2; w2 = w1; w1 = v; + } + float v1 = dst[(size_t)(h - 1) * w + col], v2 = v1, v3 = v1; + for(int i = h - 1; i >= 0; i--) + { + const size_t k = (size_t)i * w + col; + const float v = B * dst[k] + B1 * v1 + B2 * v2 + B3 * v3; + dst[k] = v; v3 = v2; v2 = v1; v1 = v; + } +} + __kernel void spektrafilm_gauss_row_4c(__global const float4 *src, __global float4 *dst, const int w, const int h, __global const float *weights, const int radius) diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index ec81351c3230..b4729927f02c 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -35,16 +35,6 @@ #include #include -/* The bundle-loader half of spektra_core.h needs file-IO/locale helpers. darktable - poisons bare libc fopen, so map them to glib here (this .c does not itself read - bundles, but the shared header must compile in this translation unit). */ -#include -/* whole-file slurp via glib (g_file_get_contents returns a NUL-terminated, - g_free-owned buffer); maps the header's SF_READ_FILE/SF_FREE_FILE. */ -#define SF_READ_FILE(path, out, len) \ - (g_file_get_contents((path), (out), (len), NULL) ? 0 : -1) -#define SF_FREE_FILE(buf) g_free(buf) -#define SF_STRTOD(s, end) g_ascii_strtod((s), (end)) #include "spektra_core.h" /* ------------------------------------------------------------------------ */ @@ -60,22 +50,12 @@ /* Maximum kernel half-width: see SF_GAUSS_MAX_RADIUS in spektra_core.h. */ -/* Build a normalized, truncated 1D Gaussian kernel -- truncate=4 sigma, - matching scipy.ndimage.gaussian_filter's own default (what the reference - spektrafilm actually blurs with) -- so this blur is exact to kernel- - truncation precision (~1e-7 relative error at 4 sigma) for ANY sigma. - This replaces a recursive (Young-van Vliet IIR) approximation, whose - actual blur radius measurably departs from the sigma it's asked for -- - confirmed by simulating its impulse response and comparing the resulting - second moment to the requested sigma: the discrepancy is a stable ~18% - too wide for sigma above ~1.5px, but the error changes character (and can - flip to too NARROW) below that, exactly the small-sigma range scatter's - core/tail and grain's clump blur typically fall into. A direct kernel has - no such regime-dependent error to chase. `kernel` must have room for - 2*max_radius+1 taps; returns the radius actually used. */ +/* Direct kernel for the small-sigma path, matching the reference's own + _gaussian_kernel_1d: truncate = 3, radius = int(3*sigma + 0.5). `kernel` must + have room for 2*max_radius+1 taps; returns the radius actually used. */ int sf_gauss_kernel_1d(const float sigma, float *const kernel, const int max_radius) { - int radius = (int)ceilf(4.0f * sigma); + int radius = (int)(3.0f * sigma + 0.5f); if(radius < 1) radius = 1; if(radius > max_radius) radius = max_radius; double sum = 0.0; @@ -91,64 +71,60 @@ int sf_gauss_kernel_1d(const float sigma, float *const kernel, const int max_rad return radius; } -/* SF_GAUSS_EXACT_MAX_SIGMA / SF_GAUSS_SIGMA_CORRECTION are defined in - spektra_core.h, shared with spektrafilm.c's GPU macros. */ +/* SF_GAUSS_EXACT_MAX_SIGMA is defined in spektra_core.h, shared with + spektrafilm.c's GPU macros so both dispatch at the same sigma. */ -/* IIR coefficients for Gaussian order-0 (Young-van Vliet). `sigma` here is - * already corrected (see SF_GAUSS_SIGMA_CORRECTION) -- this function has no - * opinion on that, it just builds the filter for whatever sigma it's given. */ -static void _sf_gauss_iir_coeffs(const float sigma, float *a0, float *a1, float *a2, - float *a3, float *b1, float *b2, float *coefp, float *coefn) +/* Young-van Vliet order-3 recursive Gaussian -- the same filter and the same + * coefficients the reference runs above its own crossover (_yvv_coeffs / + * _iir_horizontal in fast_gaussian_filter.py). + * + * What was here before was a Deriche-form recursion (alpha = 1.695/sigma) whose + * impulse response is ~1.18x wider than the sigma it is asked for, fixed up by + * a constant SF_GAUSS_SIGMA_CORRECTION. That made the blur accurate in absolute + * terms but not equal to the reference's, which is itself 8-11% wide over the + * range that matters (requested 5 -> effective 5.56, requested 30 -> 32.28). + * Every spatial radius in this module -- halation's 65 um first bounce, the + * scatter core and tail, the diffusion bank -- was chosen by eye against renders + * made THROUGH that filter, so matching it is what reproduces the intended look; + * being independently correct just makes every halo about 10% too tight. */ +void sf_gauss_yvv_coeffs(const float sigma, float out[4]) { - const float alpha = 1.695f / sigma; - const float ema = expf(-alpha); - const float ema2 = expf(-2.0f * alpha); - *b1 = -2.0f * ema; - *b2 = ema2; - const float k = (1.0f - ema) * (1.0f - ema) / (1.0f + (2.0f * alpha * ema) - ema2); - *a0 = k; - *a1 = k * (alpha - 1.0f) * ema; - *a2 = k * (alpha + 1.0f) * ema; - *a3 = -k * ema2; - *coefp = (*a0 + *a1) / (1.0f + *b1 + *b2); - *coefn = (*a2 + *a3) / (1.0f + *b1 + *b2); + const double s = (double)sigma; + const double q = (s >= 2.5) ? (0.98711 * s - 0.96330) + : (3.97156 - 4.14554 * sqrt(1.0 - 0.26891 * s)); + const double q2 = q * q, q3 = q2 * q; + const double b0 = 1.57825 + 2.44413 * q + 1.4281 * q2 + 0.422205 * q3; + const double b1 = 2.44413 * q + 2.85619 * q2 + 1.26661 * q3; + const double b2 = -(1.4281 * q2 + 1.26661 * q3); + const double b3 = 0.422205 * q3; + out[1] = (float)(b1 / b0); + out[2] = (float)(b2 / b0); + out[3] = (float)(b3 / b0); + out[0] = (float)(1.0 - (b1 + b2 + b3) / b0); } -/* Causal + anticausal IIR pass over `len` elements (stride 1), combined into - * `out`. Range-clamped to [vmin, vmax] as a safety margin -- generation - * values here never approach the +-1e9 bounds in practice. */ +/* Forward then backward sweep over `len` stride-1 elements. Both sweeps seed + * their state by replicating the edge sample, as the reference does. */ static void _sf_gauss_iir_1d(const float *const in, float *const out, const int len, - const float a0, const float a1, const float a2, const float a3, - const float b1, const float b2, const float coefp, const float coefn) + const float B, const float B1, const float B2, const float B3) { - const float vmin = -1.0e9f, vmax = 1.0e9f; - float xp = CLAMP(in[0], vmin, vmax); - float yb = xp * coefp; - float yp = yb; - out[0] = yp; - for(int i = 1; i < len; i++) + float w1 = in[0], w2 = in[0], w3 = in[0]; + for(int i = 0; i < len; i++) { - const float xc = CLAMP(in[i], vmin, vmax); - const float yc = a0 * xc + a1 * xp - b1 * yp - b2 * yb; - xp = xc; - yb = yp; - yp = yc; - out[i] = yc; + const float v = B * in[i] + B1 * w1 + B2 * w2 + B3 * w3; + out[i] = v; + w3 = w2; + w2 = w1; + w1 = v; } - float xn = CLAMP(in[len - 1], vmin, vmax); - float xa = xn; - float yn = xn * coefn; - float ya = yn; - out[len - 1] += yn; - for(int i = len - 2; i >= 0; i--) + float y1 = out[len - 1], y2 = y1, y3 = y1; + for(int i = len - 1; i >= 0; i--) { - const float xc = CLAMP(in[i], vmin, vmax); - const float yc = a2 * xn + a3 * xa - b1 * yn - b2 * ya; - xa = xn; - xn = xc; - ya = yn; - yn = yc; - out[i] += yc; + const float v = B * out[i] + B1 * y1 + B2 * y2 + B3 * y3; + out[i] = v; + y3 = y2; + y2 = y1; + y1 = v; } } @@ -202,12 +178,9 @@ static void _blur_flat_inplace(float *const plane, const int w, const int h, const int use_iir = !exact_only && sigma >= SF_GAUSS_EXACT_MAX_SIGMA; float kernel[2 * SF_GAUSS_MAX_RADIUS + 1]; int radius = 0; - float a0 = 0, a1 = 0, a2 = 0, a3 = 0, b1 = 0, b2 = 0, coefp = 0, coefn = 0; - if(use_iir) - _sf_gauss_iir_coeffs(sigma * SF_GAUSS_SIGMA_CORRECTION, &a0, &a1, &a2, &a3, &b1, &b2, &coefp, - &coefn); - else - radius = sf_gauss_kernel_1d(sigma, kernel, SF_GAUSS_MAX_RADIUS); + float yvv[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; + if(use_iir) sf_gauss_yvv_coeffs(sigma, yvv); + else radius = sf_gauss_kernel_1d(sigma, kernel, SF_GAUSS_MAX_RADIUS); if(trans && w >= 16 && h >= 16) { @@ -217,7 +190,7 @@ static void _blur_flat_inplace(float *const plane, const int w, const int h, for(int j = 0; j < h; j++) { const size_t off = (size_t)j * w; - if(use_iir) _sf_gauss_iir_1d(plane + off, temp + off, w, a0, a1, a2, a3, b1, b2, coefp, coefn); + if(use_iir) _sf_gauss_iir_1d(plane + off, temp + off, w, yvv[0], yvv[1], yvv[2], yvv[3]); else _sf_gauss_convolve_1d(plane + off, temp + off, w, kernel, radius); } /* Transpose temp (w×h) -> plane (h×w) */ @@ -227,7 +200,7 @@ static void _blur_flat_inplace(float *const plane, const int w, const int h, for(int j = 0; j < w; j++) { const size_t off = (size_t)j * h; - if(use_iir) _sf_gauss_iir_1d(plane + off, temp + off, h, a0, a1, a2, a3, b1, b2, coefp, coefn); + if(use_iir) _sf_gauss_iir_1d(plane + off, temp + off, h, yvv[0], yvv[1], yvv[2], yvv[3]); else _sf_gauss_convolve_1d(plane + off, temp + off, h, kernel, radius); } /* Transpose back temp (h×w) -> plane (w×h) */ @@ -242,7 +215,7 @@ static void _blur_flat_inplace(float *const plane, const int w, const int h, for(int j = 0; j < h; j++) { if(use_iir) - _sf_gauss_iir_1d(plane + (size_t)j * w, row_tmp, w, a0, a1, a2, a3, b1, b2, coefp, coefn); + _sf_gauss_iir_1d(plane + (size_t)j * w, row_tmp, w, yvv[0], yvv[1], yvv[2], yvv[3]); else _sf_gauss_convolve_1d(plane + (size_t)j * w, row_tmp, w, kernel, radius); memcpy(plane + (size_t)j * w, row_tmp, sizeof(float) * w); @@ -254,7 +227,7 @@ static void _blur_flat_inplace(float *const plane, const int w, const int h, for(int i = 0; i < w; i++) { for(int j = 0; j < h; j++) col_in[j] = plane[(size_t)j * w + i]; - if(use_iir) _sf_gauss_iir_1d(col_in, col_out, h, a0, a1, a2, a3, b1, b2, coefp, coefn); + if(use_iir) _sf_gauss_iir_1d(col_in, col_out, h, yvv[0], yvv[1], yvv[2], yvv[3]); else _sf_gauss_convolve_1d(col_in, col_out, h, kernel, radius); for(int j = 0; j < h; j++) plane[(size_t)j * w + i] = col_out[j]; } @@ -320,36 +293,95 @@ void sf_blur_plane3_fast(float *const buf, const int w, const int h, const float dt_free_align(trans); } -/* Multiplicative (mass-conserving) unsharp mask on density (study b80). - out = D * (D / blur(D))^amount with per-channel mass renormalisation. */ +/* Multiplicative unsharp mask on density (study b80): out = D * (D / blur(D))^amount. + The reference (apply_multiplicative_unsharp_mask, diffusion.py) follows this + with a per-channel scalar renormalisation that restores each channel's total + density mass. That renormalisation is deliberately NOT reproduced here: it is + a whole-image reduction, and this function runs on whatever ROI or tile the + pixelpipe hands it, so the scale factor would differ between the preview pipe, + the export pipe and every tile of a tiled export -- the same pixel would come + out at a different density depending on how the image was cut up. The + correction it applies is in any case tiny: measured over synthetic CMY density + fields with grain from sigma_D = 0.01 to 0.08, the factor stays inside + [0.9945, 1.0], i.e. under 0.03 dB. Dropping it also makes this path agree with + spektrafilm_grain_usm in the .cl, which never had the renormalisation. */ void sf_multiplicative_unsharp_mask3(float *const buf, const int w, const int h, const float sigma, const float amount, float *const orig, float *const work) { if(sigma <= 0.0f || amount <= 0.0f) return; - const size_t npix = (size_t)w * h; - const size_t nn = npix * 3; - double sum_in[3] = { 0.0, 0.0, 0.0 }; - for(size_t i = 0; i < nn; i++) { orig[i] = buf[i]; sum_in[i % 3] += (double)buf[i]; } + const size_t nn = (size_t)w * h * 3; + dt_iop_image_copy(orig, buf, nn); sf_blur_plane3(buf, w, h, sigma, work); - const float eps = 1e-6f; - const float ratio_max = 4.0f; - for(size_t i = 0; i < nn; i++) - { - const float D = fmaxf(orig[i], 0.0f); - const float blur = fmaxf(buf[i], eps); - const float ratio = fmaxf(fminf(D / blur, ratio_max), 1.0f / ratio_max); - buf[i] = fmaxf(D * powf(ratio, amount), 0.0f); - } - double sum_out[3] = { 0.0, 0.0, 0.0 }; - for(size_t i = 0; i < nn; i++) sum_out[i % 3] += (double)buf[i]; - for(int c = 0; c < 3; c++) + const float eps = 1e-6f; + const float ratio_max = 4.0f; + for(size_t i = 0; i < nn; i++) { - if(sum_out[c] > 0.0 && sum_in[c] > 0.0) + const float D = fmaxf(orig[i], 0.0f); + const float blur = fmaxf(buf[i], eps); + const float ratio = fmaxf(fminf(D / blur, ratio_max), 1.0f / ratio_max); + buf[i] = fmaxf(D * powf(ratio, amount), 0.0f); + } +} + +/* Additive unsharp mask ([df] apply_unsharp_mask, the scanner's own sharpening + pass): out = D + amount * (D - blur(D)). Distinct from the multiplicative one + above, which is the grain-recovery pass in the density domain -- this runs on + the scanned RGB, can legitimately overshoot below zero at an edge, and is + left unclamped exactly as the reference leaves it. */ +void sf_unsharp_mask3(float *const buf, const int w, const int h, const float sigma, + const float amount, float *const orig, float *const work) +{ + if(sigma <= 0.0f || amount <= 0.0f) return; + const size_t nn = (size_t)w * h * 3; + dt_iop_image_copy(orig, buf, nn); + sf_blur_plane3(buf, w, h, sigma, work); + for(size_t i = 0; i < nn; i++) buf[i] = orig[i] + amount * (orig[i] - buf[i]); +} + +/* Viewing glare ([gl] add_glare): a faint veil of the viewing illuminant, drawn + as a lognormal field of mean `percent`/100 and relative standard deviation + `roughness`, blurred by `blur` pixels. + + The reference adds `glare_amount * illuminant_xyz` in XYZ before the output + matrix. That illuminant is normalized to Y = 1 and the matrix adapts it to the + output white, so it lands on RGB (1, 1, 1) exactly -- which is why this can be + a scalar added to all three channels after the matrix instead. It also lands + after the output gamut compression rather than before it; at the default + 0.03% the difference is far below a code value, and doing it here keeps the + whole spatial stage on one side of sf_sim_scan. + + Lognormal with linear-space mean m and std s: sigma2 = ln(1 + (s/m)^2), + mu = ln(m) - sigma2/2, so with s/m = roughness the shape parameter does not + depend on the amount at all. */ +void sf_glare(float *const rgb, const int w, const int h, const float percent, + const float roughness, const float blur, const int roi_x, const int roi_y, + float *const field) +{ + const float mean = percent * 0.01f; + if(mean <= 0.0f) return; + const float sigma2 = logf(1.0f + roughness * roughness); + const float s = sqrtf(sigma2), bias = -0.5f * sigma2; + + DT_OMP_FOR() + for(int y = 0; y < h; y++) + for(int x = 0; x < w; x++) { - const float scale = (float)(sum_in[c] / sum_out[c]); - for(size_t i = c; i < nn; i += 3) buf[i] *= scale; + const uint32_t seed = sf_pixel_seed((uint32_t)(x + roi_x), (uint32_t)(y + roi_y), 0x5eedu); + field[(size_t)y * w + x] = mean * expf(bias + s * sf_nrm(seed)); } + float *const trans = dt_alloc_align_float((size_t)w * h); + sf_blur_plane1(field, w, h, blur, NULL, trans); + dt_free_align(trans); + + const size_t npix = (size_t)w * h; + DT_OMP_FOR() + for(size_t i = 0; i < npix; i++) + { + const float g = field[i]; + rgb[i * 3 + 0] += g; + rgb[i * 3 + 1] += g; + rgb[i * 3 + 2] += g; } } @@ -431,8 +463,15 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ static const double tail_rat[3] = { 0.5360, 1.5236, 2.7684 }; /* stage 1 (scatter): s_amount is the (1-s)*raw + s*scattered blend weight, matching upstream's scatter_amount 1:1 (no extra curve). scl is the - shared core/tail spatial-scale multiplier. */ - const double s_amount = (double)scatter_amount; + shared core/tail spatial-scale multiplier. + + Clamped to [0, 1] because the blend is CONVEX: s is the fraction of photons + that scatter, so s = 1 (upstream's own default and maximum) already means + "all of them". Past 1 the weight on the unscattered term goes negative and + the stage stops being a blur and starts subtracting the sharp image -- + s = 2 gives 2*scattered - raw, an inverted ghost of the subject with a dark + halo around it, clipping to black wherever it drives raw below zero. */ + const double s_amount = fmin(fmax((double)scatter_amount, 0.0), 1.0); const double scl = fmax((double)scatter_scale, 1e-3); /* stage 2 (halation): per-channel strength at halation_amount==1.0, and the first-bounce radius, both per-film (sf_sim_halation_params()) since @@ -536,7 +575,7 @@ void sf_halation(float *const raw, const int w, const int h, const double pixel_ * E_out = (1 - p_s) * E_in + p_s * (K_s * E_in) * where the per-channel PSF K_s is a sum of radial exponentials grouped into * core / halo / bloom. Each exponential exp(-r/lambda)/(2*pi*lambda^2) has - * radial RMS lambda*sqrt(2); we approximate each as a Gaussian of that sigma so + * per-axis sigma lambda*sqrt(3); we approximate each as a Gaussian of that sigma so * the whole PSF becomes a weighted bank of Gaussian blurs (_blur_channel, this * file's own exact direct convolution), summed per channel. The strength->p_s * table, geometric lambda progressions, group weights and warmth @@ -695,7 +734,12 @@ int sf_diffusion_build_plan(int family, float strength, float halo_warmth, sf_di diffusion_filter_radial_profile()'s own "cfg base + halo_warmth". */ sf_diff_halo_warmth(hw, nh, fam->halo_warmth_base + (double)halo_warmth, hch); - const double L2 = 1.4142135623730951; /* exp(-r/lambda) ~ Gaussian sigma=lambda*sqrt(2) */ + /* Moment-matched Gaussian surrogate for one 2D isotropic exponential + exp(-r/lambda) / (2*pi*lambda^2). That kernel has E[r^2] = 6*lambda^2, and a + 2D Gaussian of per-axis sigma has E[r^2] = 2*sigma^2, so the second moments + match at sigma = lambda*sqrt(3). (The reference's own exponential surrogate + agrees: the SF_EXPTAIL_* mixture satisfies sum_k a_k * r_k^2 = 2.988 ~ 3.) */ + const double L2 = 1.7320508075688772; /* sqrt(3) */ int idx = 0; for(int k = 0; k < nc; k++) /* core: channel-independent */ { diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index 102b0b04462f..b033fd88a70b 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -17,12 +17,8 @@ */ #pragma once -#include -#include -#include #include #include -#include #ifndef SPEKTRA_INLINE #define SPEKTRA_INLINE static inline @@ -41,6 +37,18 @@ void sf_blur_plane3_fast(float *buf, int w, int h, float sigma, float *plane); dye-cloud sigma is often well under a pixel and still meaningfully softens the raw particle draw, matching upstream's plain `> 0` check. */ void sf_blur_plane1(float *buf, int w, int h, float sigma, float *plane, float *trans); +/* Additive unsharp mask on the scanned RGB ([df] apply_unsharp_mask): + out = D + amount * (D - blur(D)). `orig` and `work` are w*h*3 and w*h + scratch buffers supplied by the caller. */ +void sf_unsharp_mask3(float *buf, int w, int h, float sigma, float amount, + float *orig, float *work); +/* Viewing-glare veil ([gl] add_glare): adds a blurred lognormal field of mean + percent/100 (relative std `roughness`) to all three channels. `field` is a + w*h scratch buffer; roi_x/roi_y are absolute image coordinates so the veil + is stable under pan and zoom. */ +void sf_glare(float *rgb, int w, int h, float percent, float roughness, float blur, + int roi_x, int roi_y, float *field); + void sf_multiplicative_unsharp_mask3(float *buf, int w, int h, float sigma, float amount, float *orig, float *work); /* Two independently-controllable stages, matching upstream's HalationParams: @@ -80,416 +88,11 @@ typedef struct sf_diffusion_plan_t int sf_diffusion_build_plan(int family, float strength, float halo_warmth, sf_diffusion_plan_t *plan); -/* Whole-file reader for the bundle loader (bundle.json and the .cube LUTs are - small enough to slurp). Inside darktable the including .c maps these to glib - (g_file_get_contents / g_free); darktable poisons bare libc fopen, so no libc - fallback is emitted in a darktable translation unit. The standalone unit test - (-DSF_STANDALONE) gets a small stdio-based fallback. - - SF_READ_FILE(path, char **out_buf, size_t *out_len) -> 0 on success, the buffer - is NUL-terminated and owned by the caller, freed with SF_FREE_FILE. */ -#ifndef SF_READ_FILE -#ifdef SF_STANDALONE -#include -SPEKTRA_INLINE int sf_read_file_stdio(const char *path, char **out, size_t *len) -{ - FILE *f = fopen(path, "rb"); - if(!f) return -1; - fseek(f, 0, SEEK_END); - long sz = ftell(f); - fseek(f, 0, SEEK_SET); - if(sz < 0) { fclose(f); return -1; } - char *b = (char *)malloc((size_t)sz + 1); - if(!b) { fclose(f); return -1; } - if(fread(b, 1, (size_t)sz, f) != (size_t)sz) { free(b); fclose(f); return -1; } - b[sz] = 0; - fclose(f); - *out = b; - if(len) *len = (size_t)sz; - return 0; -} -#define SF_READ_FILE(path, out, len) sf_read_file_stdio((path), (out), (len)) -#define SF_FREE_FILE(buf) free(buf) -#else -#error "SF_READ_FILE must be defined (map to g_file_get_contents) before including spektra_core.h" -#endif -#endif - -/* Locale-independent ASCII float parse. darktable runs under the user locale - (e.g. de_DE uses ',' as decimal), but .cube / bundle.json always use '.'. - sscanf("%f")/strtod honour LC_NUMERIC, so we must not use them. The module - maps SF_STRTOD to g_ascii_strtod; standalone uses a small C-locale parser. */ -#ifndef SF_STRTOD -SPEKTRA_INLINE double sf_ascii_strtod(const char *s, char **end) -{ - while(*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++; - double sign = 1.0; - if(*s == '+') - s++; - else if(*s == '-') - { - sign = -1.0; - s++; - } - double val = 0.0; - int any = 0; - while(*s >= '0' && *s <= '9') - { - val = val * 10.0 + (*s - '0'); - s++; - any = 1; - } - if(*s == '.') - { - s++; - double f = 0.0, sc = 1.0; - while(*s >= '0' && *s <= '9') - { - f = f * 10.0 + (*s - '0'); - sc *= 10.0; - s++; - any = 1; - } - val += f / sc; - } - if(any && (*s == 'e' || *s == 'E')) - { - s++; - int es = 1, e = 0; - if(*s == '+') - s++; - else if(*s == '-') - { - es = -1; - s++; - } - while(*s >= '0' && *s <= '9') - { - e = e * 10 + (*s - '0'); - s++; - } - double m = 1.0; - for(int i = 0; i < e; i++) m *= 10.0; - val = es > 0 ? val * m : val / m; - } - if(end) *end = (char *)s; - return any ? sign * val : 0.0; -} -#define SF_STRTOD(s, end) sf_ascii_strtod((s), (end)) -#endif - SPEKTRA_INLINE float sf_clampf(float x, float lo, float hi) { return x < lo ? lo : (x > hi ? hi : x); } -/* ---------------- .cube + bundle ---------------- */ -typedef struct -{ - int n; - float *data; -} sf_cube_t; /* n^3 * 3, R fastest */ -typedef struct -{ - sf_cube_t film, print; - float d_min[3], d_max[3]; /* cmy_film wire */ - char name[256]; /* bundle dir name */ - int valid; - int is_positive; /* slide/reversal film: film cube has inverted density slope */ - int is_combined; /* 1-LUT (combined rgb_in->rgb_out) bundle: one cube in `film`, - no density split, no `print`. Used for B&W and any 1lut bake. */ - float input_gain; /* bundle.json input_exposure.gain: the cube was baked so that - film_pipeline(decode(coord) * gain). At runtime we sample at - coord = srgb_oetf(linear / input_gain). Default 1.0. */ -} sf_bundle_t; - -SPEKTRA_INLINE int sf_load_cube(const char *path, sf_cube_t *c) -{ - char *buf = NULL; - size_t len = 0; - if(SF_READ_FILE(path, &buf, &len) != 0 || !buf) - { -#ifdef SF_DIAG_LOG - SF_DIAG_LOG("[spektrafilm] read cube FAILED: %s\n", path); -#endif - return -1; - } - - c->n = 0; - c->data = NULL; - int idx = 0, cap = 0; - /* Walk the file buffer line by line (the .cube grammar is line-oriented): - header keywords (LUT_3D_SIZE, DOMAIN_*, TITLE) and one "r g b" triplet per - data line, with R varying fastest. */ - char *p = buf; - while(*p) - { - char *eol = p; - while(*eol && *eol != '\n') eol++; - const char hold = *eol; - *eol = 0; /* terminate this line for the parsers below */ - - char *s = p; - while(*s == ' ' || *s == '\t') s++; - if(*s == '#' || *s == '\r' || *s == 0) - { - /* comment or blank: skip */ - } - else if(!strncmp(s, "LUT_3D_SIZE", 11)) - { - c->n = atoi(s + 11); - cap = c->n * c->n * c->n * 3; - c->data = (float *)malloc(sizeof(float) * cap); - if(!c->data) - { - SF_FREE_FILE(buf); - return -1; - } - } - else if(!strncmp(s, "DOMAIN_", 7) || !strncmp(s, "TITLE", 5) || (*s >= 'A' && *s <= 'Z')) - { - /* other header keyword: skip */ - } - else - { - char *e1 = NULL, *e2 = NULL, *e3 = NULL; - const float r = (float)SF_STRTOD(s, &e1); - const float g = (float)SF_STRTOD(e1, &e2); - const float b = (float)SF_STRTOD(e2, &e3); - if(e1 != s && e2 != e1 && e3 != e2 && idx + 3 <= cap) - { - c->data[idx++] = r; - c->data[idx++] = g; - c->data[idx++] = b; - } - } - - if(hold == 0) break; - p = eol + 1; - } - SF_FREE_FILE(buf); - - if(c->n <= 0 || idx != c->n * c->n * c->n * 3) - { -#ifdef SF_DIAG_LOG - SF_DIAG_LOG("[spektrafilm] cube row/size mismatch n=%d got=%d expect=%d: %s\n", c->n, idx, - c->n * c->n * c->n * 3, path); -#endif - free(c->data); - c->data = NULL; - return -1; - } - return 0; -} -SPEKTRA_INLINE void sf_cube_free(sf_cube_t *c) -{ - free(c->data); - c->data = NULL; - c->n = 0; -} - -SPEKTRA_INLINE void sf_cube_sample(const sf_cube_t *c, const float in[3], float out[3]) -{ - const int n = c->n; - float fx = sf_clampf(in[0], 0, 1) * (n - 1), fy = sf_clampf(in[1], 0, 1) * (n - 1), - fz = sf_clampf(in[2], 0, 1) * (n - 1); - int x0 = (int)fx, y0 = (int)fy, z0 = (int)fz, x1 = x0 < n - 1 ? x0 + 1 : x0, - y1 = y0 < n - 1 ? y0 + 1 : y0, z1 = z0 < n - 1 ? z0 + 1 : z0; - float dx = fx - x0, dy = fy - y0, dz = fz - z0; -#define SFI(X, Y, Z) (((size_t)(Z) * n * n + (size_t)(Y) * n + (X)) * 3) - for(int ch = 0; ch < 3; ch++) - { - float a = c->data[SFI(x0, y0, z0) + ch] * (1 - dx) + c->data[SFI(x1, y0, z0) + ch] * dx; - float b = c->data[SFI(x0, y1, z0) + ch] * (1 - dx) + c->data[SFI(x1, y1, z0) + ch] * dx; - float cc = c->data[SFI(x0, y0, z1) + ch] * (1 - dx) + c->data[SFI(x1, y0, z1) + ch] * dx; - float d = c->data[SFI(x0, y1, z1) + ch] * (1 - dx) + c->data[SFI(x1, y1, z1) + ch] * dx; - float e = a * (1 - dy) + b * dy, g = cc * (1 - dy) + d * dy; - out[ch] = e * (1 - dz) + g * dz; - } -#undef SFI -} - -/* tiny JSON scrapes (sufficient for the fixed spektrafilm bundle.json schema) */ -SPEKTRA_INLINE int sf_scrape_float(const char *b, const char *k, float *out) -{ - /* find key k (e.g. "\"gain\"") then the number after the following ':' */ - const char *p = strstr(b, k); - if(!p) return -1; - p = strchr(p, ':'); - if(!p) return -1; - p++; - while(*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; - char *end = NULL; - double d = SF_STRTOD(p, &end); - if(end == p) return -1; - *out = (float)d; - return 0; -} - -SPEKTRA_INLINE int sf_scrape_vec3(const char *b, const char *k, float v[3]) -{ - const char *p = strstr(b, k); - if(!p) return -1; - p = strchr(p, '['); - if(!p) return -1; - p++; - for(int i = 0; i < 3; i++) - { - while(*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++; - char *end = NULL; - double d = SF_STRTOD(p, &end); - if(end == p) return -1; - v[i] = (float)d; - p = end; - } - return 0; -} -SPEKTRA_INLINE int sf_scrape_path(const char *buf, const char *role, char *out, int sz) -{ - const char *p = buf; - while((p = strstr(p, "\"role\""))) - { - const char *c = strchr(p, ':'), *q1 = c ? strchr(c, '"') : 0, - *q2 = q1 ? strchr(q1 + 1, '"') : 0; - if(!q2) - { - p += 5; - continue; - } - int len = (int)(q2 - q1 - 1); - if((int)strlen(role) == len && !strncmp(q1 + 1, role, len)) - { - const char *nr = strstr(q2, "\"role\""), *pa = strstr(q2, "\"path\""); - if(!pa || (nr && pa > nr)) - { - p = q2; - continue; - } - pa = strchr(pa, ':'); - pa = strchr(pa, '"'); - if(!pa) return -1; - pa++; - const char *e = strchr(pa, '"'); - if(!e || e - pa >= sz) return -1; - memcpy(out, pa, e - pa); - out[e - pa] = 0; - return 0; - } - p = q2; - } - return -1; -} -/* load a bundle dir (containing bundle.json + the two cubes) */ -SPEKTRA_INLINE int sf_load_bundle(const char *dir, sf_bundle_t *b) -{ - memset(b, 0, sizeof *b); - char jp[1024]; - snprintf(jp, sizeof jp, "%s/bundle.json", dir); - char *buf = NULL; - size_t sz = 0; - if(SF_READ_FILE(jp, &buf, &sz) != 0 || !buf || sz == 0) - { - SF_FREE_FILE(buf); - return -1; - } - /* input exposure gain (bundle.json input_exposure.gain). The cube maps - output(coord) = film_pipeline(decode(coord) * gain), so at runtime we sample - at coord = srgb_oetf(linear / gain). Default 1.0 when absent (older bundles - or stops_above_midgray=null). Scrape the "gain" key inside "input_exposure". */ - b->input_gain = 1.0f; - { - const char *ie = strstr(buf, "\"input_exposure\""); - if(ie) - { - float g = 1.0f; - if(!sf_scrape_float(ie, "\"gain\"", &g) && g > 1e-4f) b->input_gain = g; - } - } - /* 1-LUT (combined) bundle? It has a single lut with role "combined" and no - film/print density wire. Load that one cube into `film` and mark combined. */ - char cp[256] = {0}; - if(!sf_scrape_path(buf, "combined", cp, sizeof cp)) - { - SF_FREE_FILE(buf); - char full[2048]; - snprintf(full, sizeof full, "%s/%s", dir, cp); - if(sf_load_cube(full, &b->film)) return -1; - b->is_combined = 1; - b->valid = 1; - return 0; /* no density wire / print / positive-detection for combined */ - } - - int ok = - !sf_scrape_vec3(buf, "\"d_max\"", b->d_max) && !sf_scrape_vec3(buf, "\"d_min\"", b->d_min); - char fp[256] = {0}, pp[256] = {0}; - ok = ok && !sf_scrape_path(buf, "film", fp, sizeof fp) && - !sf_scrape_path(buf, "print", pp, sizeof pp); - SF_FREE_FILE(buf); - if(!ok) - { -#ifdef SF_DIAG_LOG - SF_DIAG_LOG("[spektrafilm] bundle.json parse failed (wire/paths) in %s\n", dir); -#endif - return -1; - } - char full[2048]; - snprintf(full, sizeof full, "%s/%s", dir, fp); - if(sf_load_cube(full, &b->film)) return -1; - snprintf(full, sizeof full, "%s/%s", dir, pp); - if(sf_load_cube(full, &b->print)) - { - sf_cube_free(&b->film); - return -1; - } - b->valid = 1; - - /* Detect positive (slide/reversal) film: sample the film cube at black and - white, convert to cmy_film density via the wire, and compare. Negative - films -> density rises with input; positive films -> density falls. This - needs no metadata (bundle.json omits film type) and no name matching. */ - { - float blk[3] = {0.f, 0.f, 0.f}, wht[3] = {1.f, 1.f, 1.f}, fo_b[3], fo_w[3]; - sf_cube_sample(&b->film, blk, fo_b); - sf_cube_sample(&b->film, wht, fo_w); - float d_b = 0.f, d_w = 0.f; - for(int c = 0; c < 3; c++) - { - d_b += b->d_min[c] + fo_b[c] * (b->d_max[c] - b->d_min[c]); - d_w += b->d_min[c] + fo_w[c] * (b->d_max[c] - b->d_min[c]); - } - b->is_positive = (d_w < d_b) ? 1 : 0; /* white darker than black => slide */ - } - return 0; -} -SPEKTRA_INLINE void sf_bundle_free(sf_bundle_t *b) -{ - sf_cube_free(&b->film); - sf_cube_free(&b->print); - b->valid = 0; -} - -SPEKTRA_INLINE void sf_to_density(const sf_bundle_t *b, const float v[3], float d[3]) -{ - for(int c = 0; c < 3; c++) d[c] = b->d_min[c] + v[c] * (b->d_max[c] - b->d_min[c]); -} -SPEKTRA_INLINE void sf_from_density(const sf_bundle_t *b, const float d[3], float v[3]) -{ - for(int c = 0; c < 3; c++) - v[c] = sf_clampf((d[c] - b->d_min[c]) / (b->d_max[c] - b->d_min[c]), 0, 1); -} - -/* ---------------- sRGB transfer (module is scene-linear; cubes are sRGB) ---------------- */ -SPEKTRA_INLINE float sf_srgb_oetf(float x) -{ - x = x < 0 ? 0 : x; - return x <= 0.0031308f ? 12.92f * x : 1.055f * powf(x, 1.0f / 2.4f) - 0.055f; -} -SPEKTRA_INLINE float sf_srgb_eotf(float x) -{ - x = sf_clampf(x, 0, 1); - return x <= 0.04045f ? x / 12.92f : powf((x + 0.055f) / 1.055f, 2.4f); -} - /* ---------------- grain (validated) ---------------- * * Grain must be random per pixel yet perfectly reproducible (stable under @@ -553,18 +156,6 @@ SPEKTRA_INLINE uint32_t sf_pixel_seed(uint32_t xi, uint32_t yi, uint32_t chan) return xi * 73856093u ^ yi * 19349663u ^ chan * 83492791u; } -/* Print-stage grading applied to the CMY film density before the print cube - (2lut path only). Mirrors the spektrafilm app's print controls, approximated on - the baked density rather than by re-running the paper model: - - print_exposure (stops): a uniform density shift (brighter print = less - density); dchange = -print_exposure * SF_PRINT_EV_TO_DENSITY. - - print_contrast: pivots density about a mid-grey Dp so slopes steepen/flatten. - - filtration_m / filtration_y: subtractive printing filters. Magenta rides the - green record, yellow the blue record; each adds a small per-channel density. - density[] is modified in place; d_ref is a representative mid density (mean of - the film's d_min/d_max) used as the contrast pivot. */ -#define SF_PRINT_EV_TO_DENSITY 0.30103f /* log10(2): one stop == 0.301 density */ - /* Maximum kernel half-width (taps = 2*radius+1) for sf_gauss_kernel_1d below. Caps cost for pathologically large sigma (very high film_format_mm combined with very low resolution); every physically-plausible sigma this @@ -573,60 +164,74 @@ SPEKTRA_INLINE uint32_t sf_pixel_seed(uint32_t xi, uint32_t yi, uint32_t chan) dispatch the identical kernel for a given sigma. */ #define SF_GAUSS_MAX_RADIUS 512 -/* Above this sigma, the recursive (Young-van Vliet IIR) approximation is - accurate enough to use as a fast path instead of the exact kernel below: - simulating its actual impulse response against the requested sigma shows - the effective-vs-requested ratio stabilizes at a stable 1.1799 for sigma - >= ~1.5px, correctable with the single constant factor below. Below that - threshold the ratio isn't flat (it drifts, and below ~0.5px flips to - UNDER-blurring), which is why the exact kernel exists at all -- so this - threshold routes only the large-radius blurs (halation's bounce - especially, where the exact kernel's O(radius) cost is worst) through the - O(1)-per-pixel fast path, recovering most of the performance the exact - kernel gave up without reintroducing the inaccuracy it fixed at small - sigma. Shared by spektra_core.c's CPU dispatch and spektrafilm.c's GPU - macros so both switch over at the same sigma. */ -#define SF_GAUSS_EXACT_MAX_SIGMA 2.0f -#define SF_GAUSS_SIGMA_CORRECTION 0.8475f /* 1 / 1.1799 */ - -/* Build a normalized, truncated 1D Gaussian kernel -- truncate=4 sigma, - * matching scipy.ndimage.gaussian_filter's own default (what the reference - * spektrafilm actually blurs with), so the blur is exact to kernel- - * truncation precision for any sigma. `kernel` must have room for +/* Sigma at which the direct kernel hands over to the recursive one. This is + the reference's own crossover (SMALL_SIGMA_MAX in fast_gaussian_filter.py), + and above it both sides now run the same Young-van Vliet filter, so a given + sigma produces the same blur here, on the GPU, and in the app. */ +#define SF_GAUSS_EXACT_MAX_SIGMA 3.0f + +/* Young-van Vliet order-3 recursive Gaussian coefficients (B, B1, B2, B3), + identical to the reference's _yvv_coeffs. Exported so the GPU host side can + build the same filter the CPU runs. */ +void sf_gauss_yvv_coeffs(float sigma, float out[4]); + +/* Build a normalized, truncated 1D Gaussian kernel. truncate = 3 sigma with + * radius = int(3*sigma + 0.5), matching the reference's own + * _gaussian_kernel_1d default rather than scipy's truncate = 4 -- the + * reference never calls scipy for this. `kernel` must have room for * 2*max_radius+1 taps; returns the radius actually used. Exported so both * the CPU convolution (spektra_core.c) and the GPU host-side weight upload * (spektrafilm.c's process_cl) build the identical kernel for a given sigma. */ int sf_gauss_kernel_1d(float sigma, float *kernel, int max_radius); -#define SF_FILTRATION_TO_DENSITY 0.30f /* full filtration slider == 0.30 density */ -SPEKTRA_INLINE void sf_apply_print_grading(float density[3], float d_ref, float print_exposure, - float print_contrast, float filtration_m, - float filtration_y) -{ - const float ev = -print_exposure * SF_PRINT_EV_TO_DENSITY; - for(int c = 0; c < 3; c++) - { - float v = density[c] + ev; /* print exposure */ - v = d_ref + (v - d_ref) * print_contrast; /* print contrast (pivot) */ - density[c] = v; +/* sf_poisson: one Poisson(lam) draw from a stateless seed. + + Below SF_POISSON_EXACT_MAX the draw is EXACT (Knuth's product-of-uniforms). + That threshold is not a quality/speed compromise, it is where the normal + approximation stops being safe: sf_nrm is bounded at +-sqrt(12) (Irwin-Hall + over four uniforms), so lam + sqrt(lam)*sf_nrm() can only go negative when + lam < 12. Above the threshold no clamp is ever needed and the approximation is + mean- and variance-exact; below it, clamping a normal at zero is exactly what + biased the old sampler upward in the shadows. Cost: the exact branch averages + lam+1 hashes (<= 13), the fast branch 4 -- against 8 for the two sf_nrm draws + this replaces. */ +#define SF_POISSON_EXACT_MAX 12.0f +SPEKTRA_INLINE float sf_poisson(float lam, uint32_t seed) +{ + if(lam <= 0.0f) return 0.0f; + if(lam < SF_POISSON_EXACT_MAX) + { + const float limit = expf(-lam); + float prod = 1.0f; + int k = 0; + do + { + prod *= sf_u01(seed + (uint32_t)k * 0x9e3779b9u); + k++; + } while(prod > limit && k < 64); + return (float)(k - 1); } - /* subtractive filters: M -> green channel (index 1), Y -> blue channel (index 2) */ - density[1] += filtration_m * SF_FILTRATION_TO_DENSITY; - density[2] += filtration_y * SF_FILTRATION_TO_DENSITY; + return lam + sqrtf(lam) * sf_nrm(seed); } +/* sf_layer_particle: draw the developed density of one emulsion layer. + + The reference model (layer_particle_model, grain.py) draws N_s ~ Poisson(lam) + sensitised grains and develops each with probability p, i.e. + Binomial(Poisson(lam), p). Poisson thinning makes that composition EXACTLY + Poisson(lam * p), so the two-stage draw collapses to a single Poisson and the + intermediate grain count -- along with the two clamps that went with it -- + disappears. + + The mean is then exactly lam*p * od * sat = density, and the variance exactly + p * dmax^2 * sat / npart = D (Dmax - u D) / N, the target grain.py derives. */ SPEKTRA_INLINE float sf_layer_particle(float density, float dmax, float npart, float unif, uint32_t seed) { - float p = sf_clampf(density / dmax, 1e-6f, 1 - 1e-6f), od = dmax / npart, - sat = 1.f - p * unif * (1 - 1e-6f), lam = npart / sat; - float seeds = lam + sqrtf(fmaxf(lam, 0)) * sf_nrm(seed * 0x9e3779b9u + 1u); - if(seeds < 0) seeds = 0; - float mean = seeds * p, var = seeds * p * (1 - p), - g = mean + sqrtf(fmaxf(var, 0)) * sf_nrm(seed * 0x85ebca6bU + 7u); - if(g < 0) g = 0; - if(g > seeds) g = seeds; - return g * od * sat; + const float p = sf_clampf(density / dmax, 1e-6f, 1 - 1e-6f); + const float od = dmax / npart; + const float sat = 1.f - p * unif * (1 - 1e-6f); + return sf_poisson(npart * p / sat, seed * 0x9e3779b9u + 1u) * od * sat; } /* SF_GRAIN_REF_UM: the fixed reference scale (spektrafilm's own pixel_size_um=10) the particle model is generated at, independent of the @@ -637,126 +242,3 @@ SPEKTRA_INLINE float sf_layer_particle(float density, float dmax, float npart, f resolution — see the grain blur in spektrafilm.c/.cl and _max_halo_sigma's ROI padding, all of which must agree. */ #define SF_GRAIN_REF_UM 10.0f -/* grain on one CMY-density pixel; strength scales particle count effect via amount */ -SPEKTRA_INLINE void sf_grain_px(float dens[3], float pixel_um, float amount, float size, - uint32_t xi, uint32_t yi) -{ - const float dmin[3] = {0.03f, 0.03f, 0.03f}, dmaxc[3] = {2.2f, 2.2f, 2.2f}; - const float pscale[3] = {1.6f, 1.6f, 3.2f}, unif[3] = {0.97f, 0.99f, 0.97f}; - const int nsub = 1; - /* Grain is rendered at a FIXED reference scale (like spektrafilm's - pixel_size_um=10), NOT the live pipe pixel_um, so grain character stays - constant across zoom. The size slider scales this reference: larger size => - larger effective grain pixel => fewer particles per pixel => coarser grain. - size=1.0 reproduces the app's default look. pixel_um is unused for grain - (still used by halation). */ - const float parea = 0.2f; - const float ref_um = SF_GRAIN_REF_UM / fmaxf(size, 0.05f); /* size up => coarser grain */ - float pix = ref_um * ref_um; - (void)pixel_um; - for(int c = 0; c < 3; c++) - { - float npart = pix / (parea * pscale[c]), dmax = dmaxc[c] + dmin[c]; - float din = dens[c] + dmin[c], acc = 0; - for(int sl = 0; sl < nsub; sl++) - acc += sf_layer_particle( - din, dmax, npart, unif[c], - sf_pixel_seed(xi, yi, (uint32_t)(c + sl * 10))); - acc /= nsub; - acc -= dmin[c]; - dens[c] = dens[c] + (acc - dens[c]) * amount; /* amount=1 -> full spektrafilm grain */ - } -} - -/* apply halation+scatter to a w*h*3 LINEAR plane in place (amount scales both passes) */ -/* Compute the grain DELTA (grained density - clean density) for one pixel into - out_delta[3]. Generation matches the validated per-pixel particle model; the - visible film STRUCTURE comes from blurring this delta buffer afterwards (as - spektrafilm blurs its grain by grain.blur). Generated at a fine fixed scale so - the subsequent blur produces organic clumps rather than 1px speckle. */ -/* dmax_c: the emulsion's actual per-channel maximum density (base-subtracted). - Using a too-small value saturates the particle model in dense areas (slide - shadows) and produces a channel-dependent -- i.e. coloured -- bias. - dmin_c/rms_c/unif_c: the stock's own catalogue grain characteristics - (film_render_defaults[stock].grain in the pack — rms_granularity, - uniformity, density_min), so e.g. Portra 400 and Tri-X no longer share one - hardcoded grain signature. Callers without per-film data may pass the - SF_GRAIN_LEGACY_* arrays below to reproduce the earlier fixed look. */ -/* SF_GRAIN_REF_UM (defined above, with sf_grain_px) is reused here for the - same fine-generation reference scale. */ -SPEKTRA_INLINE void sf_grain_delta_dmax(const float dens[3], float amount, float out_delta[3], - uint32_t xi, uint32_t yi, int mono, float pixel_um, - const float dmax_c[3], const float dmin_c[3], - const float rms_c[3], const float unif_c[3]) -{ - const float dmin[3] = { dmin_c[0], dmin_c[1], dmin_c[2] }; - const float dmaxc[3] = { fmaxf(dmax_c[0], 1e-3f), fmaxf(dmax_c[1], 1e-3f), - fmaxf(dmax_c[2], 1e-3f) }; - /* Latest spektrafilm grain model (study a90): per-channel particle area from - catalogue RMS-granularity (sigma_48 through a 48um aperture, ISO 6328): - a_grain = (rms/1000)^2 * A48 / (D_ref (Dmax - u D_ref)), D_ref = 1 + d_min. - N = pixel_area / a_grain. pixel_area is the REAL physical pixel area - (film_format_mm-derived pixel_um, squared) -- matching upstream's - n_particles_per_pixel = pixel_size_um**2 * ... exactly, so raw grain - density/variance (not just visible clump size, which the separate blur - step still sets) tracks the real resolution and film format. rms/unif - come from the film stock's own catalogue data (see header comment) - rather than one shared constant. */ - const float rms[3] = { rms_c[0], rms_c[1], rms_c[2] }; - const float unif[3] = { unif_c[0], unif_c[1], unif_c[2] }; - const float A48 = 3.14159265f * 24.0f * 24.0f; - const float pix = pixel_um * pixel_um; - /* mono (B&W / combined): the three channels carry the same value, so grain must - be ACHROMATIC — one grain realisation applied identically to all channels. - Per-channel independent grain (the colour path) would otherwise paint colour - speckle onto a grey image. Use channel 1's parameters and the mean density. */ - if(mono) - { - const float dm = (dens[0] + dens[1] + dens[2]) / 3.0f; - const float dmax = dmaxc[1] + dmin[1]; - const float d_ref = 1.0f + dmin[1]; - const float sig = rms[1] / 1000.0f; - const float denom = fmaxf(d_ref * (dmax - unif[1] * d_ref), 1e-6f); - const float a_grain = sig * sig * A48 / denom; - const float npart = pix / fmaxf(a_grain, 1e-4f); - const float din = dm + dmin[1]; - float g = sf_layer_particle(din, dmax, npart, unif[1], - sf_pixel_seed(xi, yi, 0u)) - dmin[1]; - const float d = (g - dm) * amount; - out_delta[0] = out_delta[1] = out_delta[2] = d; /* identical -> grey grain */ - return; - } - for(int c = 0; c < 3; c++) - { - const float dmax = dmaxc[c] + dmin[c]; - const float d_ref = 1.0f + dmin[c]; - const float sig = rms[c] / 1000.0f; - const float denom = fmaxf(d_ref * (dmax - unif[c] * d_ref), 1e-6f); - const float a_grain = sig * sig * A48 / denom; - const float npart = pix / fmaxf(a_grain, 1e-4f); - const float din = dens[c] + dmin[c]; - float g = sf_layer_particle(din, dmax, npart, unif[c], - sf_pixel_seed(xi, yi, (uint32_t)c)); - g -= dmin[c]; - out_delta[c] = (g - dens[c]) * amount; /* delta to be blurred then added */ - } -} - -/* Fallback catalogue values (spektrafilm's original single fixed profile) for - callers with no per-film pack data (see sf_pack_film_grain / sf_sim_film_grain3 - for the real per-stock values). */ -#define SF_GRAIN_LEGACY_DMAX { 2.2f, 2.2f, 2.2f } -#define SF_GRAIN_LEGACY_DMIN { 0.03f, 0.03f, 0.03f } -#define SF_GRAIN_LEGACY_RMS { 6.0f, 8.0f, 10.0f } -#define SF_GRAIN_LEGACY_UNIFORMITY { 0.97f, 0.97f, 0.97f } - -SPEKTRA_INLINE void sf_grain_delta(const float dens[3], float amount, float out_delta[3], - uint32_t xi, uint32_t yi, int mono, float pixel_um) -{ - const float legacy_dmax[3] = SF_GRAIN_LEGACY_DMAX; - const float legacy_dmin[3] = SF_GRAIN_LEGACY_DMIN; - const float legacy_rms[3] = SF_GRAIN_LEGACY_RMS; - const float legacy_unif[3] = SF_GRAIN_LEGACY_UNIFORMITY; - sf_grain_delta_dmax(dens, amount, out_delta, xi, yi, mono, pixel_um, legacy_dmax, legacy_dmin, - legacy_rms, legacy_unif); -} diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 00c906de7b5a..9b24d2f0e8d5 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -41,14 +41,6 @@ #include #include -/* spektra_core.h's bundle-loader half needs file-IO/locale helpers; darktable - poisons bare libc fopen, so map them to glib here (this .c does not itself - read bundles, but the shared header must compile in this translation - unit) -- same mapping spektra_core.c itself uses. */ -#define SF_READ_FILE(path, out, len) \ - (g_file_get_contents((path), (out), (len), NULL) ? 0 : -1) -#define SF_FREE_FILE(buf) g_free(buf) -#define SF_STRTOD(s, end) g_ascii_strtod((s), (end)) #include "spektra_core.h" #include @@ -229,7 +221,9 @@ struct sf_pack_t typedef struct sf_curves_model_t { int n_layers; + int sept; /* [dc] model_type == "sept_norm_cdfs" (skewed septic CDF) */ double centers[3][8], amplitudes[3][8], sigmas[3][8]; + double alphas[3][8]; /* per-layer skew; all zero for norm_cdfs */ } sf_curves_model_t; struct sf_profile_t @@ -842,7 +836,24 @@ sf_profile_t *sf_profile_load(const char *path, char **errmsg) { const int nl = channel_major ? inner_len : outer_len; p->curves_model.n_layers = nl; - double c[24], a[24], s[24]; + /* [dc] model_type selects the per-layer sigmoid. Anything other than the + two known families would be silently rendered as a Gaussian fit, so say + so rather than shipping wrong curves quietly. */ + char *model_type = json_dup_string(m, "model_type"); + if(model_type && strcmp(model_type, "sept_norm_cdfs") == 0) + p->curves_model.sept = 1; + else if(model_type && strcmp(model_type, "norm_cdfs") != 0) + g_warning("spektra_sim: profile %s has unknown density_curves_model.model_type " + "'%s'; rendering it as norm_cdfs", path, model_type); + g_free(model_type); + double c[24], a[24], s[24], al[24]; + /* alphas is optional (null or absent for every norm_cdfs profile) and + follows whatever orientation centers used, so it goes through the same + outer/inner lengths and the same idx below. */ + const gboolean has_alphas = json_object_has_member(m, "alphas") + && !JSON_NODE_HOLDS_NULL(json_object_get_member(m, "alphas")); + if(!has_alphas || !json_read_dmatrix(m, "alphas", al, outer_len, inner_len)) + memset(al, 0, sizeof(al)); if(json_read_dmatrix(m, "centers", c, outer_len, inner_len) && json_read_dmatrix(m, "amplitudes", a, outer_len, inner_len) && json_read_dmatrix(m, "sigmas", s, outer_len, inner_len)) @@ -854,6 +865,7 @@ sf_profile_t *sf_profile_load(const char *path, char **errmsg) p->curves_model.centers[ch][l] = c[idx]; p->curves_model.amplitudes[ch][l] = a[idx]; p->curves_model.sigmas[ch][l] = s[idx]; + p->curves_model.alphas[ch][l] = al[idx]; } } else @@ -1406,20 +1418,49 @@ static inline float interp_curve_uniform_f(float x, float gammac, float le0, static inline double norm_cdf(double z) { return 0.5 * (1.0 + erf(z * M_SQRT1_2)); } +/* [dc] density_curves.py's septic-polynomial CDF: an order-7 Hermite + * smoothstep on a support of SF_SEPT_K sigmas, with a median-preserving skew + * warp v = u + alpha*u*(1-u)*(2u-1)^2. The warp vanishes at u = 0.5, so the + * 0.5 crossing stays on the layer centre for every alpha, and alpha == 0 + * reproduces the symmetric Gaussian fit to within the minimax fit error. */ +#define SF_SEPT_K 5.8013 +static double sept_cdf(double z, double alpha) +{ + double u = z / SF_SEPT_K + 0.5; + u = u < 0.0 ? 0.0 : (u > 1.0 ? 1.0 : u); + double v = u; + if(alpha != 0.0) + { + const double t = 2.0 * u - 1.0; + v = u + alpha * u * (1.0 - u) * t * t; + v = v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); + } + const double v2 = v * v; + return (v2 * v2) * (35.0 + v * (-84.0 + v * (70.0 - 20.0 * v))); +} + +/* One emulsion layer's sigmoid, dispatched by the profile's model_type + * ([dc] _layer_cdf_values). `alpha` is ignored by the Gaussian family. */ +static double layer_cdf(double z, int sept, double alpha) +{ + return sept ? sept_cdf(z, alpha) : norm_cdf(z); +} + /* evaluate one channel of the cdfs model over the log-exposure grid. * signed z: negated for positive profiles ([mc] _signed_z) */ static void eval_cdfs_channel(double *out, const double *le, int nle, const double *centers, - const double *amps, const double *sigmas, int n_layers, - int positive) + const double *amps, const double *sigmas, const double *alphas, + int sept, int n_layers, int positive) { for(int i = 0; i < nle; i++) out[i] = 0.0; for(int l = 0; l < n_layers; l++) { + const double alpha = alphas ? alphas[l] : 0.0; for(int i = 0; i < nle; i++) { double z = (le[i] - centers[l]) / sigmas[l]; if(positive) z = -z; - out[i] += amps[l] * norm_cdf(z); + out[i] += amps[l] * layer_cdf(z, sept, alpha); } } } @@ -1519,10 +1560,11 @@ static void _sf_build_grain_layers(sf_sim_t *s, const sf_profile_t *film, { for(int c = 0; c < 3; c++) { - double centers[8], amps[8], sigmas[8]; + double centers[8], amps[8], sigmas[8], alphas[8]; memcpy(centers, m->centers[c], sizeof(double) * nl); memcpy(amps, m->amplitudes[c], sizeof(double) * nl); memcpy(sigmas, m->sigmas[c], sizeof(double) * nl); + memcpy(alphas, m->alphas[c], sizeof(double) * nl); for(int l = 0; l < nl; l++) { double mx = -1e300; @@ -1530,7 +1572,7 @@ static void _sf_build_grain_layers(sf_sim_t *s, const sf_profile_t *film, { double z = (film->log_exposure[i] - centers[l]) / sigmas[l]; if(positive) z = -z; - const double v = amps[l] * norm_cdf(z); + const double v = amps[l] * layer_cdf(z, m->sept, alphas[l]); layer_curve[i][l][c] = v; if(v > mx) mx = v; } @@ -1610,9 +1652,9 @@ static double _sf_gumbel_matched_cdf(double z) * per-layer building block for developer exhaustion (matches * morph_curves.py's _layer_cdf; `z` already sign-flipped for positive * stocks by the caller, matching eval_cdfs_channel's own convention). */ -static double _sf_layer_cdf_mixed(double z, double gumbel_mix) +static double _sf_layer_cdf_mixed(double z, int sept, double alpha, double gumbel_mix) { - double cdf = norm_cdf(z); + double cdf = layer_cdf(z, sept, alpha); if(gumbel_mix > 0.0) cdf = (1.0 - gumbel_mix) * cdf + gumbel_mix * _sf_gumbel_matched_cdf(z); return cdf; } @@ -1623,15 +1665,16 @@ static double _sf_layer_cdf_mixed(double z, double gumbel_mix) * needs (matches morph_curves.py's _evaluate_channel_density, at a single * point since that's all the solver needs). */ static double _sf_channel_density_at(double x, const double *centers, const double *amps, - const double *sigmas, int n_layers, - const double *gumbel_mix, int positive) + const double *sigmas, const double *alphas, int sept, + int n_layers, const double *gumbel_mix, int positive) { double total = 0.0; for(int l = 0; l < n_layers; l++) { double z = (x - centers[l]) / sigmas[l]; if(positive) z = -z; - total += amps[l] * _sf_layer_cdf_mixed(z, gumbel_mix ? gumbel_mix[l] : 0.0); + total += amps[l] * _sf_layer_cdf_mixed(z, sept, alphas ? alphas[l] : 0.0, + gumbel_mix ? gumbel_mix[l] : 0.0); } return total; } @@ -1647,21 +1690,22 @@ static double _sf_channel_density_at(double x, const double *centers, const doub * channel per sim build, never per pixel. Returns 0 if gumbel_mix is zero * or no sign change is found (matching upstream's own fallback-to-zero). */ static double _sf_developer_exhaustion_offset(const double *centers, const double *amps, - const double *sigmas, int n_layers, + const double *sigmas, const double *alphas, + int sept, int n_layers, double gumbel_mix, int positive) { if(gumbel_mix <= 0.0) return 0.0; - const double target_d0 = _sf_channel_density_at(0.0, centers, amps, sigmas, n_layers, NULL, positive); + const double target_d0 = _sf_channel_density_at(0.0, centers, amps, sigmas, alphas, sept, n_layers, NULL, positive); double gmix[SF_GRAIN_MAX_SUBLAYERS]; for(int l = 0; l < n_layers; l++) gmix[l] = gumbel_mix; double shifted[SF_GRAIN_MAX_SUBLAYERS]; double lo = -0.25, hi = 0.25; for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + lo; - double r_lo = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + double r_lo = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0; for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + hi; - double r_hi = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + double r_hi = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0; if(r_lo == 0.0) return lo; if(r_hi == 0.0) return hi; @@ -1672,9 +1716,9 @@ static double _sf_developer_exhaustion_offset(const double *centers, const doubl lo *= 2.0; hi *= 2.0; for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + lo; - r_lo = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + r_lo = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0; for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + hi; - r_hi = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + r_hi = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0; if(r_lo == 0.0) return lo; if(r_hi == 0.0) return hi; } @@ -1685,7 +1729,7 @@ static double _sf_developer_exhaustion_offset(const double *centers, const doubl { mid = 0.5 * (lo + hi); for(int l = 0; l < n_layers; l++) shifted[l] = centers[l] + mid; - const double r_mid = _sf_channel_density_at(0.0, shifted, amps, sigmas, n_layers, gmix, positive) - target_d0; + const double r_mid = _sf_channel_density_at(0.0, shifted, amps, sigmas, alphas, sept, n_layers, gmix, positive) - target_d0; if(r_mid == 0.0 || (hi - lo) < 1e-12) break; if((r_lo < 0.0) == (r_mid < 0.0)) { lo = mid; r_lo = r_mid; } else hi = mid; @@ -1699,11 +1743,12 @@ static double _sf_developer_exhaustion_offset(const double *centers, const doubl * centers/sigmas move, plus the per-layer gumbel_mix output (uniformly * `exhaustion` across every layer, matching upstream). Layer speed order * (fast/mid/slow) is by ascending center, same as _sf_build_grain_layers' - * own convention elsewhere in this file, and the per-channel skew term - * (alpha) upstream's fit can carry isn't modeled here since this file's - * curves_model has no alpha field to begin with -- eval_cdfs_channel - * already made that same simplification for the print side. */ -static void _sf_morph_channel(const double centers_in[], const double sigmas_in[], int nl, + * own convention elsewhere in this file. The per-layer skew (alpha) is not + * touched by the morph -- upstream's _morph_channel_params carries it through + * unchanged too -- but it is passed on so the exhaustion solver evaluates the + * same sigmoid family the curves are built from. */ +static void _sf_morph_channel(const double centers_in[], const double sigmas_in[], + const double alphas_in[], int sept, int nl, int positive, double gamma, double gamma_fast, double gamma_slow, double exhaustion, const double amps[], double centers_out[], double sigmas_out[], double gumbel_mix_out[]) @@ -1739,7 +1784,8 @@ static void _sf_morph_channel(const double centers_in[], const double sigmas_in[ if(exhaustion > 0.0) { - const double offset = _sf_developer_exhaustion_offset(centers_out, amps, sigmas_out, nl, + const double offset = _sf_developer_exhaustion_offset(centers_out, amps, sigmas_out, + alphas_in, sept, nl, exhaustion, positive); for(int i = 0; i < nl; i++) centers_out[i] += offset; } @@ -1762,15 +1808,17 @@ static void build_film_curves(double (*curves)[3], double (*layers_out)[SF_GRAIN for(int c = 0; c < 3; c++) { - double centers[SF_GRAIN_MAX_SUBLAYERS], amps[SF_GRAIN_MAX_SUBLAYERS], sigmas[SF_GRAIN_MAX_SUBLAYERS]; + double centers[SF_GRAIN_MAX_SUBLAYERS], amps[SF_GRAIN_MAX_SUBLAYERS], + sigmas[SF_GRAIN_MAX_SUBLAYERS], alphas[SF_GRAIN_MAX_SUBLAYERS]; memcpy(centers, m->centers[c], sizeof(double) * nl); memcpy(amps, m->amplitudes[c], sizeof(double) * nl); memcpy(sigmas, m->sigmas[c], sizeof(double) * nl); + memcpy(alphas, m->alphas[c], sizeof(double) * nl); double mcenters[SF_GRAIN_MAX_SUBLAYERS], msigmas[SF_GRAIN_MAX_SUBLAYERS], gmix[SF_GRAIN_MAX_SUBLAYERS]; if(p->film_morph_active) - _sf_morph_channel(centers, sigmas, nl, positive, p->film_morph_gamma, + _sf_morph_channel(centers, sigmas, alphas, m->sept, nl, positive, p->film_morph_gamma, p->film_morph_gamma_fast, p->film_morph_gamma_slow, p->film_morph_developer_exhaustion, amps, mcenters, msigmas, gmix); else @@ -1787,7 +1835,7 @@ static void build_film_curves(double (*curves)[3], double (*layers_out)[SF_GRAIN { double z = (film->log_exposure[i] - mcenters[l]) / msigmas[l]; if(positive) z = -z; - const double v = amps[l] * _sf_layer_cdf_mixed(z, gmix[l]); + const double v = amps[l] * _sf_layer_cdf_mixed(z, m->sept, alphas[l], gmix[l]); layers_out[i][l][c] = v; total += v; } @@ -1805,10 +1853,11 @@ static void build_print_curves(double (*curves)[3], const sf_profile_t *print, for(int c = 0; c < 3; c++) { - double centers[8], amps[8], sigmas[8]; + double centers[8], amps[8], sigmas[8], alphas[8]; memcpy(centers, m->centers[c], sizeof(centers)); memcpy(amps, m->amplitudes[c], sizeof(amps)); memcpy(sigmas, m->sigmas[c], sizeof(sigmas)); + memcpy(alphas, m->alphas[c], sizeof(alphas)); if(p->morph_active && nl > 0) { @@ -1839,7 +1888,8 @@ static void build_print_curves(double (*curves)[3], const sf_profile_t *print, } double column[SF_NLE]; - eval_cdfs_channel(column, print->log_exposure, SF_NLE, centers, amps, sigmas, nl, positive); + eval_cdfs_channel(column, print->log_exposure, SF_NLE, centers, amps, sigmas, alphas, + m->sept, nl, positive); for(int i = 0; i < SF_NLE; i++) curves[i][c] = column[i]; } } @@ -3091,25 +3141,6 @@ void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, size_t n } } -void sf_sim_process(const sf_sim_t *sim, const float *rgb_in, float *rgb_out, size_t npix, - int nch_in, int nch_out) -{ - float *tmp = malloc(npix * 3 * sizeof(float)); - float *corr = sim->couplers_active ? malloc(npix * 3 * sizeof(float)) : NULL; - sf_sim_expose(sim, rgb_in, tmp, npix, nch_in, 3); - sf_sim_lograw(tmp, npix, 3); - if(corr) sf_sim_develop_corr(sim, tmp, corr, npix, 3); - sf_sim_develop(sim, tmp, corr, tmp, npix, 3, 3); - if(sim->has_print) - { - sf_sim_print_expose(sim, tmp, tmp, npix, 3, 3); - sf_sim_print_develop(sim, tmp, tmp, npix, 3, 3); - } - sf_sim_scan(sim, tmp, rgb_out, npix, 3, nch_out); - free(corr); - free(tmp); -} - /* ------------------------------------------------------------------------ */ /* GPU export: float copies of the per-pixel tables */ /* ------------------------------------------------------------------------ */ @@ -3288,14 +3319,9 @@ void sf_sim_halation_params(const sf_sim_t *sim, double strength[3], double *fir if(first_sigma_um) *first_sigma_um = sim ? sim->halation_sigma_um : SF_HALATION_SIGMA_DEFAULT_UM; } -void sf_sim_film_dmax3(const sf_sim_t *sim, float dmax[3]) -{ - for(int c = 0; c < 3; c++) dmax[c] = sim ? (float)sim->film_dmax[c] : 2.2f; -} - void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3]) { - /* matches SF_GRAIN_LEGACY_* in spektra_core.h */ + /* spektrafilm's original single fixed profile, for a sim-less caller */ static const float legacy_rms[3] = { 6.0f, 8.0f, 10.0f }; static const float legacy_unif[3] = { 0.97f, 0.97f, 0.97f }; static const float legacy_dmin[3] = { 0.03f, 0.03f, 0.03f }; @@ -3308,7 +3334,7 @@ void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], the film's real absolute D-max when dmin is the SAME floor that produced dmax_c. An independently-sourced density_min (e.g. from a separate curve-fit pass upstream) breaks that identity and silently - biases the particle count — see sf_grain_delta_dmax. */ + biases the particle count. */ dmin[c] = sim ? (float)sim->film_dmin[c] : legacy_dmin[c]; } } @@ -3382,9 +3408,9 @@ static float _sf_grain_curve_sample(const float *arr, int n, int stride, float p return arr[i0 * stride] * (1.0f - frac) + arr[(i0 + 1) * stride] * frac; } -/* Multi-sublayer grain delta: like sf_grain_delta_dmax (spektra_core.h), but - * for a film whose own fitted density-curve model has more than one - * emulsion sub-layer (sf_sim_grain_layers().n > 1). Each sub-layer is drawn +/* Multi-sublayer grain delta, for a film whose fitted density-curve model has + * more than one emulsion sub-layer (sf_sim_grain_layers().n > 1; n == 1 is the + * trivial case of the same model). Each sub-layer is drawn * independently at its own precomputed dmax/npart, using its own density * recovered by inverse-interpolating the self-consistent summed sub-layer * curve at the exposure position the already-computed total density diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index c1f293228392..9b192ba78324 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -192,7 +192,6 @@ sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *sim); void sf_sim_gpu_free(sf_sim_gpu_t *g); int sf_sim_film_bw(const sf_sim_t *sim); -void sf_sim_film_dmax3(const sf_sim_t *sim, float dmax[3]); /* per-film grain catalogue data (rms_granularity, uniformity, density_min); falls back to the legacy fixed constants (SF_GRAIN_LEGACY_* in spektra_core.h) when sim is NULL or the pack predates per-film grain. */ @@ -217,9 +216,8 @@ typedef struct sf_grain_layers_t } sf_grain_layers_t; void sf_sim_grain_layers(const sf_sim_t *sim, sf_grain_layers_t *out); /* Multi-sublayer grain delta (see _sf_build_grain_layers / sf_grain_layers_t - * above): only call this when sf_sim_grain_layers()'s n > 1 -- for n==1, - * calling the existing single-layer sf_grain_delta_dmax() (spektra_core.h) - * directly is both simpler and avoids a redundant lookup round-trip. + * above). A single-layer curve fit is the trivial n == 1 case of the same + * model, so every stock goes through here. * npart_scale rescales the build-time-precomputed layer_npart (built at the * fixed SF_GRAIN_REF_UM reference scale, since it depends on curve/coupler * state baked in at sf_sim_build time, not just resolution) up to the live @@ -404,9 +402,6 @@ void sf_sim_print_develop(const sf_sim_t *sim, const float *lograw, float *cmy, void sf_sim_scan(const sf_sim_t *sim, const float *cmy, float *rgb_out, size_t npix, int nch_in, int nch_out); -/* convenience: the full deterministic chain without spatial effects */ -void sf_sim_process(const sf_sim_t *sim, const float *rgb_in, float *rgb_out, - size_t npix, int nch_in, int nch_out); /* pre-compression OkLab lightness a single RGB triple would land at, using * boost_override in place of the sim's own out_luminance_boost -- see diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index d1069dec80f3..f6b9dd9c146f 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -79,15 +79,10 @@ #include #define SPEKTRA_INLINE static inline -#define SF_READ_FILE(path, out, len) \ - (g_file_get_contents((path), (out), (len), NULL) ? 0 : -1) -#define SF_FREE_FILE(buf) g_free(buf) -#define SF_DIAG_LOG(...) dt_print(DT_DEBUG_DEV, __VA_ARGS__) -#define SF_STRTOD(s, end) g_ascii_strtod((s), (end)) #include "common/spektra_core.h" #include "common/spektra_sim.h" -DT_MODULE_INTROSPECTION(8, dt_iop_spektrafilm_params_t) +DT_MODULE_INTROSPECTION(9, dt_iop_spektrafilm_params_t) /* Spatial-scale constants, micrometres on film unless noted (see the LUT module for the full rationale; these are shared with modify_roi_in() and @@ -97,6 +92,10 @@ DT_MODULE_INTROSPECTION(8, dt_iop_spektrafilm_params_t) /* widest stage-1 scatter component: max(sc_tail)*max(tail_rat) from spektra_core.c's sf_halation() = 9.7 * 2.7684 um, rounded up */ #define SF_SCATTER_TAIL_MAX_UM 27.0f +/* [gl] GlareParams.roughness / .blur -- the reference exposes these but leaves + them at these values for every profile, so only the amount gets a slider. */ +#define SF_GLARE_ROUGHNESS 0.7f +#define SF_GLARE_BLUR_PX 0.5f #define SF_GRAIN_BLUR_FACTOR 0.8f #define SF_GRAIN_SIZE_MIN 0.05f /* Upstream's GrainParams.blur_dye_clouds_um (params_schema.py): a SECOND, @@ -123,7 +122,6 @@ DT_MODULE_INTROSPECTION(8, dt_iop_spektrafilm_params_t) * multiplicative quantity in this model to begin with. */ #define SF_PUSH_PULL_GAMMA_PER_STOP 1.15f #define SF_HALO_SIGMAS 4.0f -#define SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM 950.0f /* DIR coupler inhibitor diffusion; spektrafilm params_schema dir_couplers.diffusion_size_um default (a plain gaussian in the reference) */ @@ -165,7 +163,7 @@ typedef struct dt_iop_spektrafilm_params_t gboolean scan_film; // $DEFAULT: FALSE $DESCRIPTION: "scan the film (skip print)" dt_iop_spektrafilm_quality_t quality; // $DEFAULT: DT_SPEKTRAFILM_Q_STANDARD $DESCRIPTION: "quality" gboolean halation_on; // $DEFAULT: TRUE $DESCRIPTION: "enable halation" - float scatter_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter amount" + float scatter_amount; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter amount" float scatter_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter size" float halation_amount; // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "halation strength" float halation_scale; // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "halation size" @@ -194,6 +192,10 @@ typedef struct dt_iop_spektrafilm_params_t float film_gamma_factor_slow; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "slow layer gamma" float film_developer_exhaustion; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "developer exhaustion" float push_pull_stops; // $MIN: -4.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "push/pull" + float scan_blur; // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "scanner blur" + float scan_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpness" + float scan_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpen strength" + float glare_percent; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.03 $DESCRIPTION: "viewing glare" } dt_iop_spektrafilm_params_t; /* one discovered profile: stock (= file base name), display name, stage */ @@ -221,6 +223,7 @@ typedef struct dt_iop_spektrafilm_gui_data_t GtkWidget *filter_m, *filter_y, *couplers_amount; GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift; GtkWidget *grain_on, *grain_amount, *grain_size; + GtkWidget *scan_blur, *scan_usm_sigma, *scan_usm_amount, *glare_percent; GtkWidget *grain_usm_sigma, *grain_usm_amount; GtkWidget *halation_on, *scatter_amount, *scatter_scale, *halation_amount, *halation_scale; GtkWidget *boost_ev, *boost_range, *protect_ev; @@ -324,8 +327,10 @@ typedef struct dt_iop_spektrafilm_global_data_t int kernel_grain_gen_raw_sl, kernel_grain_accumulate_1c, kernel_grain_finalize_channel, kernel_grain_add, kernel_grain_usm; int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough; + int kernel_crop_out, kernel_scan_usm, kernel_glare_gen, kernel_glare_add; int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply; int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c; + int kernel_yvv_row_4c, kernel_yvv_col_4c, kernel_yvv_row_1c, kernel_yvv_col_1c; int kernel_max_partials, kernel_max_reduce, kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix; } dt_iop_spektrafilm_global_data_t; @@ -356,10 +361,18 @@ void init_global(dt_iop_module_so_t *self) gd->kernel_print_expose = dt_opencl_create_kernel(program, "spektrafilm_print_expose"); gd->kernel_print_develop = dt_opencl_create_kernel(program, "spektrafilm_print_develop"); gd->kernel_scan = dt_opencl_create_kernel(program, "spektrafilm_scan"); + gd->kernel_crop_out = dt_opencl_create_kernel(program, "spektrafilm_crop_out"); + gd->kernel_scan_usm = dt_opencl_create_kernel(program, "spektrafilm_scan_usm"); + gd->kernel_glare_gen = dt_opencl_create_kernel(program, "spektrafilm_glare_gen"); + gd->kernel_glare_add = dt_opencl_create_kernel(program, "spektrafilm_glare_add"); gd->kernel_passthrough = dt_opencl_create_kernel(program, "spektrafilm_passthrough"); gd->kernel_scatter_combine = dt_opencl_create_kernel(program, "spektrafilm_scatter_combine"); gd->kernel_accum = dt_opencl_create_kernel(program, "spektrafilm_accum"); gd->kernel_channel_extract = dt_opencl_create_kernel(program, "spektrafilm_channel_extract"); + gd->kernel_yvv_row_4c = dt_opencl_create_kernel(program, "spektrafilm_yvv_row_4c"); + gd->kernel_yvv_col_4c = dt_opencl_create_kernel(program, "spektrafilm_yvv_col_4c"); + gd->kernel_yvv_row_1c = dt_opencl_create_kernel(program, "spektrafilm_yvv_row_1c"); + gd->kernel_yvv_col_1c = dt_opencl_create_kernel(program, "spektrafilm_yvv_col_1c"); gd->kernel_gauss_row_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_4c"); gd->kernel_gauss_col_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_4c"); gd->kernel_gauss_row_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_1c"); @@ -390,10 +403,18 @@ void cleanup_global(dt_iop_module_so_t *self) dt_opencl_free_kernel(gd->kernel_print_expose); dt_opencl_free_kernel(gd->kernel_print_develop); dt_opencl_free_kernel(gd->kernel_scan); + dt_opencl_free_kernel(gd->kernel_crop_out); + dt_opencl_free_kernel(gd->kernel_scan_usm); + dt_opencl_free_kernel(gd->kernel_glare_gen); + dt_opencl_free_kernel(gd->kernel_glare_add); dt_opencl_free_kernel(gd->kernel_passthrough); dt_opencl_free_kernel(gd->kernel_scatter_combine); dt_opencl_free_kernel(gd->kernel_accum); dt_opencl_free_kernel(gd->kernel_channel_extract); + dt_opencl_free_kernel(gd->kernel_yvv_row_4c); + dt_opencl_free_kernel(gd->kernel_yvv_col_4c); + dt_opencl_free_kernel(gd->kernel_yvv_row_1c); + dt_opencl_free_kernel(gd->kernel_yvv_col_1c); dt_opencl_free_kernel(gd->kernel_gauss_row_4c); dt_opencl_free_kernel(gd->kernel_gauss_col_4c); dt_opencl_free_kernel(gd->kernel_gauss_row_1c); @@ -741,6 +762,12 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int float film_developer_exhaustion; } dt_iop_spektrafilm_params_v7_t; + typedef struct dt_iop_spektrafilm_params_v8_t + { + dt_iop_spektrafilm_params_v7_t v7; + float push_pull_stops; + } dt_iop_spektrafilm_params_v8_t; + if(old_version == 1) { const dt_iop_spektrafilm_params_v1_t *o = (dt_iop_spektrafilm_params_v1_t *)old_params; @@ -1097,10 +1124,78 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->film_gamma_factor_slow = o->film_gamma_factor_slow; n->film_developer_exhaustion = o->film_developer_exhaustion; n->push_pull_stops = 0.0f; + /* v9 fields: neutral, so a v7 preset keeps rendering as it did */ + n->scan_blur = 0.0f; + n->scan_usm_sigma = 0.7f; + n->scan_usm_amount = 0.0f; + n->glare_percent = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 8; + *new_version = 9; + return 0; + } + if(old_version == 8) + { + /* v9 adds the scanner stage (blur, unsharp mask) and the viewing-glare veil + ([sc]/[gl] in the reference: ScannerParams.lens_blur / unsharp_mask and + GlareParams). All three default to OFF here rather than to the reference's + own defaults, so an existing edit renders exactly as it did before. */ + const dt_iop_spektrafilm_params_v8_t *o = (dt_iop_spektrafilm_params_v8_t *)old_params; + dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); + + n->film_hash = o->v7.film_hash; + n->paper_hash = o->v7.paper_hash; + n->exposure_ev = o->v7.exposure_ev; + n->print_exposure_ev = o->v7.print_exposure_ev; + n->print_auto_exposure = o->v7.print_auto_exposure; + n->print_contrast = o->v7.print_contrast; + n->filter_m = o->v7.filter_m; + n->filter_y = o->v7.filter_y; + n->couplers_amount = o->v7.couplers_amount; + n->preflash_exposure = o->v7.preflash_exposure; + n->preflash_m_shift = o->v7.preflash_m_shift; + n->preflash_y_shift = o->v7.preflash_y_shift; + n->scan_film = o->v7.scan_film; + n->quality = o->v7.quality; + n->halation_on = o->v7.halation_on; + n->scatter_amount = o->v7.scatter_amount; + n->scatter_scale = o->v7.scatter_scale; + n->halation_amount = o->v7.halation_amount; + n->halation_scale = o->v7.halation_scale; + n->boost_ev = o->v7.boost_ev; + n->boost_range = o->v7.boost_range; + n->protect_ev = o->v7.protect_ev; + n->diffusion_on = o->v7.diffusion_on; + n->diffusion_filter_family = o->v7.diffusion_filter_family; + n->diffusion_strength = o->v7.diffusion_strength; + n->diffusion_scale = o->v7.diffusion_scale; + n->diffusion_warmth = o->v7.diffusion_warmth; + n->print_diffusion_on = o->v7.print_diffusion_on; + n->print_diffusion_filter_family = o->v7.print_diffusion_filter_family; + n->print_diffusion_strength = o->v7.print_diffusion_strength; + n->print_diffusion_scale = o->v7.print_diffusion_scale; + n->print_diffusion_warmth = o->v7.print_diffusion_warmth; + n->grain_on = o->v7.grain_on; + n->grain_amount = o->v7.grain_amount; + n->grain_size = o->v7.grain_size; + n->film_format_mm = o->v7.film_format_mm; + n->output_luminance_boost = o->v7.output_luminance_boost; + n->grain_usm_sigma = o->v7.grain_usm_sigma; + n->grain_usm_amount = o->v7.grain_usm_amount; + n->film_gamma_factor = o->v7.film_gamma_factor; + n->film_gamma_factor_fast = o->v7.film_gamma_factor_fast; + n->film_gamma_factor_slow = o->v7.film_gamma_factor_slow; + n->film_developer_exhaustion = o->v7.film_developer_exhaustion; + n->push_pull_stops = o->push_pull_stops; + n->scan_blur = 0.0f; + n->scan_usm_sigma = 0.7f; + n->scan_usm_amount = 0.0f; + n->glare_percent = 0.0f; + + *new_params = n; + *new_params_size = sizeof(dt_iop_spektrafilm_params_t); + *new_version = 9; return 0; } return 1; @@ -1524,6 +1619,21 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, /* ROI / tiling: expand the input by the spatial-effect halo */ /* ---------------------------------------------------------------------- */ +/* Widest Gaussian sigma (micrometres on film) one diffusion-filter stage will + dispatch, or 0 when the stage is off / a no-op. Built from the same + sf_diffusion_build_plan() the CPU and GPU paths run, so the ROI padding can + never drift from the bank that is actually convolved. */ +static float _diffusion_pad_sigma_um(const gboolean on, const int family, const float strength, + const float warmth, const float scale) +{ + if(!on) return 0.0f; + sf_diffusion_plan_t plan; + if(!sf_diffusion_build_plan(family, strength, warmth, &plan) || plan.p_s <= 0.0f) return 0.0f; + float smax = 0.0f; + for(int j = 0; j < plan.n; j++) smax = fmaxf(smax, plan.sigma_um[j]); + return smax * fmaxf(scale, 1e-3f); +} + static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_um) { const float inv_um = 1.0f / fmaxf(pixel_um, 1e-3f); @@ -1542,16 +1652,21 @@ static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_u ? SF_SCATTER_TAIL_MAX_UM * SF_HALATION_PSF_SIGMAS * scat_scale * inv_um : 0.0f; /* The widest of film-stage and print-stage diffusion determines the ROI - padding — both must fit in the expanded tile. Both stages use the same - bloom constant; only the user's scale slider differs between them. */ - const float diff_film = p->diffusion_on - ? SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM * 1.41421356f * p->diffusion_scale * inv_um - : 0.0f; - const float diff_print = p->print_diffusion_on - ? SF_DIFFUSION_BLOOM_LAMBDA_MAX_UM * 1.41421356f - * p->print_diffusion_scale * inv_um - : 0.0f; - const float diff = fmaxf(diff_film, diff_print); + padding — both must fit in the expanded tile. Take the widest component of + the actual Gaussian bank each stage will dispatch rather than a single + constant: the bloom scale differs by 2.6x across the four families (BPM + 380*2.5 um vs cinebloom 1000*2.5 um), so one constant either under-pads the + wide families or over-pads the narrow ones. */ + const float diff = fmaxf(_diffusion_pad_sigma_um(p->diffusion_on, + (int)p->diffusion_filter_family, + p->diffusion_strength, p->diffusion_warmth, + p->diffusion_scale), + _diffusion_pad_sigma_um(p->print_diffusion_on, + (int)p->print_diffusion_filter_family, + p->print_diffusion_strength, + p->print_diffusion_warmth, + p->print_diffusion_scale)) + * inv_um; const float grain = (p->grain_on && p->grain_amount > 0.0f) ? SF_GRAIN_BLUR_FACTOR * SF_GRAIN_REF_UM * fmaxf(p->grain_size, SF_GRAIN_SIZE_MIN) * inv_um @@ -1563,7 +1678,10 @@ static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_u ? fmaxf((float)SF_COUPLER_BLUR_UM, (float)(SF_EXPTAIL_R2 * 200.0)) * inv_um : 0.0f; - return fmaxf(fmaxf(fmaxf(hal, scat), diff), fmaxf(grain, coupler)); + /* scanner stage: already in pixels, so it does not go through inv_um */ + const float scan = fmaxf(p->scan_blur, + (p->scan_usm_amount > 0.0f) ? p->scan_usm_sigma : 0.0f); + return fmaxf(fmaxf(fmaxf(hal, scat), fmaxf(diff, scan)), fmaxf(grain, coupler)); } void modify_roi_in(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, @@ -1657,6 +1775,11 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c const float full_long_edge = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f); const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge; + /* Fixed pixel radii (grain clumps, the two unsharp masks, the glare veil) were + validated at export resolution; darktable's preview pipe renders the same + image smaller, where the same nominal radius covers much more real scene + detail. Shrink them there, but never grow them past 1:1. */ + const float preview_scale = fminf(roi_in->scale, 1.0f); float *plane = dt_alloc_align_float(npix * 3); /* raw / lograw / cmy, in place */ float *corr = dt_alloc_align_float(npix * 3); /* DIR coupler correction field */ @@ -1749,8 +1872,7 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c /* sf_grain_delta_ml's layer_npart is precomputed at sf_sim_build time against the fixed SF_GRAIN_REF_UM reference scale (it depends on curve/coupler state baked in at build time, not just resolution); - rescale it live to the real pixe_um here, matching what - sf_grain_delta_dmax now does directly for the single-layer case. */ + rescale it live to the real pixel_um here. */ const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM); /* SF_GRAIN_BLUR_FACTOR/SF_GRAIN_DYE_BLUR_UM/grain_usm_sigma are fixed pixel radii, validated against upstream at whatever single @@ -1768,7 +1890,6 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c (npart_scale above) is NOT touched by this -- it's correctly resolution-dependent via pixel_um already, confirmed against the reference at multiple different resolutions. */ - const float preview_scale = fminf(roi_in->scale, 1.0f); float grms[3], gunif[3], gdmin[3]; sf_sim_film_grain3(sim, grms, gunif, gdmin); /* per-film catalogue grain (rms-granularity, uniformity, density @@ -1777,10 +1898,8 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c sf_grain_layers_t layers; sf_sim_grain_layers(sim, &layers); /* n==1 for a single-layer curve fit is already valid data (see the - function's own comment): unified - through this mechanism for every - stock rather than branching to the - separate sf_grain_delta_dmax path. */ + function's own comment): every stock + goes through this one mechanism. */ const int nsub = layers.n; /* Upstream's per-sub-layer dye-cloud blur (layer_particle_model's @@ -1879,24 +1998,16 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c const float sigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) * preview_scale; sf_blur_plane3(gbuf, w, h, sigma, scratch); - /* Centre grain delta: multi-sublayer model has positive DC bias (~0.07) - that renorm amplifies proportionally to sigma, making image brighter - at higher LOD. Remove bias before scaling. - IMPORTANT: this must be a per-channel mean, not one pooled scalar - across R+G+B -- the per-channel bias differs (channel-dependent - particle counts/scale in the multi-sublayer model), so a single - pooled mean leaves each channel's own bias minus the pooled average - as an uncorrected residual, i.e. a color cast wherever grain is - visually significant. */ - { - double gsum[3] = { 0.0, 0.0, 0.0 }; - for(size_t kk = 0; kk < npix; kk++) - for(int c = 0; c < 3; c++) gsum[c] += (double)gbuf[kk * 3 + c]; - float gmean[3]; - for(int c = 0; c < 3; c++) gmean[c] = (float)(gsum[c] / (double)npix); - for(size_t kk = 0; kk < npix; kk++) - for(int c = 0; c < 3; c++) gbuf[kk * 3 + c] -= gmean[c]; - } + /* No DC-centring pass here any more. The positive bias it removed came from + sf_layer_particle clamping a normal approximation at zero; the sampler now + draws a single exact/unbiased Poisson (see spektra_core.h), so the delta + has zero mean by construction. That also removes an image-wide reduction + from a function that only ever sees one ROI or tile -- the old per-channel + mean depended on how the pixelpipe cut the image up, so preview, export + and each tile of a tiled export centred by different amounts. The old + correction could not have been right in any case: the bias was a function + of density (worst in the shadows, at low particle counts), so subtracting + a single scalar left a density-dependent residual behind. */ /* No variance-restoration renorm here -- upstream's own grain finalization (_finalize_grain in grain.py) has none either; it just blurs and lets the natural contrast reduction stand, matching real @@ -1928,6 +2039,19 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c OkLCh gamut compression. Write RGBA + carried alpha, then crop. */ sf_sim_scan(sim, plane, plane, npix, 3, 3); + /* 6b) scanner optics + viewing glare, on the scanned RGB over the full padded + ROI so the crop below never sees an edge artifact ([sc] ScannerParams + lens_blur / unsharp_mask, [gl] add_glare -- the reference skips glare + when the film itself is scanned rather than printed). */ + if(d->p.scan_blur > 0.0f) + sf_blur_plane3(plane, w, h, d->p.scan_blur * preview_scale, scratch); + if(d->p.scan_usm_sigma > 0.0f && d->p.scan_usm_amount > 0.0f) + sf_unsharp_mask3(plane, w, h, d->p.scan_usm_sigma * preview_scale, d->p.scan_usm_amount, + corr, scratch); + if(!d->p.scan_film && d->p.glare_percent > 0.0f) + sf_glare(plane, w, h, d->p.glare_percent, SF_GLARE_ROUGHNESS, + SF_GLARE_BLUR_PX * preview_scale, roi_in->x, roi_in->y, scratch); + #ifdef _OPENMP #pragma omp parallel for default(none) shared(plane) firstprivate(out, in, w, ow, oh, ox, oy) \ schedule(static) @@ -1950,6 +2074,29 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c } #ifdef HAVE_OPENCL +/* Separable Young-van Vliet pass on the device, the GPU half of + sf_gauss_yvv_coeffs / _sf_gauss_iir_1d. `tmp` is a scratch buffer of the same + shape as `buf`. This replaces dt_gaussian_mean_blur_cl, which was being fed + sigma * SF_GAUSS_SIGMA_CORRECTION -- a factor measured for the CPU's old + Deriche recursion and meaningless for darktable's iterated-box blur, so the + two paths were producing different halo widths for the same sigma. */ +static cl_int _sf_yvv_blur_cl(const int devid, dt_iop_spektrafilm_global_data_t *gd, + cl_mem buf, cl_mem tmp, const int w, const int h, + const float sigma, const int ch) +{ + float b[4]; + sf_gauss_yvv_coeffs(sigma, b); + const int krow = (ch == 4) ? gd->kernel_yvv_row_4c : gd->kernel_yvv_row_1c; + const int kcol = (ch == 4) ? gd->kernel_yvv_col_4c : gd->kernel_yvv_col_1c; + cl_int e = dt_opencl_enqueue_kernel_2d_args(devid, krow, h, 1, CLARG(buf), CLARG(tmp), + CLARG(w), CLARG(h), CLARG(b[0]), CLARG(b[1]), + CLARG(b[2]), CLARG(b[3])); + if(e != CL_SUCCESS) return e; + return dt_opencl_enqueue_kernel_2d_args(devid, kcol, w, 1, CLARG(tmp), CLARG(buf), + CLARG(w), CLARG(h), CLARG(b[0]), CLARG(b[1]), + CLARG(b[2]), CLARG(b[3])); +} + /* GPU path: mirrors process(). Per-pixel stages run as kernels on the validated float tables from sf_sim_gpu_export() (POCL-checked to ~1e-6 vs the CPU engine); the Gaussian blurs (diffusion bank, halation bounces, @@ -1995,6 +2142,9 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ const float full_long_edge = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f); const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge; + /* see the matching comment in process(): fixed pixel radii shrink with the + preview pipe's reduced resolution, but never grow past 1:1 */ + const float preview_scale = fminf(roi_in->scale, 1.0f); /* ---- table uploads (read-only buffers) -------------------------------- */ /* packed matrix block: layout must match the SF_M_* offsets in the .cl */ @@ -2099,7 +2249,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_CL_STEP(label); \ } while(0) /* Same as SF_GAUSS_BLUR4, but above SF_GAUSS_EXACT_MAX_SIGMA falls back to - darktable's fast recursive blur (corrected -- see SF_GAUSS_SIGMA_CORRECTION) + the shared Young-van Vliet recursion (_sf_yvv_blur_cl above) instead of the exact row/col kernels: for callers with no downstream dependency on the exact kernel's shape (unlike grain's SF_GAUSS_BLUR4 above, which stays on the exact path unconditionally -- the fast @@ -2111,7 +2261,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ if(err == CL_SUCCESS) \ { \ if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \ - err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 4, (_sg) * SF_GAUSS_SIGMA_CORRECTION); \ + err = _sf_yvv_blur_cl(devid, gd, (buf), gtmp4, w, h, (_sg), 4); \ else \ { \ float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ @@ -2143,7 +2293,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ { \ err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \ if(err == CL_SUCCESS) \ - err = dt_gaussian_mean_blur_cl(devid, (dst), w, h, 4, (_sg) * SF_GAUSS_SIGMA_CORRECTION); \ + err = _sf_yvv_blur_cl(devid, gd, (dst), gtmp4, w, h, (_sg), 4); \ } \ else \ { \ @@ -2170,7 +2320,7 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ if(err == CL_SUCCESS) \ { \ if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \ - err = dt_gaussian_mean_blur_cl(devid, (buf), w, h, 1, (_sg) * SF_GAUSS_SIGMA_CORRECTION); \ + err = _sf_yvv_blur_cl(devid, gd, (buf), gtmp1, w, h, (_sg), 1); \ else \ { \ float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \ @@ -2303,7 +2453,8 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ /* (1-s)*raw + s*scattered, matching sf_halation()'s CPU blend; `plane` doubles as both the pre-scatter `raw` input and the `out` write target -- safe since this is a purely per-pixel elementwise op. */ - const float s_amount = d->p.scatter_amount; + /* convex blend weight -- see sf_halation() for why it cannot exceed 1 */ + const float s_amount = CLAMP(d->p.scatter_amount, 0.0f, 1.0f); err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scatter_combine, w, h, CLARG(plane), CLARG(tmpa), CLARG(acc), CLARG(plane), CLARG(w), CLARG(h), CLARG(s_amount), CLARG(ws_r), CLARG(ws_g), @@ -2412,7 +2563,6 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ in past 100% doesn't grow radii beyond what was validated. Does NOT apply to npart_scale below, which is correctly resolution- dependent via pixel_um already. */ - const float preview_scale = fminf(roi_in->scale, 1.0f); /* Unified through the multi-sublayer table for every stock (nsub can be 1) rather than branching to a separate single-layer kernel -- see the matching comment in process()'s CPU path for why that's valid: the @@ -2521,37 +2671,12 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ const float gsigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) * preview_scale; SF_GAUSS_BLUR4(tmpa, gsigma, "grain blur"); - /* Centre grain delta (matches process()'s CPU-side fix exactly -- the - multi-sublayer particle generator has a small positive DC bias; this - correction only ever existed on the CPU path before, so anyone using - OpenCL never got it). Reading the whole buffer back host-side is the - simplest way to get a full-image reduction; this runs once per grain - stage, not per pixel. Must be a per-channel mean, not one pooled - scalar across R+G+B -- see the matching comment in process() for why - a pooled mean leaves a per-channel residual (a color cast) - uncorrected. No variance-restoration renorm -- see the matching - comment on the spektrafilm_grain_add kernel. */ - float gmean[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; - if(err == CL_SUCCESS) - { - float *const tmpa_host = dt_alloc_align_float(npix * 4); - if(tmpa_host) - { - err = dt_opencl_read_buffer_from_device(devid, tmpa_host, tmpa, 0, npix * f * 4, TRUE); - if(err == CL_SUCCESS) - { - double gsum[3] = { 0.0, 0.0, 0.0 }; - for(size_t kk = 0; kk < npix; kk++) - for(int c = 0; c < 3; c++) gsum[c] += (double)tmpa_host[kk * 4 + c]; - for(int c = 0; c < 3; c++) gmean[c] = (float)(gsum[c] / (double)npix); - } - dt_free_align(tmpa_host); - } - else - err = CL_MEM_OBJECT_ALLOCATION_FAILURE; - } + /* The DC-centring reduction is gone along with its CPU counterpart: the + Poisson sampler is unbiased, so there is nothing to centre. That also + retires the full device->host readback it needed, which stalled the queue + once per grain stage. */ err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2), - CLARG(tmpa), CLARG(w), CLARG(h), CLARG(gmean)); + CLARG(tmpa), CLARG(w), CLARG(h)); SF_CL_STEP("grain add"); if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f) { @@ -2613,10 +2738,10 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ SF_CL_STEP("print_develop"); } - /* ---- 6) scan: crop the roi_out window straight into dev_out ------------- */ + /* ---- 6) scan over the full padded ROI into `plane` (free since print) ---- */ err = dt_opencl_enqueue_kernel_2d_args( - devid, gd->kernel_scan, ow, oh, CLARG(plane2), CLARG(dev_in), CLARG(dev_out), CLARG(w), - CLARG(ow), CLARG(oh), CLARG(ox), CLARG(oy), CLARG(sl_cl), CLARG(sx_cl), CLARG(sy_cl), + devid, gd->kernel_scan, w, h, CLARG(plane2), CLARG(plane), CLARG(w), CLARG(h), + CLARG(sl_cl), CLARG(sx_cl), CLARG(sy_cl), CLARG(sz_cl), CLARG(sn_cl), CLARG(sm_cl), CLARG(steps), CLARG(g->scan_lo[0]), CLARG(g->scan_lo[1]), CLARG(g->scan_lo[2]), CLARG(g->scan_hi[0]), CLARG(g->scan_hi[1]), CLARG(g->scan_hi[2]), CLARG(mats_cl), CLARG(cm_cl), CLARG(g->cmax_nl), CLARG(g->cmax_nh), @@ -2624,6 +2749,43 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(g->scan_bw_q)); SF_CL_STEP("scan"); + /* ---- 6b) scanner optics + viewing glare (mirrors process()) -------------- */ + if(d->p.scan_blur > 0.0f) + { + SF_GAUSS_BLUR4(plane, d->p.scan_blur * preview_scale, "scanner blur"); + } + if(d->p.scan_usm_sigma > 0.0f && d->p.scan_usm_amount > 0.0f) + { + err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, acc, 0, 0, npix * f * 4); + if(err != CL_SUCCESS) goto cleanup; + SF_GAUSS_BLUR4_FAST(plane, d->p.scan_usm_sigma * preview_scale, "scanner USM blur"); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scan_usm, w, h, CLARG(plane), + CLARG(acc), CLARG(w), CLARG(h), + CLARG(d->p.scan_usm_amount)); + SF_CL_STEP("scanner USM"); + } + if(!d->p.scan_film && d->p.glare_percent > 0.0f) + { + const float gmean = d->p.glare_percent * 0.01f; + const float sigma2 = logf(1.0f + SF_GLARE_ROUGHNESS * SF_GLARE_ROUGHNESS); + const float gs = sqrtf(sigma2), gbias = -0.5f * sigma2; + const int roi_x = roi_in->x, roi_y = roi_in->y; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_glare_gen, w, h, CLARG(tmpa), + CLARG(w), CLARG(h), CLARG(roi_x), CLARG(roi_y), + CLARG(gmean), CLARG(gs), CLARG(gbias)); + SF_CL_STEP("glare gen"); + SF_GAUSS_BLUR4(tmpa, SF_GLARE_BLUR_PX * preview_scale, "glare blur"); + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_glare_add, w, h, CLARG(plane), + CLARG(tmpa), CLARG(w), CLARG(h)); + SF_CL_STEP("glare add"); + } + + /* ---- 7) crop the roi_out window into dev_out ----------------------------- */ + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_crop_out, ow, oh, CLARG(plane), + CLARG(dev_in), CLARG(dev_out), CLARG(w), CLARG(ow), + CLARG(oh), CLARG(ox), CLARG(oy)); + SF_CL_STEP("crop out"); + cleanup: dt_opencl_release_mem_object(mats_cl); dt_opencl_release_mem_object(tc_cl); @@ -3002,6 +3164,39 @@ void gui_update(dt_iop_module_t *self) _update_print_sensitivity(self); } +/* Boost that puts the probe lightness of `rgb` at `target_L`. + * + * This used to be a closed form: L was taken to scale as boost^(1/3), so one + * probe plus new = current * (target/measured)^3 was supposedly exact. That + * identity holds only while the scan stage is a pure scale on XYZ. It is not: + * sf_sim_scan applies the scanner black/white-point correction AFTER the boost, + * and that correction is affine and clipped -- the delivered luminance is + * clamp(m * boost * Y + q, 0, 1). So L goes as boost^a with a < 1/3, the update + * degenerates to new = current^(1 - 3a) * const, and repeated picks crept + * toward the right value instead of landing on it. (Negatives are unaffected, + * scan_bw_on is only set for scan-film mode with positive stock -- which is + * exactly where this control gets used.) + * + * Solve against the real transfer instead. boost -> L is monotone + * non-decreasing and one probe is a single pixel through the sim, so a + * geometric bisection over the slider's own range is both exact and free: + * 20 steps pin the answer to ~2e-6 of the range. The result no longer depends + * on the current slider value at all, so picking twice gives the same number. */ +static float _solve_boost_for_lightness(const sf_sim_t *sim, const float rgb[3], + const float target_L) +{ + float lo = 0.5f, hi = 4.0f; /* the slider's own $MIN / $MAX */ + if(sf_sim_probe_lightness(sim, rgb, lo) >= target_L) return lo; + if(sf_sim_probe_lightness(sim, rgb, hi) <= target_L) return hi; + for(int i = 0; i < 20; i++) + { + const float mid = sqrtf(lo * hi); + if(sf_sim_probe_lightness(sim, rgb, mid) < target_L) lo = mid; + else hi = mid; + } + return sqrtf(lo * hi); +} + void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpipe_t *pipe) { dt_iop_spektrafilm_gui_data_t *g = self->gui_data; @@ -3041,9 +3236,6 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpi above in gui_init). */ const float rgb_max[3] = { self->picked_color_max[0], self->picked_color_max[1], self->picked_color_max[2] }; - const float current_boost = fmaxf(d_tmp.p.output_luminance_boost, 0.5f); - const float measured_L = sf_sim_probe_lightness(sim, rgb_max, current_boost); - /* Target: land well past the compressor's knee threshold (SF_OUT_LIGHT_T = 0.7 in spektra_sim.c), close to but not at its asymptotic limit (1.0). 0.80 (only 0.10 above the threshold) turned out too @@ -3053,13 +3245,9 @@ void color_picker_apply(dt_iop_module_t *self, GtkWidget *picker, dt_dev_pixelpi threshold. 0.90 still left some headroom on further testing; 0.95 (only 0.05 short of the limit) uses close to the full available range. The knee handles any input gracefully by design, so there's no - hard-clipping risk in pushing this close to it. No iteration needed: - L scales as boost^(1/3) exactly (see sf_sim_probe_lightness), so a - single probe plus this closed-form solve is enough. */ + hard-clipping risk in pushing this close to it. */ const float target_L = 0.95f; - float new_boost = current_boost; - if(measured_L > 1e-4f) new_boost = current_boost * powf(target_L / measured_L, 3.0f); - new_boost = CLAMP(new_boost, 0.5f, 4.0f); + const float new_boost = _solve_boost_for_lightness(sim, rgb_max, target_L); if(d_tmp.gpu) sf_sim_gpu_free(d_tmp.gpu); if(d_tmp.sim) sf_sim_free(d_tmp.sim); @@ -3378,6 +3566,30 @@ void gui_init(dt_iop_module_t *self) _("print diffusion halo warmth: >0 warm outer halo, <0 cool" " (added on top of the selected filter's own warmth bias)")); + /* ---- scanner tab ---- */ + self->widget = dt_ui_notebook_page(g->notebook, N_("scanner"), NULL); + + g->scan_blur = dt_bauhaus_slider_from_params(self, "scan_blur"); + gtk_widget_set_tooltip_text(g->scan_blur, + _("scanner lens softness, in pixels (0 = off)")); + + g->scan_usm_sigma = dt_bauhaus_slider_from_params(self, "scan_usm_sigma"); + gtk_widget_set_tooltip_text(g->scan_usm_sigma, + _("scanner sharpening radius, in pixels")); + + g->scan_usm_amount = dt_bauhaus_slider_from_params(self, "scan_usm_amount"); + gtk_widget_set_tooltip_text(g->scan_usm_amount, + _("scanner sharpening strength (0 = off). " + "the reference scanner applies 0.7 by default; " + "leave at 0 if you prefer to sharpen downstream")); + + g->glare_percent = dt_bauhaus_slider_from_params(self, "glare_percent"); + gtk_widget_set_tooltip_text(g->glare_percent, + _("viewing glare: a faint veil of the viewing light " + "reflected off the print surface, in percent. " + "lifts the deepest blacks slightly. " + "not applied when scanning the film directly")); + /* restore root widget */ self->widget = sf_main_box; } From 69a1eb660d0610174a1ab6cea5035344aea5583e Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 26 Jul 2026 07:54:23 +0200 Subject: [PATCH 51/56] more fixes for BW films, expose development time for films and papers that support it --- data/kernels/spektrafilm.cl | 35 ++--- src/common/spektra_core.c | 32 +++-- src/common/spektra_core.h | 17 ++- src/common/spektra_sim.c | 256 +++++++++++++++++++++++++++++++----- src/common/spektra_sim.h | 18 ++- src/iop/spektrafilm.c | 185 ++++++++++++++++++++------ 6 files changed, 430 insertions(+), 113 deletions(-) diff --git a/data/kernels/spektrafilm.cl b/data/kernels/spektrafilm.cl index 93001a7a432a..10950ddcb864 100644 --- a/data/kernels/spektrafilm.cl +++ b/data/kernels/spektrafilm.cl @@ -1025,42 +1025,23 @@ __kernel void spektrafilm_halation_apply(__global float4 *raw, __global const fl raw[k] = r; } -__kernel void spektrafilm_max_partials(__global const float4 *plane, const int npix, - __global float *partials, const int npartials) -{ - const int gid = get_global_id(0); - if(gid >= npartials) return; - float m = 0.0f; - for(int i = gid; i < npix; i += npartials) - { - float4 p = plane[i]; - m = fmax(m, fmax(p.x, fmax(p.y, p.z))); - } - partials[gid] = m; -} - -__kernel void spektrafilm_max_reduce(__global const float *partials, __global float *maxv_buf, - const int npartials) -{ - float m = 0.0f; - for(int i = 0; i < npartials; i++) m = fmax(m, partials[i]); - maxv_buf[0] = m; -} - +/* Scene-referred ceiling, SF_BOOST_SPAN_EV stops above the protect threshold -- + no frame reduction. See sf_boost_highlights() for the derivation. */ +#define SF_BOOST_SPAN_EV 4.0f __kernel void spektrafilm_boost(__global float4 *plane, const int w, const int h, const float boost_ev, const float boost_range, - const float protect_ev, __global const float *maxv_buf) + const float protect_ev) { const int x = get_global_id(0), y = get_global_id(1); if(x >= w || y >= h) return; const int k = y * w + x; - const float maxv = maxv_buf[0]; - if(boost_ev <= 0.0f || maxv <= 0.0f) return; + if(boost_ev <= 0.0f) return; const float midgray = 0.184f; const float rng = fmin(fmax(boost_range, 0.0f), 1.0f); - const float raw_x0 = midgray * exp2(fmax(protect_ev, 0.0f)); - if(raw_x0 >= maxv) return; + const float prot = fmax(protect_ev, 0.0f); + const float raw_x0 = midgray * exp2(prot); + const float maxv = midgray * exp2(prot + SF_BOOST_SPAN_EV); const float a = pow(28.0f, 1.0f - rng); const float x0 = raw_x0 / maxv; const float denom = exp(a * (1.0f - x0)) - a * (1.0f - x0) - 1.0f; diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index b4729927f02c..6a6b8e36b643 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -417,14 +417,27 @@ void sf_boost_highlights(float *const raw, const int w, const int h, const float { if(boost_ev <= 0.0f) return; const size_t nn = (size_t)w * h * 3; - float maxv = 0.0f; - for(size_t i = 0; i < nn; i++) maxv = fmaxf(maxv, raw[i]); - if(maxv <= 0.0f) return; + /* The reference normalises this curve by max(raw) over the whole frame, so its + brightest pixel lands exactly boost_ev stops higher. That is a whole-image + reduction, and this function only ever sees one ROI or one tile: the preview + pipe's downscale averages specular highlights down, so it measured a lower + peak and applied a different curve than the export, and a tiled export got a + different curve in every tile (tiling_callback sets overlap and factor, so + large images do get tiled). + + Anchor the ceiling to the exposure scale instead. raw_x0 is already defined + as a number of stops above the film's calibrated middle grey, so defining + the ceiling the same way makes the whole curve scene-referred and identical + everywhere. Anchoring it to the film's own shoulder was the other candidate + and is a trap: the log exposure at 95% of curve excursion ranges from 2.5 + (Velvia) to 272 (Vision3 250D) in raw units across the shipped stocks, which + would leave the slider nearly inert on negatives and violent on slides. */ const float midgray = 0.184f; const float rng = fminf(fmaxf(boost_range, 0.0f), 1.0f); - float raw_x0 = midgray * exp2f(fmaxf(protect_ev, 0.0f)); - if(raw_x0 > maxv) return; /* threshold above peak: nothing to boost */ + const float prot = fmaxf(protect_ev, 0.0f); + const float raw_x0 = midgray * exp2f(prot); + const float maxv = midgray * exp2f(prot + SF_BOOST_SPAN_EV); const float a = powf(28.0f, 1.0f - rng); const float x0 = raw_x0 / maxv; const float denom = expf(a * (1.0f - x0)) - a * (1.0f - x0) - 1.0f; @@ -444,20 +457,13 @@ void sf_boost_highlights(float *const raw, const int w, const int h, const float } void sf_halation(float *const raw, const int w, const int h, const double pixel_um, + const double sc_core[3], const double sc_tail[3], const double w_s[3], const float scatter_amount, const float scatter_scale, const float halation_amount, const float halation_scale, const double halation_strength[3], const double halation_first_sigma_um) { if(scatter_amount <= 0.0f && halation_amount <= 0.0f) return; - /* per-channel scatter radii (um on film) and core/tail mix weights. These - are the reference model's schema defaults (upstream HalationParams) and, - unlike halation_strength/halation_first_sigma_um below, are NOT varied - per film stock upstream (every (use, antihalation) preset leaves them - alone), so they stay fixed constants here too. */ - static const double sc_core[3] = { 2.2, 2.0, 1.6 }; - static const double sc_tail[3] = { 9.3, 9.7, 9.1 }; - static const double w_s[3] = { 0.78, 0.65, 0.67 }; /* tail = sum of three Gaussians (amplitude, radius multiplier) */ static const double tail_amp[3] = { 0.1633, 0.6496, 0.1870 }; static const double tail_rat[3] = { 0.5360, 1.5236, 2.7684 }; diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index b033fd88a70b..edbf33ded16d 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -59,9 +59,24 @@ void sf_multiplicative_unsharp_mask3(float *buf, int w, int h, float sigma, floa * in micrometres. Both come from sf_sim_halation_params() — per-film when the * pack provides film_render_defaults[stock].halation, otherwise the generic * still/strong-antihalation baseline. */ -void sf_halation(float *raw, int w, int h, double pixel_um, float scatter_amount, +/* `sc_core` / `sc_tail` / `w_s` are the per-channel in-emulsion scatter PSF from + sf_sim_scatter_params(): Gaussian core radius and exponential tail decay in + micrometres on film, and the core/tail mix weight. Already collapsed to one + value per channel for a single-emulsion stock, and already clamped by the + caller to what the ROI padding covers. */ +void sf_halation(float *raw, int w, int h, double pixel_um, const double sc_core[3], + const double sc_tail[3], const double w_s[3], float scatter_amount, float scatter_scale, float halation_amount, float halation_scale, const double halation_strength[3], double halation_first_sigma_um); +/* Stops of headroom above the protect threshold over which the boost is spread. + The reference spreads it from the threshold up to the frame's own peak; that + peak is a whole-image reduction, which a per-ROI/per-tile pixelpipe cannot + reproduce consistently. A fixed span keeps the curve identical in every tile + and in both pipes, and puts both controls on the same footing -- protect_ev + sets where the boost starts, this sets how far above that it reaches full + strength. 4 EV matches the reference's typical frame peak for a normally + exposed scene (midgray + 4-6 EV). */ +#define SF_BOOST_SPAN_EV 4.0f void sf_boost_highlights(float *raw, int w, int h, float boost_ev, float boost_range, float protect_ev); void sf_diffusion_filter(float *raw, int w, int h, double pixel_um, int family, float strength, diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 9b24d2f0e8d5..d36aba1b4560 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -108,6 +108,20 @@ static inline void neon_mat3_mulv_batch(const float m[9], const float *in, #define SF_HALATION_STRENGTH_DEFAULT_B 0.0 #define SF_HALATION_SIGMA_DEFAULT_UM 65.0 +/* [df] HalationParams scatter defaults. Unlike strength/first_sigma_um these are + * not touched by _HALATION_PRESETS, so every stock in the current release uses + * them -- but the pack carries them per film, so they are seeded here and then + * overwritten from film_render_defaults[stock].halation when present. */ +#define SF_SCATTER_CORE_DEFAULT_R 2.2 +#define SF_SCATTER_CORE_DEFAULT_G 2.0 +#define SF_SCATTER_CORE_DEFAULT_B 1.6 +#define SF_SCATTER_TAIL_DEFAULT_R 9.3 +#define SF_SCATTER_TAIL_DEFAULT_G 9.7 +#define SF_SCATTER_TAIL_DEFAULT_B 9.1 +#define SF_SCATTER_TAILW_DEFAULT_R 0.78 +#define SF_SCATTER_TAILW_DEFAULT_G 0.65 +#define SF_SCATTER_TAILW_DEFAULT_B 0.67 + /* ------------------------------------------------------------------------ */ /* small linear algebra */ /* ------------------------------------------------------------------------ */ @@ -234,6 +248,9 @@ struct sf_profile_t double log_sensitivity[SF_NWL][3]; double channel_density[SF_NWL][3]; double base_density[SF_NWL]; + double development_time; /* resolved family member, 0 when the stock has none */ + double dev_times[SF_MAX_DEV_TIMES]; + int n_dev_times; /* 0 or 1 when the stock is characterised at one development */ double log_exposure[SF_NLE]; double density_curves[SF_NLE][3]; int window_n; @@ -343,6 +360,11 @@ struct sf_sim_t Defaults to SF_HALATION_STRENGTH_DEFAULT_* / SF_HALATION_SIGMA_DEFAULT_UM when the pack has no per-stock entry (see sf_sim_build()). */ double halation_strength[3], halation_sigma_um; + /* [df] in-emulsion scatter PSF, per channel. Schema defaults for every stock + in the current release (_HALATION_PRESETS only overrides strength and + first_sigma_um), but the pack carries them per film, so a future release can + vary them without a code change here. */ + double scatter_core_um[3], scatter_tail_um[3], scatter_tail_weight[3]; /* print exposure (exact spectral path) */ int has_print; @@ -416,6 +438,19 @@ static gboolean json_read_darray(JsonObject *obj, const char *key, double *out, } /* read an n×m nested array into row-major out */ +/* Read an array of unknown length, up to `maxn`; returns how many were read. */ +static int json_read_darray_upto(JsonObject *obj, const char *key, double *out, int maxn) +{ + if(!json_object_has_member(obj, key)) return 0; + JsonNode *node = json_object_get_member(obj, key); + if(!node || !JSON_NODE_HOLDS_ARRAY(node)) return 0; + JsonArray *arr = json_node_get_array(node); + if(!arr) return 0; + const int n = MIN((int)json_array_get_length(arr), maxn); + for(int i = 0; i < n; i++) out[i] = json_elem_double(arr, i); + return n; +} + static gboolean json_read_dmatrix(JsonObject *obj, const char *key, double *out, int n, int m) { if(!json_object_has_member(obj, key)) return FALSE; @@ -743,7 +778,7 @@ void sf_profile_free(sf_profile_t *p) g_free(p); } -sf_profile_t *sf_profile_load(const char *path, char **errmsg) +sf_profile_t *sf_profile_load(const char *path, const float development_min, char **errmsg) { JsonParser *parser = json_parser_new(); GError *gerr = NULL; @@ -784,9 +819,61 @@ sf_profile_t *sf_profile_load(const char *path, char **errmsg) ok &= json_read_darray(data, "wavelengths", wavelengths, SF_NWL); ok &= json_read_dmatrix(data, "log_sensitivity", &p->log_sensitivity[0][0], SF_NWL, 3); ok &= json_read_dmatrix(data, "channel_density", &p->channel_density[0][0], SF_NWL, 3); - ok &= json_read_darray(data, "base_density", p->base_density, SF_NWL); + /* Development-time family ([dc] select_development_time). A B&W stock can + carry one density curve, base+fog spectrum and curve-model row per + development time; the export script keeps the family intact and puts the + full list of times in `development_time`, so its length is what says whether + these arrays are a family or an already-widened single member. Packs that + collapsed at export time have one time (or none) and still load unchanged. + + `development` is in minutes; <= 0 means "no choice made" and takes the + representative middle member, floor-middle when even, exactly as upstream's + select_development_time(None) does. */ + double dev_times[SF_MAX_DEV_TIMES]; + const int n_dev = json_read_darray_upto(data, "development_time", dev_times, SF_MAX_DEV_TIMES); + const gboolean dev_family = (n_dev > 1); + int dev_row = 0; + if(dev_family) + { + if(development_min <= 0.0f) + dev_row = (n_dev - 1) / 2; + else + { + double best = fabs(dev_times[0] - (double)development_min); + for(int i = 1; i < n_dev; i++) + { + const double e = fabs(dev_times[i] - (double)development_min); + if(e < best) { best = e; dev_row = i; } + } + } + p->development_time = dev_times[dev_row]; + memcpy(p->dev_times, dev_times, sizeof(double) * n_dev); + p->n_dev_times = n_dev; + } + if(dev_family) + { + double bd[SF_NWL * SF_MAX_DEV_TIMES]; + if(json_read_dmatrix(data, "base_density", bd, SF_NWL, n_dev)) + for(int l = 0; l < SF_NWL; l++) p->base_density[l] = bd[l * n_dev + dev_row]; + else + ok = FALSE; + } + else + ok &= json_read_darray(data, "base_density", p->base_density, SF_NWL); ok &= json_read_darray(data, "log_exposure", p->log_exposure, SF_NLE); - ok &= json_read_dmatrix(data, "density_curves", &p->density_curves[0][0], SF_NLE, 3); + if(dev_family) + { + /* (n_le, n_dev): take the selected column into all three widened channels */ + double *dc = malloc(sizeof(double) * SF_NLE * SF_MAX_DEV_TIMES); + if(dc && json_read_dmatrix(data, "density_curves", dc, SF_NLE, n_dev)) + for(int i = 0; i < SF_NLE; i++) + for(int c = 0; c < 3; c++) p->density_curves[i][c] = dc[i * n_dev + dev_row]; + else + ok = FALSE; + free(dc); + } + else + ok &= json_read_dmatrix(data, "density_curves", &p->density_curves[0][0], SF_NLE, 3); if(!ok) { set_error(errmsg, "spektra_sim: profile %s has unexpected data shapes " @@ -813,28 +900,41 @@ sf_profile_t *sf_profile_load(const char *path, char **errmsg) JsonNode *cnode = (m && json_object_has_member(m, "centers")) ? json_object_get_member(m, "centers") : NULL; JsonArray *centers = (cnode && JSON_NODE_HOLDS_ARRAY(cnode)) ? json_node_get_array(cnode) : NULL; - /* The reference package's own DensityCurvesModel stores centers/ - amplitudes/sigmas as (n_channels=3, n_layers) -- confirmed directly - against its bundled kodak_portra_endura.json, byte-identical to our - copy, and against apply_print_curves_morph's own - "n_channels = model.centers.shape[0]". That's the normal, - channel-major case (outer array length 3) and every profile checked - against the reference package matches it. - kodak_2302.json (not part of the reference package's own bundled - profiles -- a separately-authored addition) stores it transposed: - (n_layers=5, n_channels=3), outer length 5. Rather than assume one - convention universally (an earlier version of this fix did, and - broke every normal profile by mis-transposing correctly-shaped - data), detect orientation per-profile from the one invariant that - always holds: there are exactly 3 channels. */ - const int outer_len = centers ? MIN((int)json_array_get_length(centers), 8) : 0; + /* Layout of centers/amplitudes/sigmas, resolved from declared metadata + rather than guessed from the array shape. + + For a colour stock the outer axis is the channel: (3, n_layers), which is + what the reference's DensityCurvesModel documents and what + apply_print_curves_morph reads as "n_channels = model.centers.shape[0]". + + For a single-emulsion (B&W) stock there is only one channel, and the outer + axis is the DEVELOPMENT-TIME family -- (n_dev, n_layers). The reference + collapses it in select_development_time() (density_curves.py) before the + model is ever evaluated, defaulting to the middle member. Our shipped + profiles are pre-processed inconsistently: kodak_2302 arrives collapsed + and widened to 3 identical rows, kodak_trix collapsed to 1 row, and + kodak_doublex still carries all 5 development rows even though its + density_curves and development_time were already reduced. Reconstructing + each stock's curve from its model confirms it -- doublex matches its own + density_curves exactly on row 2 of 5 (= (5-1)/2, the reference's default) + and nowhere else, trix on row 0, 2302 on any of its three identical rows. + + Reading the outer axis as layers instead, which shape-guessing does for + every B&W stock (their inner length is 3, the LAYER count, not a channel + count), builds a model that reproduces nothing: doublex is off by 1.34 + density at best and trix by 2.37. Both currently render from a wrong + curve fit. */ + const int outer_len = centers ? MIN((int)json_array_get_length(centers), SF_MAX_DEV_TIMES) : 0; JsonArray *row0 = (outer_len > 0) ? json_array_get_array_element(centers, 0) : NULL; - const int inner_len = row0 ? (int)json_array_get_length(row0) : 0; - const gboolean channel_major = (outer_len == 3); /* centers[channel][layer]: normal */ - const gboolean layer_major = (!channel_major && inner_len == 3); /* centers[layer][channel]: e.g. kodak_2302 */ - if(channel_major || layer_major) + const int inner_len = row0 ? MIN((int)json_array_get_length(row0), 8) : 0; + const gboolean bw = p->channel_model && strcmp(p->channel_model, "bw") == 0; + /* A family puts development time on the model's outer axis. So does a pack + generated before the export script learned to collapse it, which is why + any B&W model whose outer length is not 3 is read the same way. */ + const gboolean dev_major = dev_family || (bw && outer_len != 3); + if(outer_len > 0 && inner_len > 0 && (dev_major || outer_len == 3)) { - const int nl = channel_major ? inner_len : outer_len; + const int nl = inner_len; p->curves_model.n_layers = nl; /* [dc] model_type selects the per-layer sigmoid. Anything other than the two known families would be silently rendered as a Gaussian fit, so say @@ -846,25 +946,32 @@ sf_profile_t *sf_profile_load(const char *path, char **errmsg) g_warning("spektra_sim: profile %s has unknown density_curves_model.model_type " "'%s'; rendering it as norm_cdfs", path, model_type); g_free(model_type); - double c[24], a[24], s[24], al[24]; - /* alphas is optional (null or absent for every norm_cdfs profile) and - follows whatever orientation centers used, so it goes through the same - outer/inner lengths and the same idx below. */ + double c[SF_MAX_DEV_TIMES * 8], a[SF_MAX_DEV_TIMES * 8], s2[SF_MAX_DEV_TIMES * 8], + al[SF_MAX_DEV_TIMES * 8]; + /* alphas is optional (null or absent for every norm_cdfs profile) and has + the same shape as centers when present. */ const gboolean has_alphas = json_object_has_member(m, "alphas") && !JSON_NODE_HOLDS_NULL(json_object_get_member(m, "alphas")); if(!has_alphas || !json_read_dmatrix(m, "alphas", al, outer_len, inner_len)) memset(al, 0, sizeof(al)); if(json_read_dmatrix(m, "centers", c, outer_len, inner_len) && json_read_dmatrix(m, "amplitudes", a, outer_len, inner_len) - && json_read_dmatrix(m, "sigmas", s, outer_len, inner_len)) + && json_read_dmatrix(m, "sigmas", s2, outer_len, inner_len)) { + /* dev_major: one member of the family for every widened channel. With a + real family the row is the one selected above from `development`; on a + pre-collapse pack the times are gone, so fall back to the middle row, + which is the member the rest of that profile was reduced to. */ + const int row = !dev_major ? 0 + : dev_family ? MIN(dev_row, outer_len - 1) + : (outer_len - 1) / 2; for(int ch = 0; ch < 3; ch++) for(int l = 0; l < nl; l++) { - const int idx = channel_major ? (ch * nl + l) : (l * 3 + ch); + const int idx = (dev_major ? row : ch) * inner_len + l; p->curves_model.centers[ch][l] = c[idx]; p->curves_model.amplitudes[ch][l] = a[idx]; - p->curves_model.sigmas[ch][l] = s[idx]; + p->curves_model.sigmas[ch][l] = s2[idx]; p->curves_model.alphas[ch][l] = al[idx]; } } @@ -883,6 +990,13 @@ const char *sf_profile_stage(const sf_profile_t *p) { return p->stage; } const char *sf_profile_type(const sf_profile_t *p) { return p->type; } const char *sf_profile_target_print(const sf_profile_t *p) { return p->target_print; } const char *sf_profile_channel_model(const sf_profile_t *p) { return p->channel_model; } +int sf_profile_dev_times(const sf_profile_t *p, double *out, int maxn) +{ + if(!p || p->n_dev_times <= 1) return 0; + const int n = MIN(p->n_dev_times, maxn); + if(out) for(int i = 0; i < n; i++) out[i] = p->dev_times[i]; + return n; +} /* ------------------------------------------------------------------------ */ /* parameter defaults & colour spaces */ @@ -2326,8 +2440,18 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, s->halation_strength[2] = SF_HALATION_STRENGTH_DEFAULT_B; double sigma3[3] = { SF_HALATION_SIGMA_DEFAULT_UM, SF_HALATION_SIGMA_DEFAULT_UM, SF_HALATION_SIGMA_DEFAULT_UM }; + const double core_d[3] = { SF_SCATTER_CORE_DEFAULT_R, SF_SCATTER_CORE_DEFAULT_G, + SF_SCATTER_CORE_DEFAULT_B }; + const double tail_d[3] = { SF_SCATTER_TAIL_DEFAULT_R, SF_SCATTER_TAIL_DEFAULT_G, + SF_SCATTER_TAIL_DEFAULT_B }; + const double tw_d[3] = { SF_SCATTER_TAILW_DEFAULT_R, SF_SCATTER_TAILW_DEFAULT_G, + SF_SCATTER_TAILW_DEFAULT_B }; + memcpy(s->scatter_core_um, core_d, sizeof(core_d)); + memcpy(s->scatter_tail_um, tail_d, sizeof(tail_d)); + memcpy(s->scatter_tail_weight, tw_d, sizeof(tw_d)); sf_pack_film_defaults(pack, film->stock, NULL, NULL, NULL, NULL, s->halation_strength, - sigma3, NULL, NULL, NULL); + sigma3, s->scatter_core_um, s->scatter_tail_um, + s->scatter_tail_weight); /* all known presets use one sigma for R/G/B (see _HALATION_PRESETS upstream); take the first channel rather than plumb a 3-wide sigma through sf_halation() for a split that doesn't currently exist. */ @@ -2528,10 +2652,54 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, _sf_build_grain_layers(s, film, p->grain_density_min, s->grain_uniformity, s->grain_rms, particle_scale, n_scale); } - /* [cp] coupler matrix: donor row -> receiver column, scaled by amount */ + /* A single-emulsion stock is one panchromatic layer. The reference reaches + every per-channel constant through match_channels(values, n_ch), which at + n_ch == 1 returns values[:1] -- the FIRST channel, for all of them. Collapse + them here, once, so no consumer has to know: otherwise a B&W frame is + rendered with chromatic grain statistics and a chromatic halation strength, + and the achromatic grain draw (which reads channel 1) picks the green + figure where the reference uses red. */ + if(s->film_bw) + { + for(int c = 1; c < 3; c++) + { + s->grain_rms[c] = s->grain_rms[0]; + s->grain_uniformity[c] = s->grain_uniformity[0]; + s->halation_strength[c] = s->halation_strength[0]; + s->scatter_core_um[c] = s->scatter_core_um[0]; + s->scatter_tail_um[c] = s->scatter_tail_um[0]; + s->scatter_tail_weight[c] = s->scatter_tail_weight[0]; + } + } + + /* [cp] coupler matrix: donor row -> receiver column, scaled by amount. + + A single-emulsion (B&W) stock is one panchromatic layer, which the + reference models as n_ch == 1: compute_dir_couplers_matrix() populates + M_inter only for n_ch == 3, so the matrix is 1x1 self-inhibition and the + interlayer gammas are inert. couplers.toml says so explicitly -- "B&W + (n_ch == 1) uses only gamma_samelayer_rgb[0]; its interlayer entries are + inert but kept so the params mirror the color defaults" -- and they are + NOT zero there (defaults.bw.negative carries the color values verbatim). + + Since this file widens a B&W emulsion to three identical channels, every + output channel would otherwise pick up its whole matrix COLUMN instead of + just the diagonal: with defaults.bw.negative that is 1.79x / 2.28x / 1.87x + the reference's single-channel correction, and the three differ by 28%, so + an achromatic stock acquires a channel-dependent density shift. Both parts + matter -- zeroing the off-diagonal is not enough, because the diagonal + alone already spans 0.5159 / 0.5934 / 0.2829. Use gamma_samelayer[0] for + all three, which is the value the reference's one channel uses. */ s->couplers_active = p->couplers_active; { double M[3][3] = { { 0 } }; + if(s->film_bw) + { + const double self_bw = p->gamma_samelayer[0] * p->inhibition_samelayer; + M[0][0] = M[1][1] = M[2][2] = self_bw; + } + else + { M[0][0] = p->gamma_samelayer[0] * p->inhibition_samelayer; M[1][1] = p->gamma_samelayer[1] * p->inhibition_samelayer; M[2][2] = p->gamma_samelayer[2] * p->inhibition_samelayer; @@ -2541,6 +2709,7 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, M[1][2] = p->gamma_inter_g_rb[1] * p->inhibition_interlayer; M[2][0] = p->gamma_inter_b_rg[0] * p->inhibition_interlayer; M[2][1] = p->gamma_inter_b_rg[1] * p->inhibition_interlayer; + } for(int i = 0; i < 3; i++) for(int j = 0; j < 3; j++) s->couplers_M[i][j] = M[i][j] * p->couplers_amount; @@ -3202,6 +3371,12 @@ sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *s) g->halation_strength[c] = (float)s->halation_strength[c]; } g->halation_first_sigma_um = (float)s->halation_sigma_um; + for(int c = 0; c < 3; c++) + { + g->scatter_core_um[c] = (float)s->scatter_core_um[c]; + g->scatter_tail_um[c] = (float)s->scatter_tail_um[c]; + g->scatter_tail_weight[c] = (float)s->scatter_tail_weight[c]; + } g->film_positive = s->film_positive; g->couplers_active = s->couplers_active; @@ -3319,6 +3494,23 @@ void sf_sim_halation_params(const sf_sim_t *sim, double strength[3], double *fir if(first_sigma_um) *first_sigma_um = sim ? sim->halation_sigma_um : SF_HALATION_SIGMA_DEFAULT_UM; } +void sf_sim_scatter_params(const sf_sim_t *sim, double core_um[3], double tail_um[3], + double tail_weight[3]) +{ + static const double core_d[3] = { SF_SCATTER_CORE_DEFAULT_R, SF_SCATTER_CORE_DEFAULT_G, + SF_SCATTER_CORE_DEFAULT_B }; + static const double tail_d[3] = { SF_SCATTER_TAIL_DEFAULT_R, SF_SCATTER_TAIL_DEFAULT_G, + SF_SCATTER_TAIL_DEFAULT_B }; + static const double tw_d[3] = { SF_SCATTER_TAILW_DEFAULT_R, SF_SCATTER_TAILW_DEFAULT_G, + SF_SCATTER_TAILW_DEFAULT_B }; + for(int c = 0; c < 3; c++) + { + if(core_um) core_um[c] = sim ? sim->scatter_core_um[c] : core_d[c]; + if(tail_um) tail_um[c] = sim ? sim->scatter_tail_um[c] : tail_d[c]; + if(tail_weight) tail_weight[c] = sim ? sim->scatter_tail_weight[c] : tw_d[c]; + } +} + void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3]) { /* spektrafilm's original single fixed profile, for a sim-less caller */ diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index 9b192ba78324..25a3a377ef18 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -113,7 +113,14 @@ bool sf_pack_film_defaults(const sf_pack_t *pack, const char *film_stock, /* ------------------------------------------------------------- profile -- */ -sf_profile_t *sf_profile_load(const char *path, char **errmsg); +/* `development_min` picks a member of a B&W stock's development-time family, in + * minutes, snapping to the nearest available; <= 0 means "no choice made" and + * takes the representative middle member, mirroring + * select_development_time(None) (density_curves.py). Ignored for colour stocks + * and for stocks with no family. */ +/* Widest development-time family we index (kodak_doublex ships 5). */ +#define SF_MAX_DEV_TIMES 8 +sf_profile_t *sf_profile_load(const char *path, float development_min, char **errmsg); void sf_profile_free(sf_profile_t *profile); /* ---------------------------------------------------------- GPU export -- */ @@ -179,6 +186,7 @@ typedef struct sf_sim_gpu_t (spektra_sim.c) when the pack has no per-stock entry. See sf_sim_halation_params(). */ float halation_strength[3], halation_first_sigma_um; + float scatter_core_um[3], scatter_tail_um[3], scatter_tail_weight[3]; /* output gamut compression */ int out_compress; /* sf_output_compress_t */ float out_luminance_boost; @@ -262,6 +270,11 @@ bool sf_pack_film_coupler_diffusion(const sf_pack_t *pack, const char *film_stoc * SF_HALATION_SIGMA_DEFAULT_UM in spektra_sim.c) when `sim` is NULL or the * pack predates per-stock halation data. */ void sf_sim_halation_params(const sf_sim_t *sim, double strength[3], double *first_sigma_um); +/* [df] per-film in-emulsion scatter PSF: Gaussian core radius, exponential tail + * decay (both micrometres on film) and the core/tail mix weight, per channel. + * Falls back to the schema defaults when sim is NULL or the pack has no entry. */ +void sf_sim_scatter_params(const sf_sim_t *sim, double core_um[3], double tail_um[3], + double tail_weight[3]); const char *sf_profile_stock(const sf_profile_t *p); const char *sf_profile_name(const sf_profile_t *p); @@ -269,6 +282,9 @@ const char *sf_profile_stage(const sf_profile_t *p); /* "filming" / "prin const char *sf_profile_type(const sf_profile_t *p); /* "negative" / "positive" */ const char *sf_profile_target_print(const sf_profile_t *p); /* may be NULL */ const char *sf_profile_channel_model(const sf_profile_t *p); /* "color" / "bw" / NULL */ +/* Copies out the development times this stock is characterised at, in minutes, + * and returns how many there are (0 when it has no family). */ +int sf_profile_dev_times(const sf_profile_t *p, double *out, int maxn); /* -------------------------------------------------------------- params -- */ diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index f6b9dd9c146f..09a9e53060cc 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -82,7 +82,7 @@ #include "common/spektra_core.h" #include "common/spektra_sim.h" -DT_MODULE_INTROSPECTION(9, dt_iop_spektrafilm_params_t) +DT_MODULE_INTROSPECTION(10, dt_iop_spektrafilm_params_t) /* Spatial-scale constants, micrometres on film unless noted (see the LUT module for the full rationale; these are shared with modify_roi_in() and @@ -92,6 +92,13 @@ DT_MODULE_INTROSPECTION(9, dt_iop_spektrafilm_params_t) /* widest stage-1 scatter component: max(sc_tail)*max(tail_rat) from spektra_core.c's sf_halation() = 9.7 * 2.7684 um, rounded up */ #define SF_SCATTER_TAIL_MAX_UM 27.0f +/* Per-channel ceilings the ROI padding above is sized for. The scatter PSF is + per-film pack data now, and modify_roi_in() runs before the sim exists, so it + cannot measure the real values -- clamp them to what the padding covers + instead, exactly as hal_sigma_um is clamped to SF_HALATION_FIRST_SIGMA_UM. + 9.7 * SF_EXPTAIL_R2 = 26.85, which is where the 27.0 above comes from. */ +#define SF_SCATTER_CORE_CLAMP_UM 2.2f +#define SF_SCATTER_TAIL_CLAMP_UM 9.7f /* [gl] GlareParams.roughness / .blur -- the reference exposes these but leaves them at these values for every profile, so only the amount gets a slider. */ #define SF_GLARE_ROUGHNESS 0.7f @@ -196,6 +203,7 @@ typedef struct dt_iop_spektrafilm_params_t float scan_usm_sigma; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpness" float scan_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpen strength" float glare_percent; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.03 $DESCRIPTION: "viewing glare" + float development_min; // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "development time" } dt_iop_spektrafilm_params_t; /* one discovered profile: stock (= file base name), display name, stage */ @@ -207,6 +215,10 @@ typedef struct sf_prof_entry_t gboolean printing; /* stage == "printing" */ gboolean positive; /* info.type == "positive" (slide / reversal) */ gboolean bw; /* channel_model == "bw" */ + /* development times this stock is characterised at; n_dev == 0 means a single + characterisation, i.e. nothing for the development slider to choose */ + int n_dev; + double dev_times[SF_MAX_DEV_TIMES]; uint32_t hash; } sf_prof_entry_t; @@ -224,6 +236,7 @@ typedef struct dt_iop_spektrafilm_gui_data_t GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift; GtkWidget *grain_on, *grain_amount, *grain_size; GtkWidget *scan_blur, *scan_usm_sigma, *scan_usm_amount, *glare_percent; + GtkWidget *development_min; GtkWidget *grain_usm_sigma, *grain_usm_amount; GtkWidget *halation_on, *scatter_amount, *scatter_scale, *halation_amount, *halation_scale; GtkWidget *boost_ev, *boost_range, *protect_ev; @@ -331,7 +344,7 @@ typedef struct dt_iop_spektrafilm_global_data_t int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply; int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c; int kernel_yvv_row_4c, kernel_yvv_col_4c, kernel_yvv_row_1c, kernel_yvv_col_1c; - int kernel_max_partials, kernel_max_reduce, kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix; + int kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix; } dt_iop_spektrafilm_global_data_t; /* the data pack is large (spectra LUT ~12 MB) and shared by all pieces; @@ -379,8 +392,6 @@ void init_global(dt_iop_module_so_t *self) gd->kernel_gauss_col_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_1c"); gd->kernel_channel_accum = dt_opencl_create_kernel(program, "spektrafilm_channel_accum"); gd->kernel_halation_apply = dt_opencl_create_kernel(program, "spektrafilm_halation_apply"); - gd->kernel_max_partials = dt_opencl_create_kernel(program, "spektrafilm_max_partials"); - gd->kernel_max_reduce = dt_opencl_create_kernel(program, "spektrafilm_max_reduce"); gd->kernel_boost = dt_opencl_create_kernel(program, "spektrafilm_boost"); gd->kernel_diffusion_accum = dt_opencl_create_kernel(program, "spektrafilm_diffusion_accum"); gd->kernel_diffusion_mix = dt_opencl_create_kernel(program, "spektrafilm_diffusion_mix"); @@ -421,8 +432,6 @@ void cleanup_global(dt_iop_module_so_t *self) dt_opencl_free_kernel(gd->kernel_gauss_col_1c); dt_opencl_free_kernel(gd->kernel_channel_accum); dt_opencl_free_kernel(gd->kernel_halation_apply); - dt_opencl_free_kernel(gd->kernel_max_partials); - dt_opencl_free_kernel(gd->kernel_max_reduce); dt_opencl_free_kernel(gd->kernel_boost); dt_opencl_free_kernel(gd->kernel_diffusion_accum); dt_opencl_free_kernel(gd->kernel_diffusion_mix); @@ -1129,10 +1138,11 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->scan_usm_sigma = 0.7f; n->scan_usm_amount = 0.0f; n->glare_percent = 0.0f; + n->development_min = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 9; + *new_version = 10; return 0; } if(old_version == 8) @@ -1192,10 +1202,26 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->scan_usm_sigma = 0.7f; n->scan_usm_amount = 0.0f; n->glare_percent = 0.0f; + n->development_min = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 9; + *new_version = 10; + return 0; + } + if(old_version == 9) + { + /* v10 appends `development_min`, which picks a member of a B&W stock's + development-time family in minutes. 0 means "the stock's own default", the + representative middle member -- which is what v9 and earlier effectively + rendered whenever they got the layout right, so old edits keep that. */ + dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); + memcpy(n, old_params, sizeof(*n) - sizeof(n->development_min)); + n->development_min = 0.0f; + + *new_params = n; + *new_params_size = sizeof(dt_iop_spektrafilm_params_t); + *new_version = 10; return 0; } return 1; @@ -1273,7 +1299,7 @@ static int sf_scan_profiles(sf_prof_entry_t *out, int maxn) char path[SF_PATH_LEN + 300]; snprintf(path, sizeof path, "%s/%s", profdir, fn); char *err = NULL; - sf_profile_t *prof = sf_profile_load(path, &err); + sf_profile_t *prof = sf_profile_load(path, 0.5f, &err); /* info header only */ if(!prof) { free(err); @@ -1294,6 +1320,7 @@ static int sf_scan_profiles(sf_prof_entry_t *out, int maxn) e->positive = (type && !strcmp(type, "positive")); const char *cm = sf_profile_channel_model(prof); e->bw = (cm && !strcmp(cm, "bw")); + e->n_dev = sf_profile_dev_times(prof, e->dev_times, SF_MAX_DEV_TIMES); e->hash = sf_name_hash(e->stock); sf_profile_free(prof); n++; @@ -1437,6 +1464,9 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, key = _mix64(key, &p->preflash_m_shift, sizeof p->preflash_m_shift); key = _mix64(key, &p->preflash_y_shift, sizeof p->preflash_y_shift); key = _mix64(key, &p->scan_film, sizeof p->scan_film); + /* selects which member of a B&W development-time family the curve model is + built from, so it has to be part of the sim's cache key */ + key = _mix64(key, &p->development_min, sizeof p->development_min); key = _mix64(key, &p->quality, sizeof p->quality); key = _mix64(key, &p->output_luminance_boost, sizeof p->output_luminance_boost); key = _mix64(key, &p->film_gamma_factor, sizeof p->film_gamma_factor); @@ -1532,12 +1562,12 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, sf_pack_dir(dir, sizeof dir); char *err = NULL; snprintf(path, sizeof path, "%s/profiles/%s.json", dir, film_stock); - sf_profile_t *film = sf_profile_load(path, &err); + sf_profile_t *film = sf_profile_load(path, p->development_min, &err); sf_profile_t *paper = NULL; if(film && !p->scan_film) { snprintf(path, sizeof path, "%s/profiles/%s.json", dir, paper_stock); - paper = sf_profile_load(path, &err); + paper = sf_profile_load(path, p->development_min, &err); } if(film && (paper || p->scan_film)) @@ -1811,8 +1841,17 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c SF_HALATION_FIRST_SIGMA_UM (see _max_halo_sigma); clamp so a future pack entry larger than that can't under-pad the halo. */ hal_sigma_um = fmin(hal_sigma_um, (double)SF_HALATION_FIRST_SIGMA_UM); - sf_halation(plane, w, h, (double)pixel_um, d->p.scatter_amount, d->p.scatter_scale, - d->p.halation_amount, d->p.halation_scale, hal_strength, hal_sigma_um); + /* per-film scatter PSF; clamped so a pack cannot outrun the ROI padding */ + double sc_core[3], sc_tail[3], sc_w[3]; + sf_sim_scatter_params(sim, sc_core, sc_tail, sc_w); + for(int c = 0; c < 3; c++) + { + sc_core[c] = fmin(sc_core[c], (double)SF_SCATTER_CORE_CLAMP_UM); + sc_tail[c] = fmin(sc_tail[c], (double)SF_SCATTER_TAIL_CLAMP_UM); + } + sf_halation(plane, w, h, (double)pixel_um, sc_core, sc_tail, sc_w, d->p.scatter_amount, + d->p.scatter_scale, d->p.halation_amount, d->p.halation_scale, hal_strength, + hal_sigma_um); } /* 3) film development: log exposure, DIR coupler inhibition (the correction @@ -2348,29 +2387,14 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ /* ---- 2) pre-film spatial effects on linear exposure -------------------- */ if(d->p.boost_ev > 0.0f) { - const int npartials = 256; - cl_mem partials = dt_opencl_alloc_device_buffer(devid, npartials * sizeof(float)); - cl_mem maxv_buf = dt_opencl_alloc_device_buffer(devid, sizeof(float)); - if(partials && maxv_buf) - { - const int npix_i = (int)npix; - err = dt_opencl_enqueue_kernel_1d_args(devid, gd->kernel_max_partials, npartials, - CLARG(plane), CLARG(npix_i), CLARG(partials), - CLARG(npartials)); - if(err == CL_SUCCESS) - err = dt_opencl_enqueue_kernel_1d_args(devid, gd->kernel_max_reduce, 1, CLARG(partials), - CLARG(maxv_buf), CLARG(npartials)); - if(err == CL_SUCCESS) - { - const float b_ev = d->p.boost_ev, b_rng = d->p.boost_range, b_prot = d->p.protect_ev; - err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_boost, w, h, CLARG(plane), - CLARG(w), CLARG(h), CLARG(b_ev), CLARG(b_rng), - CLARG(b_prot), CLARG(maxv_buf)); - } - SF_CL_STEP("boost"); - } - dt_opencl_release_mem_object(partials); - dt_opencl_release_mem_object(maxv_buf); + /* The frame-maximum reduction that used to run here is gone: the curve is + anchored to the exposure scale now, which is what makes the boost agree + between the preview pipe, the export pipe and every tile. */ + const float b_ev = d->p.boost_ev, b_rng = d->p.boost_range, b_prot = d->p.protect_ev; + err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_boost, w, h, CLARG(plane), + CLARG(w), CLARG(h), CLARG(b_ev), CLARG(b_rng), + CLARG(b_prot)); + SF_CL_STEP("boost"); } if(d->p.diffusion_on) @@ -2415,8 +2439,13 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ channel into the single-channel scratch buffer plane1, blur it alone (1x the work of a same-size float4 blur, not 4x), then kernel_channel_accum folds it into the target channel of tmpa/acc. */ - const float sc_core[3] = { 2.2f, 2.0f, 1.6f }; - const float sc_tail[3] = { 9.3f, 9.7f, 9.1f }; + /* per-film scatter PSF, same clamps as the CPU path */ + float sc_core[3], sc_tail[3]; + for(int c = 0; c < 3; c++) + { + sc_core[c] = fminf(g->scatter_core_um[c], SF_SCATTER_CORE_CLAMP_UM); + sc_tail[c] = fminf(g->scatter_tail_um[c], SF_SCATTER_TAIL_CLAMP_UM); + } const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f }; for(int c = 0; c < 3; c++) { @@ -2449,7 +2478,8 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ CLARG(amp[g3]), CLARG(c), CLARG(reset)); SF_CL_STEP("scatter tail accum"); } - const float ws_r = 0.78f, ws_g = 0.65f, ws_b = 0.67f; + const float ws_r = g->scatter_tail_weight[0], ws_g = g->scatter_tail_weight[1], + ws_b = g->scatter_tail_weight[2]; /* (1-s)*raw + s*scattered, matching sf_halation()'s CPU blend; `plane` doubles as both the pre-scatter `raw` input and the `out` write target -- safe since this is a purely per-pixel elementwise op. */ @@ -2836,6 +2866,7 @@ static void _rescan(dt_iop_module_t *self) } static void _update_print_sensitivity(dt_iop_module_t *self); +static void _update_development_sensitivity(dt_iop_module_t *self); static void _film_changed(GtkWidget *w, dt_iop_module_t *self) { @@ -2907,13 +2938,81 @@ static void _paper_changed(GtkWidget *w, dt_iop_module_t *self) const int pi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->paper)); if(pi < 0) return; p->paper_hash = g->entries[pi].hash; + /* the print stock can carry a development-time family of its own (2302) */ + _update_development_sensitivity(self); dt_dev_add_history_item(darktable.develop, self, TRUE); } +/* Entry for a stock hash, or NULL. `printing` picks which of the two lists to + search, since a stock can appear as film or as paper. */ +static const sf_prof_entry_t *_entry_by_hash(const dt_iop_spektrafilm_gui_data_t *g, + const uint32_t hash, const gboolean printing) +{ + const int n = printing ? g->n_papers : g->n_films; + const int *idx = printing ? g->paper_entry : g->film_entry; + for(int i = 0; i < n; i++) + if(g->entries[idx[i]].hash == hash) return &g->entries[idx[i]]; + return NULL; +} + +/* The development slider only has something to choose when the stock in use is + characterised at more than one development time. Both loads in _ensure_sim() + get the same value, so the print stock counts too -- print film 2302 carries a + family of its own -- but only while there is a print stage. Lives here rather + than in gui_update() because this is the one function every film / paper / + scan_film change already routes through; in gui_update() alone it went stale + the moment the stock was switched. */ +static void _update_development_sensitivity(dt_iop_module_t *self) +{ + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + const dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; + if(!g->development_min) return; + + const sf_prof_entry_t *fe = _entry_by_hash(g, p->film_hash, FALSE); + const sf_prof_entry_t *pe = p->scan_film ? NULL : _entry_by_hash(g, p->paper_hash, TRUE); + const sf_prof_entry_t *de = (fe && fe->n_dev > 1) ? fe : ((pe && pe->n_dev > 1) ? pe : NULL); + + gtk_widget_set_sensitive(g->development_min, de != NULL); + /* Span exactly what this stock offers, so dragging lands on real values + instead of wandering through empty range. 0 stays reachable at the bottom -- + it means "the stock's own default" -- and the hard 15 min ceiling covers the + widest family in the release (Double-X, 12 min) with room to spare. */ + float dev_hi = 15.0f; + if(de) + { + dev_hi = (float)de->dev_times[0]; + for(int i = 1; i < de->n_dev; i++) dev_hi = fmaxf(dev_hi, (float)de->dev_times[i]); + } + dt_bauhaus_slider_set_soft_range(g->development_min, 0.0f, dev_hi); + if(de) + { + char times[128] = { 0 }; + for(int i = 0; i < de->n_dev; i++) + { + char one[24]; + snprintf(one, sizeof one, "%s%.3g", i ? ", " : "", de->dev_times[i]); + g_strlcat(times, one, sizeof times); + } + char tip[320]; + snprintf(tip, sizeof tip, + _("development time, in minutes. snaps to the nearest time %s is\n" + "characterised at: %s min. 0 uses the stock's own default (%.3g min)."), + de->name, times, de->dev_times[(de->n_dev - 1) / 2]); + gtk_widget_set_tooltip_text(g->development_min, tip); + } + else + gtk_widget_set_tooltip_text(g->development_min, + _("development time. the stock in use is characterised at a\n" + "single development, so there is nothing to choose.\n\n" + "single-emulsion B&W stocks are the ones that carry a family:\n" + "Double-X at 4/5/6.5/9/12 min, print film 2302 at 2/3.5/5/7/9 min.")); +} + static void _update_print_sensitivity(dt_iop_module_t *self) { dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; + _update_development_sensitivity(self); const gboolean printing = !p->scan_film; gtk_widget_set_sensitive(g->paper, printing); gtk_widget_set_sensitive(g->print_exposure_ev, printing); @@ -3352,6 +3451,14 @@ void gui_init(dt_iop_module_t *self) dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "chemistry"))); + g->development_min = dt_bauhaus_slider_from_params(self, "development_min"); + dt_bauhaus_slider_set_format(g->development_min, _(" min")); + /* gui_update() replaces this with the selected stock's own times, and greys + the slider out for stocks characterised at a single development */ + gtk_widget_set_tooltip_text(g->development_min, + _("development time. snaps to the nearest time the stock was\n" + "characterised at; 0 uses the stock's own default.")); + g->film_gamma_factor = dt_bauhaus_slider_from_params(self, "film_gamma_factor"); dt_bauhaus_slider_set_soft_range(g->film_gamma_factor, 0.25f, 2.0f); gtk_widget_set_tooltip_text( From ef87cdae9a0c9c97cdbe0d1f778aa5c2054cf372 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 26 Jul 2026 11:07:29 +0200 Subject: [PATCH 52/56] Set development time slider to stock/paper default, have one slider in film and print for the respective part, reset to default value --- src/iop/spektrafilm.c | 157 ++++++++++++++++++++++++++++++------------ 1 file changed, 114 insertions(+), 43 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 09a9e53060cc..70ea749514d3 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -204,6 +204,7 @@ typedef struct dt_iop_spektrafilm_params_t float scan_usm_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpen strength" float glare_percent; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.03 $DESCRIPTION: "viewing glare" float development_min; // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "development time" + float print_development_min; // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "development time" } dt_iop_spektrafilm_params_t; /* one discovered profile: stock (= file base name), display name, stage */ @@ -236,7 +237,7 @@ typedef struct dt_iop_spektrafilm_gui_data_t GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift; GtkWidget *grain_on, *grain_amount, *grain_size; GtkWidget *scan_blur, *scan_usm_sigma, *scan_usm_amount, *glare_percent; - GtkWidget *development_min; + GtkWidget *development_min, *print_development_min; GtkWidget *grain_usm_sigma, *grain_usm_amount; GtkWidget *halation_on, *scatter_amount, *scatter_scale, *halation_amount, *halation_scale; GtkWidget *boost_ev, *boost_range, *protect_ev; @@ -1139,6 +1140,7 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->scan_usm_amount = 0.0f; n->glare_percent = 0.0f; n->development_min = 0.0f; + n->print_development_min = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); @@ -1203,6 +1205,7 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int n->scan_usm_amount = 0.0f; n->glare_percent = 0.0f; n->development_min = 0.0f; + n->print_development_min = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); @@ -1216,8 +1219,9 @@ int legacy_params(dt_iop_module_t *self, const void *const old_params, const int representative middle member -- which is what v9 and earlier effectively rendered whenever they got the layout right, so old edits keep that. */ dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - memcpy(n, old_params, sizeof(*n) - sizeof(n->development_min)); + memcpy(n, old_params, sizeof(*n) - sizeof(n->development_min) - sizeof(n->print_development_min)); n->development_min = 0.0f; + n->print_development_min = 0.0f; *new_params = n; *new_params_size = sizeof(dt_iop_spektrafilm_params_t); @@ -1467,6 +1471,7 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, /* selects which member of a B&W development-time family the curve model is built from, so it has to be part of the sim's cache key */ key = _mix64(key, &p->development_min, sizeof p->development_min); + key = _mix64(key, &p->print_development_min, sizeof p->print_development_min); key = _mix64(key, &p->quality, sizeof p->quality); key = _mix64(key, &p->output_luminance_boost, sizeof p->output_luminance_boost); key = _mix64(key, &p->film_gamma_factor, sizeof p->film_gamma_factor); @@ -1567,7 +1572,7 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, if(film && !p->scan_film) { snprintf(path, sizeof path, "%s/profiles/%s.json", dir, paper_stock); - paper = sf_profile_load(path, p->development_min, &err); + paper = sf_profile_load(path, p->print_development_min, &err); } if(film && (paper || p->scan_film)) @@ -2866,7 +2871,9 @@ static void _rescan(dt_iop_module_t *self) } static void _update_print_sensitivity(dt_iop_module_t *self); -static void _update_development_sensitivity(dt_iop_module_t *self); +static void _update_development_sensitivity(const dt_iop_spektrafilm_gui_data_t *g, + const dt_iop_spektrafilm_params_t *p); +static float _development_default(const sf_prof_entry_t *e); static void _film_changed(GtkWidget *w, dt_iop_module_t *self) { @@ -2877,6 +2884,14 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) if(fi < 0) return; const sf_prof_entry_t *e = &g->entries[fi]; p->film_hash = e->hash; + /* A development time from the previous stock means nothing here -- the times + differ per stock, and a stale value can sit above the new stock's longest + (Double-X at 12 min, then 2302's family topping out at 9). Land on the new + stock's own default, so the slider always shows a time it actually has. */ + p->development_min = _development_default(e); + DT_ENTER_GUI_UPDATE(); + dt_bauhaus_slider_set(g->development_min, p->development_min); + DT_LEAVE_GUI_UPDATE(); /* scan_film's checkbox is a plain GtkCheckButton (dt_bauhaus_toggle_from_ params), not a bauhaus widget -- the real reset mechanism for it (dt_bauhaus_toggle_widget_reset, develop/imageop_gui.c, part of this @@ -2903,7 +2918,13 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) and runs before the memcpy specifically so a single reset click converges correctly on the first try. */ if(self->default_params) - ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = e->positive; + { + dt_iop_spektrafilm_params_t *dp = (dt_iop_spektrafilm_params_t *)self->default_params; + dp->scan_film = e->positive; + /* same reasoning as the slider's own default above, for the reset path that + memcpys default_params over params wholesale */ + dp->development_min = p->development_min; + } /* scan-film follows the film's natural mode on a film switch: slides and reversal stocks are viewed directly (scan), negatives go through the print stage. The user can still toggle freely afterwards -- this only @@ -2927,6 +2948,10 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) --darktable.gui->reset; break; } + /* last, once scan_film and the auto-followed paper have settled: both + development sliders are gated on their own stock, and the print one also on + there being a print stage at all */ + _update_development_sensitivity(g, p); dt_dev_add_history_item(darktable.develop, self, TRUE); } @@ -2938,8 +2963,15 @@ static void _paper_changed(GtkWidget *w, dt_iop_module_t *self) const int pi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->paper)); if(pi < 0) return; p->paper_hash = g->entries[pi].hash; - /* the print stock can carry a development-time family of its own (2302) */ - _update_development_sensitivity(self); + /* same as _film_changed: a time from the previous paper does not transfer */ + p->print_development_min = _development_default(&g->entries[pi]); + if(self->default_params) + ((dt_iop_spektrafilm_params_t *)self->default_params)->print_development_min + = p->print_development_min; + DT_ENTER_GUI_UPDATE(); + dt_bauhaus_slider_set(g->print_development_min, p->print_development_min); + DT_LEAVE_GUI_UPDATE(); + _update_development_sensitivity(g, p); dt_dev_add_history_item(darktable.develop, self, TRUE); } @@ -2955,64 +2987,83 @@ static const sf_prof_entry_t *_entry_by_hash(const dt_iop_spektrafilm_gui_data_t return NULL; } -/* The development slider only has something to choose when the stock in use is - characterised at more than one development time. Both loads in _ensure_sim() - get the same value, so the print stock counts too -- print film 2302 carries a - family of its own -- but only while there is a print stage. Lives here rather - than in gui_update() because this is the one function every film / paper / - scan_film change already routes through; in gui_update() alone it went stale - the moment the stock was switched. */ -static void _update_development_sensitivity(dt_iop_module_t *self) +/* Default development time for a stock, in minutes: the representative middle + member of its family, which is what select_development_time(None) picks. 0 when + the stock is characterised at a single development. */ +static float _development_default(const sf_prof_entry_t *e) { - dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; - const dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; - if(!g->development_min) return; - - const sf_prof_entry_t *fe = _entry_by_hash(g, p->film_hash, FALSE); - const sf_prof_entry_t *pe = p->scan_film ? NULL : _entry_by_hash(g, p->paper_hash, TRUE); - const sf_prof_entry_t *de = (fe && fe->n_dev > 1) ? fe : ((pe && pe->n_dev > 1) ? pe : NULL); - - gtk_widget_set_sensitive(g->development_min, de != NULL); - /* Span exactly what this stock offers, so dragging lands on real values - instead of wandering through empty range. 0 stays reachable at the bottom -- - it means "the stock's own default" -- and the hard 15 min ceiling covers the - widest family in the release (Double-X, 12 min) with room to spare. */ - float dev_hi = 15.0f; - if(de) - { - dev_hi = (float)de->dev_times[0]; - for(int i = 1; i < de->n_dev; i++) dev_hi = fmaxf(dev_hi, (float)de->dev_times[i]); + return (e && e->n_dev > 1) ? (float)e->dev_times[(e->n_dev - 1) / 2] : 0.0f; +} + +/* Point one development slider at one stock: sensitive only where that stock is + characterised at more than one development time, spanning exactly the times it + offers, and naming them -- they differ per stock and the value snaps to them, + so a bare 0-15 range would be guesswork. */ +static void _development_widget_update(GtkWidget *w, const sf_prof_entry_t *e) +{ + if(!w) return; + const gboolean have = (e && e->n_dev > 1); + gtk_widget_set_sensitive(w, have); + + float hi = 15.0f; + if(have) + { + hi = (float)e->dev_times[0]; + for(int i = 1; i < e->n_dev; i++) hi = fmaxf(hi, (float)e->dev_times[i]); } - dt_bauhaus_slider_set_soft_range(g->development_min, 0.0f, dev_hi); - if(de) + /* 0 stays reachable at the bottom -- it means "this stock's own default" -- and + the hard 15 min ceiling covers the widest family in the release (Double-X, + 12 min) with room to spare. */ + dt_bauhaus_slider_set_soft_range(w, 0.0f, hi); + /* Reset gestures (double-click, scroll-reset) go to the widget's own default, + which introspection set to the compiled 0. That renders correctly -- 0 means + "this stock's default" -- but leaves the slider reading 0 instead of the time + it resolved to, on the one discontinuity in the range. Point it at the real + number for the stock in hand, so a reset shows 6.5 min on Double-X and 5 min + on 2302 rather than 0. */ + dt_bauhaus_slider_set_default(w, _development_default(e)); + + if(have) { char times[128] = { 0 }; - for(int i = 0; i < de->n_dev; i++) + for(int i = 0; i < e->n_dev; i++) { char one[24]; - snprintf(one, sizeof one, "%s%.3g", i ? ", " : "", de->dev_times[i]); + snprintf(one, sizeof one, "%s%.3g", i ? ", " : "", e->dev_times[i]); g_strlcat(times, one, sizeof times); } char tip[320]; snprintf(tip, sizeof tip, _("development time, in minutes. snaps to the nearest time %s is\n" "characterised at: %s min. 0 uses the stock's own default (%.3g min)."), - de->name, times, de->dev_times[(de->n_dev - 1) / 2]); - gtk_widget_set_tooltip_text(g->development_min, tip); + e->name, times, (double)_development_default(e)); + gtk_widget_set_tooltip_text(w, tip); } else - gtk_widget_set_tooltip_text(g->development_min, - _("development time. the stock in use is characterised at a\n" - "single development, so there is nothing to choose.\n\n" + gtk_widget_set_tooltip_text(w, + _("development time. this stock is characterised at a single\n" + "development, so there is nothing to choose.\n\n" "single-emulsion B&W stocks are the ones that carry a family:\n" "Double-X at 4/5/6.5/9/12 min, print film 2302 at 2/3.5/5/7/9 min.")); } +/* Film and print are separate chemistries developed for separate times, so they + get a slider each, pointed at their own stock. Lives here rather than in + gui_update() because this is the one function every film / paper / scan_film + change already routes through; in gui_update() alone it went stale the moment a + stock was switched. */ +static void _update_development_sensitivity(const dt_iop_spektrafilm_gui_data_t *g, + const dt_iop_spektrafilm_params_t *p) +{ + _development_widget_update(g->development_min, _entry_by_hash(g, p->film_hash, FALSE)); + _development_widget_update(g->print_development_min, + p->scan_film ? NULL : _entry_by_hash(g, p->paper_hash, TRUE)); +} + static void _update_print_sensitivity(dt_iop_module_t *self) { dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; - _update_development_sensitivity(self); const gboolean printing = !p->scan_film; gtk_widget_set_sensitive(g->paper, printing); gtk_widget_set_sensitive(g->print_exposure_ev, printing); @@ -3042,6 +3093,12 @@ static void _update_print_sensitivity(dt_iop_module_t *self) gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_diffusion_on), printing && p->print_diffusion_on); DT_LEAVE_GUI_UPDATE(); + + /* Also from here: gui_changed() sends the scan_film toggle to this function and + not to _toggle_sensitivity(), so without this the print development slider + stayed live after switching to a scan-the-film workflow that has no print + stage at all. */ + _update_development_sensitivity(g, p); } /* Grays out each effect's own sub-controls when its master "enable" toggle @@ -3078,6 +3135,10 @@ static void _toggle_sensitivity(dt_iop_spektrafilm_gui_data_t *g, gtk_widget_set_sensitive(g->print_diffusion_strength, pdif); gtk_widget_set_sensitive(g->print_diffusion_scale, pdif); gtk_widget_set_sensitive(g->print_diffusion_warmth, pdif); + + /* last: the development sliders are gated on their own stock's family, and + must not be re-enabled by any of the plain `printing` toggles above */ + _update_development_sensitivity(g, p); } /* dt_iop_reload_defaults() runs module->reload_defaults() (this function, @@ -3518,6 +3579,16 @@ void gui_init(dt_iop_module_t *self) gtk_widget_set_tooltip_text(g->print_contrast, _("print contrast (morphs the paper's density curves)")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "chemistry"))); + + g->print_development_min = dt_bauhaus_slider_from_params(self, "print_development_min"); + dt_bauhaus_slider_set_format(g->print_development_min, _(" min")); + /* _update_development_sensitivity() replaces this with the selected paper's own + times, and greys it out for papers characterised at a single development */ + gtk_widget_set_tooltip_text(g->print_development_min, + _("print development time. snaps to the nearest time the paper\n" + "was characterised at; 0 uses its own default.")); + dt_gui_box_add(self->widget, dt_ui_section_label_new(C_("section", "filtration"))); g->filter_m = dt_bauhaus_slider_from_params(self, "filter_m"); From 7a5141e4b44dd759aba5ddb77dd1e6ef0f60e6df Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Sun, 26 Jul 2026 22:12:10 +0200 Subject: [PATCH 53/56] limit DIR coupler range to 1 to keep it invertible --- src/common/spektra_sim.c | 61 ++++++++++++++++++++++++++++++++++++++++ src/external/lua-scripts | 2 +- src/iop/spektrafilm.c | 2 +- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index d36aba1b4560..02409b4c5a82 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -2762,6 +2762,67 @@ sf_sim_t *sf_sim_build(const sf_pack_t *pack, const sf_profile_t *film, /* [cp] compute_density_curves_before_dir_couplers */ if(s->couplers_active) { + /* The inversion below reads le_0 as the x-axis of an interpolation, which + needs it strictly increasing: le_0 = le - cac is invertible only while + d(cac)/d(le) < 1. Past that the same corrected exposure maps to two + densities and the film has no "before couplers" curve at all -- the model + has left the physical regime, not merely become inaccurate. + + The breakdown is stock-dependent and can sit well inside the slider's + range: measured over the shipped profiles it is amount ~1.60 for + Portra 400, ~1.66 for Double-X, ~2.09 for Vision3 250D and ~1.00 for + Velvia 100. Beyond it both this code and the reference feed unsorted x to + an interpolator -- np.interp there, binary search here -- and each returns + a different arbitrary bracket, which is why high amounts diverge between + the two while low ones agree. + + So find the largest amount that stays invertible and use that, rather than + emitting curves nobody can reproduce. Bisection on a monotone predicate, + 32 iterations, once per sim build. */ + { + double lo = 0.0, hi = 1.0; + for(int it = 0; it < 32; it++) + { + const double mid = 0.5 * (lo + hi); + int ok = 1; + for(int m = 0; m < 3 && ok; m++) + { + double prev = -INFINITY; + for(int i = 0; i < SF_NLE && ok; i++) + { + double cac = 0.0; + for(int k = 0; k < 3; k++) + { + double silver = s->film_positive ? s->film_dmax[k] - s->curves_norm[i][k] + : s->curves_norm[i][k]; + if(s->couplers_donor_lm) + silver = silver * (s->couplers_donor_K[k] + s->couplers_donor_Dref[k]) + / (s->couplers_donor_K[k] + silver); + cac += silver * s->couplers_M[k][m] * mid; + } + if(s->couplers_recv_lm) + cac = cac * (s->couplers_recv_Kr[m] + s->couplers_recv_cref[m]) + / (s->couplers_recv_Kr[m] + cac); + const double v = film->log_exposure[i] - cac; + if(v <= prev) ok = 0; + prev = v; + } + } + if(ok) lo = mid; else hi = mid; + } + if(lo < 1.0) + { + /* couplers_M is already amount-scaled, so scale it again by the surviving + fraction and keep every downstream consumer -- inversion, forward + correction, GPU export -- on one matrix. */ + for(int i = 0; i < 3; i++) + for(int j = 0; j < 3; j++) s->couplers_M[i][j] *= lo; + dt_print(DT_DEBUG_PIPE, + "[spektrafilm] DIR couplers: amount %.3f is past this stock's invertible" + " limit, using %.3f", p->couplers_amount, p->couplers_amount * lo); + } + } + double le_0[SF_NLE][3]; for(int i = 0; i < SF_NLE; i++) for(int m = 0; m < 3; m++) diff --git a/src/external/lua-scripts b/src/external/lua-scripts index b24dd18ddc0c..3ade2a8b4059 160000 --- a/src/external/lua-scripts +++ b/src/external/lua-scripts @@ -1 +1 @@ -Subproject commit b24dd18ddc0c830b04b323c2202dbd505d75fb76 +Subproject commit 3ade2a8b4059b954ae2993b84bb29c65985f0deb diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 70ea749514d3..1990ae9550dc 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -163,7 +163,7 @@ typedef struct dt_iop_spektrafilm_params_t float print_contrast; // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print contrast" float filter_m; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration M" float filter_y; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration Y" - float couplers_amount; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "DIR couplers" + float couplers_amount; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 1.0 $DESCRIPTION: "DIR couplers" float preflash_exposure; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash exposure" float preflash_m_shift; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash M filter shift" float preflash_y_shift; // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash Y filter shift" From a5a0a823da09f3fc93a641f021db5af660473f18 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Mon, 27 Jul 2026 07:23:40 +0200 Subject: [PATCH 54/56] follow the Bauhaus checkbox changes --- src/iop/spektrafilm.c | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 1990ae9550dc..16fd1dd45826 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -2921,6 +2921,13 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) { dt_iop_spektrafilm_params_t *dp = (dt_iop_spektrafilm_params_t *)self->default_params; dp->scan_film = e->positive; + /* the checkbox caches the default it was created with, the way sliders + and comboboxes do, so re-baselining the param's default has to + re-baseline the widget's too. Otherwise a reset would return to the + default of whichever film happened to be selected when the module's + gui was built, and the tab's "changed" marker would compare against + that same stale value. */ + dt_bauhaus_toggle_set_default(g->scan_film, dp->scan_film); /* same reasoning as the slider's own default above, for the reset path that memcpys default_params over params wholesale */ dp->development_min = p->development_min; @@ -2932,9 +2939,9 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) if(p->scan_film != e->positive) { p->scan_film = e->positive; - ++darktable.gui->reset; - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->scan_film), p->scan_film); - --darktable.gui->reset; + DT_ENTER_GUI_UPDATE(); + dt_bauhaus_toggle_set(g->scan_film, p->scan_film); + DT_LEAVE_GUI_UPDATE(); _update_print_sensitivity(self); } /* if the paper is still on "auto" (hash 0) keep it following the film's @@ -2943,9 +2950,9 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) for(int k = 0; k < g->n_papers; k++) if(!strcmp(g->entries[g->paper_entry[k]].stock, e->target_print)) { - ++darktable.gui->reset; + DT_ENTER_GUI_UPDATE(); dt_bauhaus_combobox_set_from_value(g->paper, g->paper_entry[k]); - --darktable.gui->reset; + DT_LEAVE_GUI_UPDATE(); break; } /* last, once scan_film and the auto-followed paper have settled: both @@ -3088,10 +3095,10 @@ static void _update_print_sensitivity(dt_iop_module_t *self) programmatic widget syncs rely on -- so this is purely visual and never writes back into the param. */ DT_ENTER_GUI_UPDATE(); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_auto_exposure), - printing && p->print_auto_exposure); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_diffusion_on), - printing && p->print_diffusion_on); + dt_bauhaus_toggle_set(g->print_auto_exposure, + printing && p->print_auto_exposure); + dt_bauhaus_toggle_set(g->print_diffusion_on, + printing && p->print_diffusion_on); DT_LEAVE_GUI_UPDATE(); /* Also from here: gui_changed() sends the scan_film toggle to this function and @@ -3165,6 +3172,12 @@ void reload_defaults(dt_iop_module_t *self) { if(self->default_params) ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = FALSE; + + /* keep the widget's cached default in step with the one just restored, + for the same reason _film_changed() does after re-baselining it */ + dt_iop_spektrafilm_gui_data_t *g = self->gui_data; + if(g && g->scan_film) + dt_bauhaus_toggle_set_default(g->scan_film, FALSE); } void gui_reset(dt_iop_module_t *self) @@ -3313,12 +3326,12 @@ void gui_update(dt_iop_module_t *self) them here or they drift from the params: a stale box makes the first click a no-op (field already has that value -> no history item) and module reset never updates them. */ - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->scan_film), p->scan_film); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_auto_exposure), p->print_auto_exposure); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->halation_on), p->halation_on); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->diffusion_on), p->diffusion_on); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->print_diffusion_on), p->print_diffusion_on); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g->grain_on), p->grain_on); + dt_bauhaus_toggle_set(g->scan_film, p->scan_film); + dt_bauhaus_toggle_set(g->print_auto_exposure, p->print_auto_exposure); + dt_bauhaus_toggle_set(g->halation_on, p->halation_on); + dt_bauhaus_toggle_set(g->diffusion_on, p->diffusion_on); + dt_bauhaus_toggle_set(g->print_diffusion_on, p->print_diffusion_on); + dt_bauhaus_toggle_set(g->grain_on, p->grain_on); _toggle_sensitivity(g, p); _update_print_sensitivity(self); From f618e7ecf9e9c71d77e1099b91afc30e5ca8dac0 Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Mon, 27 Jul 2026 21:42:02 +0200 Subject: [PATCH 55/56] Reset module to v1, remove special checkbox handling as this is now a proper bauhaus widget, add blank lines between routined, use GList where necessary, use dt_hash(), add underscore prefex to static routines --- src/iop/spektrafilm.c | 1191 +++++++---------------------------------- 1 file changed, 195 insertions(+), 996 deletions(-) diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 16fd1dd45826..2180549d8812 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -82,7 +82,7 @@ #include "common/spektra_core.h" #include "common/spektra_sim.h" -DT_MODULE_INTROSPECTION(10, dt_iop_spektrafilm_params_t) +DT_MODULE_INTROSPECTION(1, dt_iop_spektrafilm_params_t) /* Spatial-scale constants, micrometres on film unless noted (see the LUT module for the full rationale; these are shared with modify_roi_in() and @@ -132,7 +132,6 @@ DT_MODULE_INTROSPECTION(10, dt_iop_spektrafilm_params_t) /* DIR coupler inhibitor diffusion; spektrafilm params_schema dir_couplers.diffusion_size_um default (a plain gaussian in the reference) */ -#define SF_MAX_PROFILES 128 #define SF_NAME_LEN 128 #define SF_PATH_LEN 1024 @@ -245,10 +244,9 @@ typedef struct dt_iop_spektrafilm_gui_data_t GtkWidget *print_diffusion_on, *print_diffusion_filter_family; GtkWidget *print_diffusion_strength, *print_diffusion_scale, *print_diffusion_warmth; - sf_prof_entry_t entries[SF_MAX_PROFILES]; - int n_entries; - int film_entry[SF_MAX_PROFILES], n_films; - int paper_entry[SF_MAX_PROFILES], n_papers; + /* every profile found on disk, sorted, films and papers together -- + e->printing separates them. Owns its sf_prof_entry_t nodes. */ + GList *entries; GtkNotebook *notebook; } dt_iop_spektrafilm_gui_data_t; @@ -454,10 +452,12 @@ const char *name(void) { return _("spektrafilm"); } + const char *aliases(void) { return _("film simulation|analog|spectral|grain|halation|print"); } + const char **description(dt_iop_module_t *self) { return dt_iop_set_description( @@ -467,788 +467,38 @@ const char **description(dt_iop_module_t *self) _("creative"), _("linear, RGB, scene-referred"), _("non-linear, RGB"), _("non-linear, RGB, display-referred")); } + int default_group(void) { return IOP_GROUP_COLOR | IOP_GROUP_GRADING; } + int flags(void) { return IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_INCLUDE_IN_STYLES | IOP_FLAGS_ALLOW_TILING; } + dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self, dt_dev_pixelpipe_t *p, dt_dev_pixelpipe_iop_t *pi) { return IOP_CS_RGB; } -int legacy_params(dt_iop_module_t *self, const void *const old_params, const int old_version, - void **new_params, int32_t *new_params_size, int *new_version) -{ - /* v1 -> v5, v2 -> v5, v3 -> v5, v4 -> v5: each case below produces the - current (v5) struct directly, rather than chaining through the - intermediate versions -- same convention darktable's own modules use for - multi-version migrations (see e.g. exposure.c, where every old_version - case sets *new_version straight to its latest, not to old_version+1). - - v1 is the module's true original params shape, confirmed directly - against the live, unmodified upstream source -- covering every params - layout this module shipped with before the version was first bumped - (the introspection version was never bumped through several early - field additions, print_auto_exposure among them, during this module's - fast-moving initial development, so v1 recovers history saved against - any of those shapes too: the struct has only ever grown by appending - fields, so an old, smaller saved blob still matches the leading - fields of the current struct). - - v2 is the shape after the first proper version bump (preflash_*, - diffusion_filter_family, output_luminance_boost added) but before - print diffusion existed. - - v3 adds print_diffusion_on/print_diffusion_filter_family/ - print_diffusion_strength/print_diffusion_scale/print_diffusion_warmth - -- a second, independent diffusion filter applied at the print stage - rather than the film stage. - - v4 splits what used to be one shared halation_amount/halation_scale - pair -- driving BOTH the in-emulsion scatter stage (always fully - engaged, s_amount==1 hardcoded) and the back-reflection halation stage - -- into two independent pairs: scatter_amount/scatter_scale (new) and - halation_amount/halation_scale (unchanged meaning, now stage-2 only). - Every v1/v2/v3 case sets scatter_amount = 1.0 (previously the implicit, - unconfigurable, always-on value) and scatter_scale = (the spatial multiplier that value used to feed into - BOTH stages) so migrated params reproduce the old rendering exactly, - pixel for pixel, before the user ever touches the new sliders. - - v6 adds grain_usm_sigma and grain_usm_amount after grain blur (study b80). - v5 is a params-VALUE change, not a struct-shape change: sf_halation() - no longer applies eff = halation_amount^1.3 before scaling - halation_strength -- halation_amount is now a direct linear multiplier, - matching upstream's a_tot = halation_strength * halation_amount exactly. - Every version prior to v5 (v1 through v4, all of which share today's - dt_iop_spektrafilm_params_t layout for this field) rendered under the - ^1.3 curve, so every case below remaps - halation_amount := old_halation_amount^1.3 -- the exact inverse of - dropping the curve -- so a_tot stays numerically identical and every - migrated edit still renders pixel-for-pixel as before, even though nothing - about the struct's field layout changed. This is why a params version - bump is still required even for a pure algorithm/semantics change: without - it, darktable has no reason to call this function at all, and old edits - would silently double-apply/skip the curve on load. - - From this version onward, any further params struct or semantics change - should bump the version and add another case here rather than silently - drift again. */ - typedef struct dt_iop_spektrafilm_params_v1_t - { - uint32_t film_hash; - uint32_t paper_hash; - float exposure_ev; - float print_exposure_ev; - gboolean print_auto_exposure; - float print_contrast; - float filter_m; - float filter_y; - float couplers_amount; - gboolean scan_film; - dt_iop_spektrafilm_quality_t quality; - gboolean halation_on; - float halation_amount; - float halation_scale; - float boost_ev; - float boost_range; - float protect_ev; - gboolean diffusion_on; - float diffusion_strength; - float diffusion_scale; - float diffusion_warmth; - gboolean grain_on; - float grain_amount; - float grain_size; - float film_format_mm; - } dt_iop_spektrafilm_params_v1_t; - - typedef struct dt_iop_spektrafilm_params_v2_t - { - uint32_t film_hash; - uint32_t paper_hash; - float exposure_ev; - float print_exposure_ev; - gboolean print_auto_exposure; - float print_contrast; - float filter_m; - float filter_y; - float couplers_amount; - float preflash_exposure; - float preflash_m_shift; - float preflash_y_shift; - gboolean scan_film; - dt_iop_spektrafilm_quality_t quality; - gboolean halation_on; - float halation_amount; - float halation_scale; - float boost_ev; - float boost_range; - float protect_ev; - gboolean diffusion_on; - dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; - float diffusion_strength; - float diffusion_scale; - float diffusion_warmth; - gboolean grain_on; - float grain_amount; - float grain_size; - float film_format_mm; - float output_luminance_boost; - } dt_iop_spektrafilm_params_v2_t; - - typedef struct dt_iop_spektrafilm_params_v3_t - { - uint32_t film_hash; - uint32_t paper_hash; - float exposure_ev; - float print_exposure_ev; - gboolean print_auto_exposure; - float print_contrast; - float filter_m; - float filter_y; - float couplers_amount; - float preflash_exposure; - float preflash_m_shift; - float preflash_y_shift; - gboolean scan_film; - dt_iop_spektrafilm_quality_t quality; - gboolean halation_on; - float halation_amount; - float halation_scale; - float boost_ev; - float boost_range; - float protect_ev; - gboolean diffusion_on; - dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; - float diffusion_strength; - float diffusion_scale; - float diffusion_warmth; - gboolean print_diffusion_on; - dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; - float print_diffusion_strength; - float print_diffusion_scale; - float print_diffusion_warmth; - gboolean grain_on; - float grain_amount; - float grain_size; - float film_format_mm; - float output_luminance_boost; - } dt_iop_spektrafilm_params_v3_t; - - typedef struct dt_iop_spektrafilm_params_v4_t - { - uint32_t film_hash; - uint32_t paper_hash; - float exposure_ev; - float print_exposure_ev; - gboolean print_auto_exposure; - float print_contrast; - float filter_m; - float filter_y; - float couplers_amount; - float preflash_exposure; - float preflash_m_shift; - float preflash_y_shift; - gboolean scan_film; - dt_iop_spektrafilm_quality_t quality; - gboolean halation_on; - float scatter_amount; - float scatter_scale; - float halation_amount; - float halation_scale; - float boost_ev; - float boost_range; - float protect_ev; - gboolean diffusion_on; - dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; - float diffusion_strength; - float diffusion_scale; - float diffusion_warmth; - gboolean print_diffusion_on; - dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; - float print_diffusion_strength; - float print_diffusion_scale; - float print_diffusion_warmth; - gboolean grain_on; - float grain_amount; - float grain_size; - float film_format_mm; - float output_luminance_boost; - } dt_iop_spektrafilm_params_v4_t; - - typedef struct dt_iop_spektrafilm_params_v6_t - { - uint32_t film_hash; - uint32_t paper_hash; - float exposure_ev; - float print_exposure_ev; - gboolean print_auto_exposure; - float print_contrast; - float filter_m; - float filter_y; - float couplers_amount; - float preflash_exposure; - float preflash_m_shift; - float preflash_y_shift; - gboolean scan_film; - dt_iop_spektrafilm_quality_t quality; - gboolean halation_on; - float scatter_amount; - float scatter_scale; - float halation_amount; - float halation_scale; - float boost_ev; - float boost_range; - float protect_ev; - gboolean diffusion_on; - dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; - float diffusion_strength; - float diffusion_scale; - float diffusion_warmth; - gboolean print_diffusion_on; - dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; - float print_diffusion_strength; - float print_diffusion_scale; - float print_diffusion_warmth; - gboolean grain_on; - float grain_amount; - float grain_size; - float film_format_mm; - float output_luminance_boost; - float grain_usm_sigma; - float grain_usm_amount; - } dt_iop_spektrafilm_params_v6_t; - - typedef struct dt_iop_spektrafilm_params_v7_t - { - uint32_t film_hash; - uint32_t paper_hash; - float exposure_ev; - float print_exposure_ev; - gboolean print_auto_exposure; - float print_contrast; - float filter_m; - float filter_y; - float couplers_amount; - float preflash_exposure; - float preflash_m_shift; - float preflash_y_shift; - gboolean scan_film; - dt_iop_spektrafilm_quality_t quality; - gboolean halation_on; - float scatter_amount; - float scatter_scale; - float halation_amount; - float halation_scale; - float boost_ev; - float boost_range; - float protect_ev; - gboolean diffusion_on; - dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; - float diffusion_strength; - float diffusion_scale; - float diffusion_warmth; - gboolean print_diffusion_on; - dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; - float print_diffusion_strength; - float print_diffusion_scale; - float print_diffusion_warmth; - gboolean grain_on; - float grain_amount; - float grain_size; - float film_format_mm; - float output_luminance_boost; - float grain_usm_sigma; - float grain_usm_amount; - float film_gamma_factor; - float film_gamma_factor_fast; - float film_gamma_factor_slow; - float film_developer_exhaustion; - } dt_iop_spektrafilm_params_v7_t; - - typedef struct dt_iop_spektrafilm_params_v8_t - { - dt_iop_spektrafilm_params_v7_t v7; - float push_pull_stops; - } dt_iop_spektrafilm_params_v8_t; - - if(old_version == 1) - { - const dt_iop_spektrafilm_params_v1_t *o = (dt_iop_spektrafilm_params_v1_t *)old_params; - dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - - n->film_hash = o->film_hash; - n->paper_hash = o->paper_hash; - n->exposure_ev = o->exposure_ev; - n->print_exposure_ev = o->print_exposure_ev; - n->print_auto_exposure = o->print_auto_exposure; - n->print_contrast = o->print_contrast; - n->filter_m = o->filter_m; - n->filter_y = o->filter_y; - n->couplers_amount = o->couplers_amount; - n->preflash_exposure = 0.0f; /* new in v2: neutral default, no-op (matches upstream) */ - n->preflash_m_shift = 0.0f; /* new in v2: neutral default, no-op */ - n->preflash_y_shift = 0.0f; /* new in v2: neutral default, no-op */ - n->scan_film = o->scan_film; - n->quality = o->quality; - n->halation_on = o->halation_on; - /* new in v4: scatter used to be hardcoded fully-on and shared - halation_scale as its spatial multiplier -- reproduce that exactly. */ - n->scatter_amount = 1.0f; - n->scatter_scale = o->halation_scale; - n->halation_amount = powf(o->halation_amount, 1.3f); /* v5: undo dropped ^1.3 curve */ - n->halation_scale = o->halation_scale; - n->boost_ev = o->boost_ev; - n->boost_range = o->boost_range; - n->protect_ev = o->protect_ev; - n->diffusion_on = o->diffusion_on; - /* new in v2: the engine was hardcoded to Black Pro-Mist before the - family selector existed, so this exactly reproduces old saved - diffusion settings rather than just picking a neutral default. */ - n->diffusion_filter_family = DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST; - n->diffusion_strength = o->diffusion_strength; - n->diffusion_scale = o->diffusion_scale; - n->diffusion_warmth = o->diffusion_warmth; - /* new in v3: print diffusion never existed for anything saved against - v1, so it defaults off, same $DEFAULT the param itself declares. */ - n->print_diffusion_on = FALSE; - n->print_diffusion_filter_family = DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST; - n->print_diffusion_strength = 0.5f; - n->print_diffusion_scale = 1.0f; - n->print_diffusion_warmth = 0.0f; - n->grain_on = o->grain_on; - n->grain_amount = o->grain_amount; - n->grain_size = o->grain_size; - n->film_format_mm = o->film_format_mm; - n->output_luminance_boost = 1.0f; /* new in v2: neutral default, no-op (matches upstream) */ - n->grain_usm_sigma = 0.8f; - n->grain_usm_amount = 0.0f; - n->film_gamma_factor = 1.0f; - n->film_gamma_factor_fast = 1.0f; - n->film_gamma_factor_slow = 1.0f; - n->film_developer_exhaustion = 0.0f; - n->push_pull_stops = 0.0f; - - *new_params = n; - *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 8; - return 0; - } - if(old_version == 2) - { - const dt_iop_spektrafilm_params_v2_t *o = (dt_iop_spektrafilm_params_v2_t *)old_params; - dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - - n->film_hash = o->film_hash; - n->paper_hash = o->paper_hash; - n->exposure_ev = o->exposure_ev; - n->print_exposure_ev = o->print_exposure_ev; - n->print_auto_exposure = o->print_auto_exposure; - n->print_contrast = o->print_contrast; - n->filter_m = o->filter_m; - n->filter_y = o->filter_y; - n->couplers_amount = o->couplers_amount; - n->preflash_exposure = o->preflash_exposure; - n->preflash_m_shift = o->preflash_m_shift; - n->preflash_y_shift = o->preflash_y_shift; - n->scan_film = o->scan_film; - n->quality = o->quality; - n->halation_on = o->halation_on; - /* new in v4: scatter used to be hardcoded fully-on and shared - halation_scale as its spatial multiplier -- reproduce that exactly. */ - n->scatter_amount = 1.0f; - n->scatter_scale = o->halation_scale; - n->halation_amount = powf(o->halation_amount, 1.3f); /* v5: undo dropped ^1.3 curve */ - n->halation_scale = o->halation_scale; - n->boost_ev = o->boost_ev; - n->boost_range = o->boost_range; - n->protect_ev = o->protect_ev; - n->diffusion_on = o->diffusion_on; - n->diffusion_filter_family = o->diffusion_filter_family; - n->diffusion_strength = o->diffusion_strength; - n->diffusion_scale = o->diffusion_scale; - n->diffusion_warmth = o->diffusion_warmth; - /* new in v3: nothing saved against v2 ever had print diffusion, so it - defaults off, same $DEFAULT the param itself declares. */ - n->print_diffusion_on = FALSE; - n->print_diffusion_filter_family = DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST; - n->print_diffusion_strength = 0.5f; - n->print_diffusion_scale = 1.0f; - n->print_diffusion_warmth = 0.0f; - n->grain_on = o->grain_on; - n->grain_amount = o->grain_amount; - n->grain_size = o->grain_size; - n->film_format_mm = o->film_format_mm; - n->output_luminance_boost = o->output_luminance_boost; - n->grain_usm_sigma = 0.8f; - n->grain_usm_amount = 0.0f; - n->film_gamma_factor = 1.0f; - n->film_gamma_factor_fast = 1.0f; - n->film_gamma_factor_slow = 1.0f; - n->film_developer_exhaustion = 0.0f; - n->push_pull_stops = 0.0f; - - *new_params = n; - *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 8; - return 0; - } - if(old_version == 3) - { - const dt_iop_spektrafilm_params_v3_t *o = (dt_iop_spektrafilm_params_v3_t *)old_params; - dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - - n->film_hash = o->film_hash; - n->paper_hash = o->paper_hash; - n->exposure_ev = o->exposure_ev; - n->print_exposure_ev = o->print_exposure_ev; - n->print_auto_exposure = o->print_auto_exposure; - n->print_contrast = o->print_contrast; - n->filter_m = o->filter_m; - n->filter_y = o->filter_y; - n->couplers_amount = o->couplers_amount; - n->preflash_exposure = o->preflash_exposure; - n->preflash_m_shift = o->preflash_m_shift; - n->preflash_y_shift = o->preflash_y_shift; - n->scan_film = o->scan_film; - n->quality = o->quality; - n->halation_on = o->halation_on; - /* new in v4: scatter used to be hardcoded fully-on and shared - halation_scale as its spatial multiplier -- reproduce that exactly. */ - n->scatter_amount = 1.0f; - n->scatter_scale = o->halation_scale; - n->halation_amount = powf(o->halation_amount, 1.3f); /* v5: undo dropped ^1.3 curve */ - n->halation_scale = o->halation_scale; - n->boost_ev = o->boost_ev; - n->boost_range = o->boost_range; - n->protect_ev = o->protect_ev; - n->diffusion_on = o->diffusion_on; - n->diffusion_filter_family = o->diffusion_filter_family; - n->diffusion_strength = o->diffusion_strength; - n->diffusion_scale = o->diffusion_scale; - n->diffusion_warmth = o->diffusion_warmth; - n->print_diffusion_on = o->print_diffusion_on; - n->print_diffusion_filter_family = o->print_diffusion_filter_family; - n->print_diffusion_strength = o->print_diffusion_strength; - n->print_diffusion_scale = o->print_diffusion_scale; - n->print_diffusion_warmth = o->print_diffusion_warmth; - n->grain_on = o->grain_on; - n->grain_amount = o->grain_amount; - n->grain_size = o->grain_size; - n->film_format_mm = o->film_format_mm; - n->output_luminance_boost = o->output_luminance_boost; - n->grain_usm_sigma = 0.8f; - n->grain_usm_amount = 0.0f; - n->film_gamma_factor = 1.0f; - n->film_gamma_factor_fast = 1.0f; - n->film_gamma_factor_slow = 1.0f; - n->film_developer_exhaustion = 0.0f; - n->push_pull_stops = 0.0f; - - *new_params = n; - *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 8; - return 0; - } - if(old_version == 4) - { - const dt_iop_spektrafilm_params_v4_t *o = (dt_iop_spektrafilm_params_v4_t *)old_params; - dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - - n->film_hash = o->film_hash; - n->paper_hash = o->paper_hash; - n->exposure_ev = o->exposure_ev; - n->print_exposure_ev = o->print_exposure_ev; - n->print_auto_exposure = o->print_auto_exposure; - n->print_contrast = o->print_contrast; - n->filter_m = o->filter_m; - n->filter_y = o->filter_y; - n->couplers_amount = o->couplers_amount; - n->preflash_exposure = o->preflash_exposure; - n->preflash_m_shift = o->preflash_m_shift; - n->preflash_y_shift = o->preflash_y_shift; - n->scan_film = o->scan_film; - n->quality = o->quality; - n->halation_on = o->halation_on; - /* v4 already had independent scatter_amount/scatter_scale -- carry them - over unchanged, only halation_amount's curve is being removed here. */ - n->scatter_amount = o->scatter_amount; - n->scatter_scale = o->scatter_scale; - n->halation_amount = powf(o->halation_amount, 1.3f); /* v5: undo dropped ^1.3 curve */ - n->halation_scale = o->halation_scale; - n->boost_ev = o->boost_ev; - n->boost_range = o->boost_range; - n->protect_ev = o->protect_ev; - n->diffusion_on = o->diffusion_on; - n->diffusion_filter_family = o->diffusion_filter_family; - n->diffusion_strength = o->diffusion_strength; - n->diffusion_scale = o->diffusion_scale; - n->diffusion_warmth = o->diffusion_warmth; - n->print_diffusion_on = o->print_diffusion_on; - n->print_diffusion_filter_family = o->print_diffusion_filter_family; - n->print_diffusion_strength = o->print_diffusion_strength; - n->print_diffusion_scale = o->print_diffusion_scale; - n->print_diffusion_warmth = o->print_diffusion_warmth; - n->grain_on = o->grain_on; - n->grain_amount = o->grain_amount; - n->grain_size = o->grain_size; - n->film_format_mm = o->film_format_mm; - n->output_luminance_boost = o->output_luminance_boost; - n->grain_usm_sigma = 0.8f; - n->grain_usm_amount = 0.0f; - n->film_gamma_factor = 1.0f; - n->film_gamma_factor_fast = 1.0f; - n->film_gamma_factor_slow = 1.0f; - n->film_developer_exhaustion = 0.0f; - n->push_pull_stops = 0.0f; - - *new_params = n; - *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 8; - return 0; - } - if(old_version == 5) - { - const dt_iop_spektrafilm_params_t *o = (dt_iop_spektrafilm_params_t *)old_params; - dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - *n = *o; - n->grain_usm_sigma = 0.8f; - n->grain_usm_amount = 0.0f; - n->film_gamma_factor = 1.0f; - n->film_gamma_factor_fast = 1.0f; - n->film_gamma_factor_slow = 1.0f; - n->film_developer_exhaustion = 0.0f; - n->push_pull_stops = 0.0f; - *new_params = n; - *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 8; - return 0; - } - if(old_version == 6) - { - const dt_iop_spektrafilm_params_v6_t *o = (dt_iop_spektrafilm_params_v6_t *)old_params; - dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - - n->film_hash = o->film_hash; - n->paper_hash = o->paper_hash; - n->exposure_ev = o->exposure_ev; - n->print_exposure_ev = o->print_exposure_ev; - n->print_auto_exposure = o->print_auto_exposure; - n->print_contrast = o->print_contrast; - n->filter_m = o->filter_m; - n->filter_y = o->filter_y; - n->couplers_amount = o->couplers_amount; - n->preflash_exposure = o->preflash_exposure; - n->preflash_m_shift = o->preflash_m_shift; - n->preflash_y_shift = o->preflash_y_shift; - n->scan_film = o->scan_film; - n->quality = o->quality; - n->halation_on = o->halation_on; - n->scatter_amount = o->scatter_amount; - n->scatter_scale = o->scatter_scale; - n->halation_amount = o->halation_amount; - n->halation_scale = o->halation_scale; - n->boost_ev = o->boost_ev; - n->boost_range = o->boost_range; - n->protect_ev = o->protect_ev; - n->diffusion_on = o->diffusion_on; - n->diffusion_filter_family = o->diffusion_filter_family; - n->diffusion_strength = o->diffusion_strength; - n->diffusion_scale = o->diffusion_scale; - n->diffusion_warmth = o->diffusion_warmth; - n->print_diffusion_on = o->print_diffusion_on; - n->print_diffusion_filter_family = o->print_diffusion_filter_family; - n->print_diffusion_strength = o->print_diffusion_strength; - n->print_diffusion_scale = o->print_diffusion_scale; - n->print_diffusion_warmth = o->print_diffusion_warmth; - n->grain_on = o->grain_on; - n->grain_amount = o->grain_amount; - n->grain_size = o->grain_size; - n->film_format_mm = o->film_format_mm; - n->output_luminance_boost = o->output_luminance_boost; - n->grain_usm_sigma = o->grain_usm_sigma; - n->grain_usm_amount = o->grain_usm_amount; - n->film_gamma_factor = 1.0f; - n->film_gamma_factor_fast = 1.0f; - n->film_gamma_factor_slow = 1.0f; - n->film_developer_exhaustion = 0.0f; - n->push_pull_stops = 0.0f; - - *new_params = n; - *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 8; - return 0; - } - if(old_version == 7) - { - const dt_iop_spektrafilm_params_v7_t *o = (dt_iop_spektrafilm_params_v7_t *)old_params; - dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - - n->film_hash = o->film_hash; - n->paper_hash = o->paper_hash; - n->exposure_ev = o->exposure_ev; - n->print_exposure_ev = o->print_exposure_ev; - n->print_auto_exposure = o->print_auto_exposure; - n->print_contrast = o->print_contrast; - n->filter_m = o->filter_m; - n->filter_y = o->filter_y; - n->couplers_amount = o->couplers_amount; - n->preflash_exposure = o->preflash_exposure; - n->preflash_m_shift = o->preflash_m_shift; - n->preflash_y_shift = o->preflash_y_shift; - n->scan_film = o->scan_film; - n->quality = o->quality; - n->halation_on = o->halation_on; - n->scatter_amount = o->scatter_amount; - n->scatter_scale = o->scatter_scale; - n->halation_amount = o->halation_amount; - n->halation_scale = o->halation_scale; - n->boost_ev = o->boost_ev; - n->boost_range = o->boost_range; - n->protect_ev = o->protect_ev; - n->diffusion_on = o->diffusion_on; - n->diffusion_filter_family = o->diffusion_filter_family; - n->diffusion_strength = o->diffusion_strength; - n->diffusion_scale = o->diffusion_scale; - n->diffusion_warmth = o->diffusion_warmth; - n->print_diffusion_on = o->print_diffusion_on; - n->print_diffusion_filter_family = o->print_diffusion_filter_family; - n->print_diffusion_strength = o->print_diffusion_strength; - n->print_diffusion_scale = o->print_diffusion_scale; - n->print_diffusion_warmth = o->print_diffusion_warmth; - n->grain_on = o->grain_on; - n->grain_amount = o->grain_amount; - n->grain_size = o->grain_size; - n->film_format_mm = o->film_format_mm; - n->output_luminance_boost = o->output_luminance_boost; - n->grain_usm_sigma = o->grain_usm_sigma; - n->grain_usm_amount = o->grain_usm_amount; - n->film_gamma_factor = o->film_gamma_factor; - n->film_gamma_factor_fast = o->film_gamma_factor_fast; - n->film_gamma_factor_slow = o->film_gamma_factor_slow; - n->film_developer_exhaustion = o->film_developer_exhaustion; - n->push_pull_stops = 0.0f; - /* v9 fields: neutral, so a v7 preset keeps rendering as it did */ - n->scan_blur = 0.0f; - n->scan_usm_sigma = 0.7f; - n->scan_usm_amount = 0.0f; - n->glare_percent = 0.0f; - n->development_min = 0.0f; - n->print_development_min = 0.0f; - - *new_params = n; - *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 10; - return 0; - } - if(old_version == 8) - { - /* v9 adds the scanner stage (blur, unsharp mask) and the viewing-glare veil - ([sc]/[gl] in the reference: ScannerParams.lens_blur / unsharp_mask and - GlareParams). All three default to OFF here rather than to the reference's - own defaults, so an existing edit renders exactly as it did before. */ - const dt_iop_spektrafilm_params_v8_t *o = (dt_iop_spektrafilm_params_v8_t *)old_params; - dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - - n->film_hash = o->v7.film_hash; - n->paper_hash = o->v7.paper_hash; - n->exposure_ev = o->v7.exposure_ev; - n->print_exposure_ev = o->v7.print_exposure_ev; - n->print_auto_exposure = o->v7.print_auto_exposure; - n->print_contrast = o->v7.print_contrast; - n->filter_m = o->v7.filter_m; - n->filter_y = o->v7.filter_y; - n->couplers_amount = o->v7.couplers_amount; - n->preflash_exposure = o->v7.preflash_exposure; - n->preflash_m_shift = o->v7.preflash_m_shift; - n->preflash_y_shift = o->v7.preflash_y_shift; - n->scan_film = o->v7.scan_film; - n->quality = o->v7.quality; - n->halation_on = o->v7.halation_on; - n->scatter_amount = o->v7.scatter_amount; - n->scatter_scale = o->v7.scatter_scale; - n->halation_amount = o->v7.halation_amount; - n->halation_scale = o->v7.halation_scale; - n->boost_ev = o->v7.boost_ev; - n->boost_range = o->v7.boost_range; - n->protect_ev = o->v7.protect_ev; - n->diffusion_on = o->v7.diffusion_on; - n->diffusion_filter_family = o->v7.diffusion_filter_family; - n->diffusion_strength = o->v7.diffusion_strength; - n->diffusion_scale = o->v7.diffusion_scale; - n->diffusion_warmth = o->v7.diffusion_warmth; - n->print_diffusion_on = o->v7.print_diffusion_on; - n->print_diffusion_filter_family = o->v7.print_diffusion_filter_family; - n->print_diffusion_strength = o->v7.print_diffusion_strength; - n->print_diffusion_scale = o->v7.print_diffusion_scale; - n->print_diffusion_warmth = o->v7.print_diffusion_warmth; - n->grain_on = o->v7.grain_on; - n->grain_amount = o->v7.grain_amount; - n->grain_size = o->v7.grain_size; - n->film_format_mm = o->v7.film_format_mm; - n->output_luminance_boost = o->v7.output_luminance_boost; - n->grain_usm_sigma = o->v7.grain_usm_sigma; - n->grain_usm_amount = o->v7.grain_usm_amount; - n->film_gamma_factor = o->v7.film_gamma_factor; - n->film_gamma_factor_fast = o->v7.film_gamma_factor_fast; - n->film_gamma_factor_slow = o->v7.film_gamma_factor_slow; - n->film_developer_exhaustion = o->v7.film_developer_exhaustion; - n->push_pull_stops = o->push_pull_stops; - n->scan_blur = 0.0f; - n->scan_usm_sigma = 0.7f; - n->scan_usm_amount = 0.0f; - n->glare_percent = 0.0f; - n->development_min = 0.0f; - n->print_development_min = 0.0f; - - *new_params = n; - *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 10; - return 0; - } - if(old_version == 9) - { - /* v10 appends `development_min`, which picks a member of a B&W stock's - development-time family in minutes. 0 means "the stock's own default", the - representative middle member -- which is what v9 and earlier effectively - rendered whenever they got the layout right, so old edits keep that. */ - dt_iop_spektrafilm_params_t *n = malloc(sizeof(dt_iop_spektrafilm_params_t)); - memcpy(n, old_params, sizeof(*n) - sizeof(n->development_min) - sizeof(n->print_development_min)); - n->development_min = 0.0f; - n->print_development_min = 0.0f; - - *new_params = n; - *new_params_size = sizeof(dt_iop_spektrafilm_params_t); - *new_version = 10; - return 0; - } - return 1; -} - /* ---------------------------------------------------------------------- */ /* profile discovery */ /* ---------------------------------------------------------------------- */ -/* stable string hash for profile identity in params (same as the LUT module - used for bundles, so behaviour across machines/rescans is order-free) */ -static uint32_t sf_name_hash(const char *s) +/* Stable identity for a profile in params: the stock name hashed, so a rescan + or a different machine resolves the same stock regardless of directory order. + Folded to 32 bits because params store it as uint32_t. */ +static uint32_t _name_hash(const char *s) { - uint32_t h = 2166136261u; /* FNV-1a */ - for(const unsigned char *p = (const unsigned char *)s; *p; p++) - { - h ^= *p; - h *= 16777619u; - } - return h ? h : 1; /* 0 is reserved for "first available" */ + const dt_hash_t h = dt_hash(DT_INITHASH, s, strlen(s)); + const uint32_t h32 = (uint32_t)(h ^ (h >> 32)); + return h32 ? h32 : 1; /* 0 is reserved for "first available" */ } -static void sf_pack_dir(char *dst, size_t dstsz) +static void _pack_dir(char *dst, size_t dstsz) { char cfg[SF_PATH_LEN]; dt_loc_get_user_config_dir(cfg, sizeof cfg); @@ -1257,7 +507,7 @@ static void sf_pack_dir(char *dst, size_t dstsz) /* natural (human) string compare: embedded numbers compared numerically so "Vision3 50D" < "Vision3 200T" < "Vision3 500T" */ -static int sf_nat_cmp(const char *a, const char *b) +static int _nat_cmp(const char *a, const char *b) { for(;;) { @@ -1284,20 +534,26 @@ static int sf_nat_cmp(const char *a, const char *b) } } +static gint _entry_name_cmp(gconstpointer a, gconstpointer b) +{ + return _nat_cmp(((const sf_prof_entry_t *)a)->name, ((const sf_prof_entry_t *)b)->name); +} + /* scan /spektrafilm/profiles/ (all .json files); reads only the info header of - each profile (stock / name / stage / target_print) */ -static int sf_scan_profiles(sf_prof_entry_t *out, int maxn) + each profile (stock / name / stage / target_print). Returns a newly allocated + list of sf_prof_entry_t, sorted by name, which the caller owns. */ +static GList *_scan_profiles(void) { char dir[SF_PATH_LEN]; - sf_pack_dir(dir, sizeof dir); + _pack_dir(dir, sizeof dir); char profdir[SF_PATH_LEN + 16]; snprintf(profdir, sizeof profdir, "%s/profiles", dir); GDir *gd = g_dir_open(profdir, 0, NULL); - if(!gd) return 0; - int n = 0; + if(!gd) return NULL; + GList *list = NULL; const char *fn; - while(n < maxn && (fn = g_dir_read_name(gd))) + while((fn = g_dir_read_name(gd))) { if(!g_str_has_suffix(fn, ".json")) continue; char path[SF_PATH_LEN + 300]; @@ -1309,8 +565,7 @@ static int sf_scan_profiles(sf_prof_entry_t *out, int maxn) free(err); continue; } - sf_prof_entry_t *e = &out[n]; - memset(e, 0, sizeof(*e)); + sf_prof_entry_t *e = g_malloc0(sizeof(sf_prof_entry_t)); g_strlcpy(e->stock, sf_profile_stock(prof) ? sf_profile_stock(prof) : fn, SF_NAME_LEN); /* strip .json when falling back to the file name */ char *dot = strstr(e->stock, ".json"); @@ -1325,51 +580,51 @@ static int sf_scan_profiles(sf_prof_entry_t *out, int maxn) const char *cm = sf_profile_channel_model(prof); e->bw = (cm && !strcmp(cm, "bw")); e->n_dev = sf_profile_dev_times(prof, e->dev_times, SF_MAX_DEV_TIMES); - e->hash = sf_name_hash(e->stock); + e->hash = _name_hash(e->stock); sf_profile_free(prof); - n++; + list = g_list_prepend(list, e); } g_dir_close(gd); /* natural order by display name (numbers compared numerically, so "50D" < "200T" instead of lexicographic "200T" < "50D") */ - for(int i = 0; i < n; i++) - for(int j = i + 1; j < n; j++) - if(sf_nat_cmp(out[j].name, out[i].name) < 0) - { - sf_prof_entry_t t = out[i]; - out[i] = out[j]; - out[j] = t; - } - return n; + return g_list_sort(list, _entry_name_cmp); } /* resolve a profile hash to its stock name. hash 0 -> default: for films the first filming stock, for papers prefer the film's target_print. Returns false when nothing matches. */ -static gboolean sf_resolve_stock(const sf_prof_entry_t *entries, int n, uint32_t hash, - gboolean want_printing, const char *prefer_stock, - char *dst, size_t dstsz) +static gboolean _resolve_stock(GList *entries, uint32_t hash, gboolean want_printing, + const char *prefer_stock, char *dst, size_t dstsz) { if(hash) - for(int i = 0; i < n; i++) - if(entries[i].hash == hash && entries[i].printing == want_printing) + for(GList *l = entries; l; l = l->next) + { + const sf_prof_entry_t *e = l->data; + if(e->hash == hash && e->printing == want_printing) { - g_strlcpy(dst, entries[i].stock, dstsz); + g_strlcpy(dst, e->stock, dstsz); return TRUE; } + } if(prefer_stock && prefer_stock[0]) - for(int i = 0; i < n; i++) - if(entries[i].printing == want_printing && !strcmp(entries[i].stock, prefer_stock)) + for(GList *l = entries; l; l = l->next) + { + const sf_prof_entry_t *e = l->data; + if(e->printing == want_printing && !strcmp(e->stock, prefer_stock)) { - g_strlcpy(dst, entries[i].stock, dstsz); + g_strlcpy(dst, e->stock, dstsz); return TRUE; } - for(int i = 0; i < n; i++) - if(entries[i].printing == want_printing) + } + for(GList *l = entries; l; l = l->next) + { + const sf_prof_entry_t *e = l->data; + if(e->printing == want_printing) { - g_strlcpy(dst, entries[i].stock, dstsz); + g_strlcpy(dst, e->stock, dstsz); return TRUE; } + } return FALSE; } @@ -1383,6 +638,7 @@ void init_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe dt_pthread_mutex_init(&d->lock, NULL); piece->data = d; } + void cleanup_pipe(dt_iop_module_t *self, dt_dev_pixelpipe_t *pipe, dt_dev_pixelpipe_iop_t *piece) { dt_iop_spektrafilm_data_t *d = (dt_iop_spektrafilm_data_t *)piece->data; @@ -1518,7 +774,7 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, if(!_pack && !_pack_error[0]) { char dir[SF_PATH_LEN]; - sf_pack_dir(dir, sizeof dir); + _pack_dir(dir, sizeof dir); char *err = NULL; _pack = sf_pack_load(dir, &err); if(!_pack) @@ -1540,31 +796,36 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, } /* resolve stocks */ - sf_prof_entry_t entries[SF_MAX_PROFILES]; - const int n = sf_scan_profiles(entries, SF_MAX_PROFILES); + GList *entries = _scan_profiles(); char film_stock[SF_NAME_LEN] = { 0 }, paper_stock[SF_NAME_LEN] = { 0 }; - if(!sf_resolve_stock(entries, n, p->film_hash, FALSE, "kodak_portra_400", film_stock, - sizeof film_stock)) + if(!_resolve_stock(entries, p->film_hash, FALSE, "kodak_portra_400", film_stock, + sizeof film_stock)) { g_strlcpy(d->sim_error, "no filming profiles found", sizeof d->sim_error); + g_list_free_full(entries, g_free); dt_pthread_mutex_unlock(&d->lock); return NULL; } const char *target_print = NULL; - for(int i = 0; i < n; i++) - if(!entries[i].printing && !strcmp(entries[i].stock, film_stock)) - target_print = entries[i].target_print; + for(GList *l = entries; l; l = l->next) + { + const sf_prof_entry_t *e = l->data; + if(!e->printing && !strcmp(e->stock, film_stock)) target_print = e->target_print; + } if(!p->scan_film - && !sf_resolve_stock(entries, n, p->paper_hash, TRUE, target_print, paper_stock, - sizeof paper_stock)) + && !_resolve_stock(entries, p->paper_hash, TRUE, target_print, paper_stock, + sizeof paper_stock)) { g_strlcpy(d->sim_error, "no printing profiles found", sizeof d->sim_error); + g_list_free_full(entries, g_free); dt_pthread_mutex_unlock(&d->lock); return NULL; } + g_list_free_full(entries, g_free); /* stocks resolved; the list is done */ + char dir[SF_PATH_LEN], path[SF_PATH_LEN + 300]; - sf_pack_dir(dir, sizeof dir); + _pack_dir(dir, sizeof dir); char *err = NULL; snprintf(path, sizeof path, "%s/profiles/%s.json", dir, film_stock); sf_profile_t *film = sf_profile_load(path, p->development_min, &err); @@ -2859,15 +2120,15 @@ int process_cl(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, cl_mem dev_ static void _rescan(dt_iop_module_t *self) { dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; - g->n_entries = sf_scan_profiles(g->entries, SF_MAX_PROFILES); - g->n_films = g->n_papers = 0; - for(int i = 0; i < g->n_entries; i++) - { - if(g->entries[i].printing) - g->paper_entry[g->n_papers++] = i; - else - g->film_entry[g->n_films++] = i; - } + g_list_free_full(g->entries, g_free); + g->entries = _scan_profiles(); +} + +/* Entry at a list position, or NULL. The comboboxes carry the position as their + data, so this is how a user selection resolves back to a profile. */ +static const sf_prof_entry_t *_entry_at(const dt_iop_spektrafilm_gui_data_t *g, const int pos) +{ + return (pos >= 0) ? g_list_nth_data(g->entries, pos) : NULL; } static void _update_print_sensitivity(dt_iop_module_t *self); @@ -2880,9 +2141,9 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) if(darktable.gui->reset) return; dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; - const int fi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film)); - if(fi < 0) return; - const sf_prof_entry_t *e = &g->entries[fi]; + const sf_prof_entry_t *e + = _entry_at(g, GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film))); + if(!e) return; p->film_hash = e->hash; /* A development time from the previous stock means nothing here -- the times differ per stock, and a stale value can sit above the new stock's longest @@ -2892,46 +2153,11 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) DT_ENTER_GUI_UPDATE(); dt_bauhaus_slider_set(g->development_min, p->development_min); DT_LEAVE_GUI_UPDATE(); - /* scan_film's checkbox is a plain GtkCheckButton (dt_bauhaus_toggle_from_ - params), not a bauhaus widget -- the real reset mechanism for it - (dt_bauhaus_toggle_widget_reset, develop/imageop_gui.c, part of this - fork's own darktable-core toggle-reset patch) reads - self->default_params live, via pointer offset, at the moment of the - reset -- there is no separate cached value to poke; mutating - default_params->scan_film here IS the mechanism, not a workaround for - it. This covers BOTH of that patch's reset paths: a right-click/ - scroll reset on just this checkbox, and double-clicking the notebook - tab bar (_reset_all_bauhaus -> dt_bauhaus_toggle_widget_reset for - every checkbox on the current page). Without this, a positive/ - reversal film's scan_film (which has no print stage and needs - scan_film TRUE) would silently revert to the compiled FALSE default - on either gesture. - This alone would reintroduce an old bug, though: a later WHOLE-MODULE - reset (_gui_reset_callback -> dt_iop_reload_defaults -> - dt_iop_load_default_params's memcpy(params, default_params, ...)) - copies default_params verbatim, INCLUDING film_hash, which stays - pinned at the compiled default (kodak_portra_400, per gui_update's - own fallback) regardless of what's mutated here -- so a whole-module - reset needs default_params->scan_film to already match THAT film - (FALSE), not whatever film was selected right before the reset was - clicked. See reload_defaults() below, which restores exactly that, - and runs before the memcpy specifically so a single reset click - converges correctly on the first try. */ - if(self->default_params) - { - dt_iop_spektrafilm_params_t *dp = (dt_iop_spektrafilm_params_t *)self->default_params; - dp->scan_film = e->positive; - /* the checkbox caches the default it was created with, the way sliders - and comboboxes do, so re-baselining the param's default has to - re-baseline the widget's too. Otherwise a reset would return to the - default of whichever film happened to be selected when the module's - gui was built, and the tab's "changed" marker would compare against - that same stale value. */ - dt_bauhaus_toggle_set_default(g->scan_film, dp->scan_film); - /* same reasoning as the slider's own default above, for the reset path that - memcpys default_params over params wholesale */ - dp->development_min = p->development_min; - } + /* A positive/reversal stock has no print stage, so its natural mode is + scan_film. Point the widget's reset target at that, so a reset gesture on + the checkbox lands on what THIS film wants rather than the compiled + default. */ + dt_bauhaus_toggle_set_default(g->scan_film, e->positive); /* scan-film follows the film's natural mode on a film switch: slides and reversal stocks are viewed directly (scan), negatives go through the print stage. The user can still toggle freely afterwards -- this only @@ -2947,14 +2173,20 @@ static void _film_changed(GtkWidget *w, dt_iop_module_t *self) /* if the paper is still on "auto" (hash 0) keep it following the film's target print; otherwise leave the explicit user choice alone */ if(p->paper_hash == 0 && e->target_print[0]) - for(int k = 0; k < g->n_papers; k++) - if(!strcmp(g->entries[g->paper_entry[k]].stock, e->target_print)) + { + int pos = 0; + for(const GList *l = g->entries; l; l = l->next, pos++) + { + const sf_prof_entry_t *pe = l->data; + if(pe->printing && !strcmp(pe->stock, e->target_print)) { DT_ENTER_GUI_UPDATE(); - dt_bauhaus_combobox_set_from_value(g->paper, g->paper_entry[k]); + dt_bauhaus_combobox_set_from_value(g->paper, pos); DT_LEAVE_GUI_UPDATE(); break; } + } + } /* last, once scan_film and the auto-followed paper have settled: both development sliders are gated on their own stock, and the print one also on there being a print stage at all */ @@ -2967,14 +2199,12 @@ static void _paper_changed(GtkWidget *w, dt_iop_module_t *self) if(darktable.gui->reset) return; dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; - const int pi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->paper)); - if(pi < 0) return; - p->paper_hash = g->entries[pi].hash; + const sf_prof_entry_t *pe + = _entry_at(g, GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->paper))); + if(!pe) return; + p->paper_hash = pe->hash; /* same as _film_changed: a time from the previous paper does not transfer */ - p->print_development_min = _development_default(&g->entries[pi]); - if(self->default_params) - ((dt_iop_spektrafilm_params_t *)self->default_params)->print_development_min - = p->print_development_min; + p->print_development_min = _development_default(pe); DT_ENTER_GUI_UPDATE(); dt_bauhaus_slider_set(g->print_development_min, p->print_development_min); DT_LEAVE_GUI_UPDATE(); @@ -2982,15 +2212,16 @@ static void _paper_changed(GtkWidget *w, dt_iop_module_t *self) dt_dev_add_history_item(darktable.develop, self, TRUE); } -/* Entry for a stock hash, or NULL. `printing` picks which of the two lists to - search, since a stock can appear as film or as paper. */ +/* Entry for a stock hash, or NULL. `printing` disambiguates, since the same + stock name can exist as both a film and a paper. */ static const sf_prof_entry_t *_entry_by_hash(const dt_iop_spektrafilm_gui_data_t *g, const uint32_t hash, const gboolean printing) { - const int n = printing ? g->n_papers : g->n_films; - const int *idx = printing ? g->paper_entry : g->film_entry; - for(int i = 0; i < n; i++) - if(g->entries[idx[i]].hash == hash) return &g->entries[idx[i]]; + for(const GList *l = g->entries; l; l = l->next) + { + const sf_prof_entry_t *e = l->data; + if(e->hash == hash && e->printing == printing) return e; + } return NULL; } @@ -3148,38 +2379,6 @@ static void _toggle_sensitivity(dt_iop_spektrafilm_gui_data_t *g, _update_development_sensitivity(g, p); } -/* dt_iop_reload_defaults() runs module->reload_defaults() (this function, - if present) BEFORE dt_iop_load_default_params()'s memcpy(params, - default_params, ...) -- see _gui_reset_callback, develop/imageop.c. - _film_changed/gui_update mutate self->default_params->scan_film to - track whatever film is CURRENTLY selected (see the comment in - _film_changed for why that mutation, not a separate cache, is the real - core toggle-reset mechanism for this checkbox). But a whole-module - reset's memcpy also resets film_hash itself, unconditionally, back to - the compiled default (0, which gui_update's own fallback resolves to - kodak_portra_400 -- a negative film, scan_film FALSE) -- regardless of - what film was selected right before the reset was clicked. Without - this, default_params->scan_film could still be TRUE (from a - previously-selected positive film) at the moment the memcpy runs, - copying a value that doesn't match the film_hash it's paired with in - the same struct; only the NEXT reload_defaults() (triggered by that - same reset's own gui_update(), which re-baselines it for the film the - combobox fell back to) would catch up, so the first reset click landed - wrong and a second was needed to actually converge. Reset it back to - the one film_hash=0 always actually means, right before the memcpy - copies it, so the first click is correct. */ -void reload_defaults(dt_iop_module_t *self) -{ - if(self->default_params) - ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = FALSE; - - /* keep the widget's cached default in step with the one just restored, - for the same reason _film_changed() does after re-baselining it */ - dt_iop_spektrafilm_gui_data_t *g = self->gui_data; - if(g && g->scan_film) - dt_bauhaus_toggle_set_default(g->scan_film, FALSE); -} - void gui_reset(dt_iop_module_t *self) { dt_iop_color_picker_reset(self, TRUE); @@ -3218,106 +2417,96 @@ void gui_update(dt_iop_module_t *self) dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; _rescan(self); + + /* Films and papers share one list; e->printing separates them, and each + combobox entry carries its position in that list as its data. */ + static const struct { int pos; int bw; const char *label; } groups[] = { + { 0, 0, N_("negative color") }, + { 0, 1, N_("negative monochrome") }, + { 1, 0, N_("positive color") }, + { 1, 1, N_("positive monochrome") }, + }; + static const struct { int bw; const char *label; } pgroups[] = { + { 0, N_("color") }, + { 1, N_("monochrome") }, + }; + dt_bauhaus_combobox_clear(g->film); - if(g->n_films == 0) - dt_bauhaus_combobox_add(g->film, _("(no profiles found)")); - else + gboolean any_film = FALSE; + for(int gi = 0; gi < 4; gi++) { - static const struct { int pos; int bw; const char *label; } groups[] = { - { 0, 0, N_("negative color") }, - { 1, 0, N_("positive color") }, - { 0, 1, N_("negative monochrome") }, - { 1, 1, N_("positive monochrome") }, - }; - for(int gi = 0; gi < 4; gi++) + gboolean first = TRUE; + int pos = 0; + for(const GList *l = g->entries; l; l = l->next, pos++) { - gboolean first = TRUE; - for(int f = 0; f < g->n_films; f++) - { - const sf_prof_entry_t *e = &g->entries[g->film_entry[f]]; - if(e->positive != groups[gi].pos || e->bw != groups[gi].bw) continue; - if(first) { dt_bauhaus_combobox_add_section(g->film, _(groups[gi].label)); first = FALSE; } - dt_bauhaus_combobox_add_full(g->film, e->name, - DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, - GINT_TO_POINTER(g->film_entry[f]), - NULL, TRUE); - } + const sf_prof_entry_t *e = l->data; + if(e->printing || e->positive != groups[gi].pos || e->bw != groups[gi].bw) continue; + if(first) { dt_bauhaus_combobox_add_section(g->film, _(groups[gi].label)); first = FALSE; } + dt_bauhaus_combobox_add_full(g->film, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, + GINT_TO_POINTER(pos), NULL, TRUE); + any_film = TRUE; } } + if(!any_film) dt_bauhaus_combobox_add(g->film, _("(no profiles found)")); + dt_bauhaus_combobox_clear(g->paper); - if(g->n_papers == 0) - dt_bauhaus_combobox_add(g->paper, _("(none)")); - else + gboolean any_paper = FALSE; + for(int gi = 0; gi < 2; gi++) { - static const struct { int bw; const char *label; } pgroups[] = { - { 0, N_("color") }, - { 1, N_("monochrome") }, - }; - for(int gi = 0; gi < 2; gi++) + gboolean first = TRUE; + int pos = 0; + for(const GList *l = g->entries; l; l = l->next, pos++) { - gboolean first = TRUE; - for(int k = 0; k < g->n_papers; k++) - { - const sf_prof_entry_t *e = &g->entries[g->paper_entry[k]]; - if(e->bw != pgroups[gi].bw) continue; - if(first) { dt_bauhaus_combobox_add_section(g->paper, _(pgroups[gi].label)); first = FALSE; } - dt_bauhaus_combobox_add_full(g->paper, e->name, - DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, - GINT_TO_POINTER(g->paper_entry[k]), - NULL, TRUE); - } + const sf_prof_entry_t *e = l->data; + if(!e->printing || e->bw != pgroups[gi].bw) continue; + if(first) { dt_bauhaus_combobox_add_section(g->paper, _(pgroups[gi].label)); first = FALSE; } + dt_bauhaus_combobox_add_full(g->paper, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, + GINT_TO_POINTER(pos), NULL, TRUE); + any_paper = TRUE; } } - - int fi = 0; - gboolean film_matched = FALSE; - for(int f = 0; f < g->n_films; f++) - if(g->entries[g->film_entry[f]].hash == p->film_hash) { fi = f; film_matched = TRUE; } - if(!film_matched) - { - /* no hash match (fresh param with film_hash==0, or the saved stock - vanished from the pack) -- mirror sf_resolve_stock's fallback so the - combobox agrees with what the pixel pipeline actually renders, instead - of silently landing on whatever sorts first (e.g. "Fujifilm C200" - alphabetically before "Kodak Portra 400") while the pipe renders the - real default. */ - for(int f = 0; f < g->n_films; f++) - if(!strcmp(g->entries[g->film_entry[f]].stock, "kodak_portra_400")) fi = f; + if(!any_paper) dt_bauhaus_combobox_add(g->paper, _("(none)")); + + /* Select the saved film. On no hash match -- a fresh param with film_hash 0, + or a stock that vanished from the pack -- mirror _resolve_stock's fallback + so the combobox agrees with what the pipeline actually renders, instead of + landing on whatever sorts first while the pipe renders the real default. */ + int fpos = -1, fallback = -1, pos = 0; + const sf_prof_entry_t *fe = NULL; + for(const GList *l = g->entries; l; l = l->next, pos++) + { + const sf_prof_entry_t *e = l->data; + if(e->printing) continue; + if(fallback < 0 || !strcmp(e->stock, "kodak_portra_400")) fallback = pos; + if(p->film_hash && e->hash == p->film_hash) { fpos = pos; fe = e; } } - dt_bauhaus_combobox_set_from_value(g->film, g->film_entry[fi]); - /* _film_changed() is a no-op during the combobox-set above (it bails out - on darktable.gui->reset, which gui_update runs under, so programmatic - loads don't get treated as user edits / spawn spurious history items). - That means its self->default_params->scan_film re-baselining (see the - fuller comment there for why this field, not a separate cache, IS the - real core toggle-reset mechanism) never runs on a fresh module load, - only when the user actually interacts with the film combobox. Left - alone, it stays at whatever it was last set to -- possibly from a - PREVIOUSLY loaded image's film -- after e.g. switching to a different - image with a positive/reversal film loaded (which has no print stage - and needs scan_film TRUE): the checkbox itself still shows correctly - checked here (synced from p->scan_film below), but a later - right-click/scroll or tab-bar-double-click reset would silently flip - scan_film back off. Re-baseline it here too, exactly like - _film_changed does -- but WITHOUT touching p->scan_film itself, since - the just-loaded value may be a deliberate user override away from the - film's natural mode and must be preserved on load; only the reset - target needs fixing. */ - if(fi < g->n_films && self->default_params) + if(fpos < 0) fpos = fallback; + if(fpos >= 0) { - const sf_prof_entry_t *e = &g->entries[g->film_entry[fi]]; - ((dt_iop_spektrafilm_params_t *)self->default_params)->scan_film = e->positive; + if(!fe) fe = _entry_at(g, fpos); + dt_bauhaus_combobox_set_from_value(g->film, fpos); } - int pi = 0; - const char *target = (fi < g->n_films) ? g->entries[g->film_entry[fi]].target_print : NULL; - for(int k = 0; k < g->n_papers; k++) - { - const sf_prof_entry_t *e = &g->entries[g->paper_entry[k]]; - if(p->paper_hash ? (e->hash == p->paper_hash) - : (target && !strcmp(e->stock, target))) - pi = k; + + /* _film_changed() bails out under darktable.gui->reset, which gui_update runs + under, so its reset target never gets set on a plain module load. Do it here + too, or a reset gesture on a positive/reversal film would flip scan_film off. + p->scan_film itself is deliberately not touched: the loaded value may be an + intentional override and must survive the load. */ + if(fe) dt_bauhaus_toggle_set_default(g->scan_film, fe->positive); + + const char *target = fe ? fe->target_print : NULL; + int ppos = -1, pfirst = -1; + pos = 0; + for(const GList *l = g->entries; l; l = l->next, pos++) + { + const sf_prof_entry_t *e = l->data; + if(!e->printing) continue; + if(pfirst < 0) pfirst = pos; + if(p->paper_hash ? (e->hash == p->paper_hash) : (target && !strcmp(e->stock, target))) + ppos = pos; } - dt_bauhaus_combobox_set_from_value(g->paper, g->paper_entry[pi]); + if(ppos < 0) ppos = pfirst; + if(ppos >= 0) dt_bauhaus_combobox_set_from_value(g->paper, ppos); dt_bauhaus_combobox_set_from_value(g->film_format_combo, _format_mm_to_preset(p->film_format_mm)); @@ -3785,6 +2974,16 @@ void gui_init(dt_iop_module_t *self) self->widget = sf_main_box; } +void gui_cleanup(dt_iop_module_t *self) +{ + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; + if(g) + { + g_list_free_full(g->entries, g_free); + g->entries = NULL; + } +} + // clang-format off // modelines // vim: shiftwidth=2 expandtab tabstop=2 cindent From 1a6bbf4c1cc0dac32311e67307e9ed1c1beaf6ff Mon Sep 17 00:00:00 2001 From: piratenpanda Date: Wed, 29 Jul 2026 06:51:52 +0200 Subject: [PATCH 56/56] move to float16 LUT as reference, fix grain opencl path again, fix diffusion filters and clarify scatter --- src/common/spektra_core.c | 6 +- src/common/spektra_core.h | 19 +++++++ src/common/spektra_sim.c | 116 +++++++++++++++++++++++++++++++++++--- src/common/spektra_sim.h | 12 ++++ src/iop/spektrafilm.c | 111 ++++++++++++++++++++++++++++++------ 5 files changed, 239 insertions(+), 25 deletions(-) diff --git a/src/common/spektra_core.c b/src/common/spektra_core.c index 6a6b8e36b643..3704cec7907f 100644 --- a/src/common/spektra_core.c +++ b/src/common/spektra_core.c @@ -87,8 +87,12 @@ int sf_gauss_kernel_1d(const float sigma, float *const kernel, const int max_rad * scatter core and tail, the diffusion bank -- was chosen by eye against renders * made THROUGH that filter, so matching it is what reproduces the intended look; * being independently correct just makes every halo about 10% too tight. */ -void sf_gauss_yvv_coeffs(const float sigma, float out[4]) +void sf_gauss_yvv_coeffs(const float sigma_req, float out[4]) { + /* clamped for both callers at once -- the CPU dispatch in _blur_flat_inplace + and _sf_yvv_blur_cl on the GPU both come through here, so they cannot drift + apart. See SF_GAUSS_MAX_IIR_SIGMA. */ + const float sigma = fminf(sigma_req, SF_GAUSS_MAX_IIR_SIGMA); const double s = (double)sigma; const double q = (s >= 2.5) ? (0.98711 * s - 0.96330) : (3.97156 - 4.14554 * sqrt(1.0 - 0.26891 * s)); diff --git a/src/common/spektra_core.h b/src/common/spektra_core.h index edbf33ded16d..f47eff6687d6 100644 --- a/src/common/spektra_core.h +++ b/src/common/spektra_core.h @@ -188,6 +188,25 @@ SPEKTRA_INLINE uint32_t sf_pixel_seed(uint32_t xi, uint32_t yi, uint32_t chan) /* Young-van Vliet order-3 recursive Gaussian coefficients (B, B1, B2, B3), identical to the reference's _yvv_coeffs. Exported so the GPU host side can build the same filter the CPU runs. */ +/* Widest sigma the recursive filter is asked for. Above this its float32 + coefficients stop describing the filter we want: B falls to ~1e-7 while + B1..B3 stay near 3, and the poles walk out to the unit circle. Measured + effective vs requested sigma, single pass, float32: + + requested 100 150 200 400 700 + effective 102 155 254 875 105395 + + -- so it tracks to ~150, is unusable by 200, and diverges outright past ~700, + which is where cinebloom and pro-mist land at export resolution (their bloom + reaches 2500 um and 1625 um, ~1000 px on a 6000 px frame at 26 mm). The + divergence shows as full-height coloured striping: the column pass runs after + the row pass, so each column blows up on its own. + + Clamping keeps the filter inside the range where it is a Gaussian at the cost + of a narrower halo than asked for at extreme diffusion settings. That is a + stopgap, not the answer -- a large-sigma blur wants downsample/blur/upsample, + which is also faster. This just stops it producing garbage in the meantime. */ +#define SF_GAUSS_MAX_IIR_SIGMA 150.0f void sf_gauss_yvv_coeffs(float sigma, float out[4]); /* Build a normalized, truncated 1D Gaussian kernel. truncate = 3 sigma with diff --git a/src/common/spektra_sim.c b/src/common/spektra_sim.c index 02409b4c5a82..997af48f15d0 100644 --- a/src/common/spektra_sim.c +++ b/src/common/spektra_sim.c @@ -227,6 +227,9 @@ struct sf_pack_t JsonNode *neutral_filters; /* nested object database */ JsonNode *film_defaults; /* per-film render defaults */ JsonParser *parser; /* keeps the JSON tree alive */ + /* identity of the spectral upsampling table, from its header */ + char lut_id[256]; + uint32_t lut_hash; /* hanatos2025 irradiance spectra LUT */ int tc_n; /* 192 */ float *spectra; /* tc_n * tc_n * SF_NWL */ @@ -451,6 +454,39 @@ static int json_read_darray_upto(JsonObject *obj, const char *key, double *out, return n; } +/* IEEE 754 binary16 -> binary32. Handles subnormals and inf/NaN; the LUT is + plain finite reflectance data, but a decoder that quietly mangles the edge + cases is worse than one that costs a branch on a once-per-load path. */ +static inline float _sf_half_to_float(const uint16_t h) +{ + const uint32_t sign = (uint32_t)(h & 0x8000u) << 16; + const uint32_t exp = (h >> 10) & 0x1fu; + const uint32_t mant = h & 0x3ffu; + uint32_t bits; + if(exp == 0) + { + if(mant == 0) + bits = sign; /* +-0 */ + else + { + /* subnormal: normalise */ + uint32_t e = exp, m = mant; + int shift = 0; + while(!(m & 0x400u)) { m <<= 1; shift++; } + m &= 0x3ffu; + e = 127 - 15 - shift + 1; + bits = sign | (e << 23) | (m << 13); + } + } + else if(exp == 0x1fu) + bits = sign | 0x7f800000u | (mant << 13); /* inf / NaN */ + else + bits = sign | ((exp + 127 - 15) << 23) | (mant << 13); + float f; + memcpy(&f, &bits, sizeof f); + return f; +} + static gboolean json_read_dmatrix(JsonObject *obj, const char *key, double *out, int n, int m) { if(!json_object_has_member(obj, key)) return FALSE; @@ -488,6 +524,9 @@ static void set_error(char **errmsg, const char *fmt, ...) /* pack loading */ /* ------------------------------------------------------------------------ */ +uint32_t sf_pack_lut_hash(const sf_pack_t *pack) { return pack ? pack->lut_hash : 0u; } +const char *sf_pack_lut_id(const sf_pack_t *pack) { return pack ? pack->lut_id : ""; } + void sf_pack_free(sf_pack_t *pack) { if(!pack) return; @@ -583,7 +622,18 @@ sf_pack_t *sf_pack_load(const char *dir, char **errmsg) if(json_object_has_member(root, "film_render_defaults")) pack->film_defaults = json_object_get_member(root, "film_render_defaults"); - /* hanatos2025 spectra LUT */ + /* Spectral upsampling LUT. + + Upstream revises this table often -- arctic2026 alpha, alpha02, beta01 + through beta04 so far, with the older ones deleted as each lands -- and + every revision changes every render. So the header carries the table's + identity as well as its shape, and that identity is recorded in params: + an edit made against one revision and reopened against another is reported + rather than silently rendering differently. + + Stored as float16, which is what upstream computes and ships; widened here + on load. Reading it as float32 doubled the file for precision that was + never in the source data. */ { FILE *fh = g_fopen(lut_path, "rb"); if(!fh) @@ -592,18 +642,52 @@ sf_pack_t *sf_pack_load(const char *dir, char **errmsg) goto fail; } char magic[4]; - int32_t dims[3]; - if(fread(magic, 1, 4, fh) != 4 || memcmp(magic, "SFSL", 4) != 0 - || fread(dims, 4, 3, fh) != 3 || dims[0] != dims[1] || dims[2] != SF_NWL) + int32_t hdr_version = 0, dims[3], dtype = 0, id_len = 0; + uint32_t lut_hash = 0; + if(fread(magic, 1, 4, fh) != 4 || memcmp(magic, "SFS2", 4) != 0 + || fread(&hdr_version, 4, 1, fh) != 1 || hdr_version != 2 + || fread(dims, 4, 3, fh) != 3 || dims[0] != dims[1] || dims[2] != SF_NWL + || fread(&dtype, 4, 1, fh) != 1 || (dtype != 0 && dtype != 1) + || fread(&lut_hash, 4, 1, fh) != 1 || fread(&id_len, 4, 1, fh) != 1 + || id_len < 0 || id_len > 255) + { + set_error(errmsg, + "spektra_sim: %s is not a v2 spectral LUT -- regenerate the data " + "pack with spektrafilm_export_data.py", + lut_path); + fclose(fh); + goto fail; + } + if(id_len && fread(pack->lut_id, 1, id_len, fh) != (size_t)id_len) { - set_error(errmsg, "spektra_sim: bad spectra_lut header in %s", lut_path); + set_error(errmsg, "spektra_sim: truncated spectra lut header in %s", lut_path); fclose(fh); goto fail; } + pack->lut_id[id_len] = 0; + pack->lut_hash = lut_hash; pack->tc_n = dims[0]; + const size_t count = (size_t)dims[0] * dims[1] * dims[2]; pack->spectra = malloc(count * sizeof(float)); - if(!pack->spectra || fread(pack->spectra, sizeof(float), count, fh) != count) + if(!pack->spectra) + { + set_error(errmsg, "spektra_sim: out of memory for spectra lut"); + fclose(fh); + goto fail; + } + gboolean ok; + if(dtype == 1) /* float16 -> float32 */ + { + uint16_t *h16 = malloc(count * sizeof(uint16_t)); + ok = h16 && fread(h16, sizeof(uint16_t), count, fh) == count; + if(ok) + for(size_t i = 0; i < count; i++) pack->spectra[i] = _sf_half_to_float(h16[i]); + free(h16); + } + else + ok = fread(pack->spectra, sizeof(float), count, fh) == count; + if(!ok) { set_error(errmsg, "spektra_sim: truncated spectra lut %s", lut_path); fclose(fh); @@ -3427,8 +3511,6 @@ sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *s) { g->grain_rms[c] = (float)s->grain_rms[c]; g->grain_uniformity[c] = (float)s->grain_uniformity[c]; - /* self-consistent with g->film_dmax: see sf_sim_film_grain3 */ - g->grain_dmin[c] = (float)s->film_dmin[c]; g->halation_strength[c] = (float)s->halation_strength[c]; } g->halation_first_sigma_um = (float)s->halation_sigma_um; @@ -3442,6 +3524,11 @@ sf_sim_gpu_t *sf_sim_gpu_export(const sf_sim_t *s) g->couplers_active = s->couplers_active; g->grain_n_sublayers = s->grain_n_sublayers; + /* What the sampler adds: the sum of the per-sub-layer floors. The GPU combine + subtracts this exactly as the CPU one now does, so the two agree. This used + to be film_dmin here and grain_density_min on the CPU -- two different wrong + values, which is why the paths brightened by different amounts. */ + sf_sim_grain_dmin_total(s, g->grain_dmin); for(int l = 0; l < SF_GRAIN_MAX_SUBLAYERS; l++) { g->grain_particle_scale[l] = (float)s->grain_particle_scale[l]; @@ -3572,6 +3659,19 @@ void sf_sim_scatter_params(const sf_sim_t *sim, double core_um[3], double tail_u } } +void sf_sim_grain_dmin_total(const sf_sim_t *sim, float dmin_total[3]) +{ + for(int c = 0; c < 3; c++) dmin_total[c] = 0.0f; + if(!sim) return; + const int nl = sim->grain_n_sublayers > 0 ? sim->grain_n_sublayers : 1; + for(int c = 0; c < 3; c++) + { + double t = 0.0; + for(int l = 0; l < nl; l++) t += sim->grain_layer_dmin[l][c]; + dmin_total[c] = (float)t; + } +} + void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3]) { /* spektrafilm's original single fixed profile, for a sim-less caller */ diff --git a/src/common/spektra_sim.h b/src/common/spektra_sim.h index 25a3a377ef18..e7e90b3a1a7a 100644 --- a/src/common/spektra_sim.h +++ b/src/common/spektra_sim.h @@ -83,6 +83,10 @@ typedef struct sf_sim_t sf_sim_t; /* Load a data pack directory (pack.json + spectra_lut.f32 + profiles/). * On failure returns NULL and sets *errmsg (caller frees with free()). */ sf_pack_t *sf_pack_load(const char *dir, char **errmsg); +/* Identity of the spectral upsampling table this pack carries. The hash is what + * params record; the string is for the message shown when they disagree. */ +uint32_t sf_pack_lut_hash(const sf_pack_t *pack); +const char *sf_pack_lut_id(const sf_pack_t *pack); void sf_pack_free(sf_pack_t *pack); const char *sf_pack_version(const sf_pack_t *pack); @@ -204,6 +208,14 @@ int sf_sim_film_bw(const sf_sim_t *sim); falls back to the legacy fixed constants (SF_GRAIN_LEGACY_* in spektra_core.h) when sim is NULL or the pack predates per-film grain. */ void sf_sim_film_grain3(const sf_sim_t *sim, float rms[3], float uniformity[3], float dmin[3]); +/* Sum of the per-sub-layer density floors, per channel -- the amount the grain + * sampler actually adds to a pixel's density, and so the amount the caller has + * to take back off to recover a zero-mean delta. Equals grain_density_min for a + * single-layer stock, but NOT in general: the multi-sub-layer table's floors are + * density_max_fractions[l] * density_min and their sum is not constrained to + * density_min. Subtracting grain_density_min instead left the delta with a + * constant positive mean of (sum - density_min) per unit grain strength. */ +void sf_sim_grain_dmin_total(const sf_sim_t *sim, float dmin_total[3]); /* Multi-sublayer grain model (see _sf_build_grain_layers in spektra_sim.c): * n==1 for any stock whose own fitted density-curve model is single-layer diff --git a/src/iop/spektrafilm.c b/src/iop/spektrafilm.c index 2180549d8812..4199589d9f5b 100644 --- a/src/iop/spektrafilm.c +++ b/src/iop/spektrafilm.c @@ -155,6 +155,13 @@ typedef enum dt_iop_spektrafilm_diffusion_family_t typedef struct dt_iop_spektrafilm_params_t { uint32_t film_hash; // $DEFAULT: 0 (0 = first available filming stock) + /* Identity of the spectral upsampling table this edit was developed against. + Upstream revises it often and every revision changes the render, so an edit + reopened against a different one is reported rather than silently rendering + differently. 0 means "not recorded" (an edit older than this field, or one + made while no pack was loaded) and never warns. Diagnostic only -- nothing + downstream reads it. */ + uint32_t lut_hash; // $DEFAULT: 0 uint32_t paper_hash; // $DEFAULT: 0 (0 = the film's target print stock) float exposure_ev; // $MIN: -4.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "film exposure" float print_exposure_ev; // $MIN: -3.0 $MAX: 3.0 $DEFAULT: 0.0 $DESCRIPTION: "print exposure" @@ -321,6 +328,7 @@ typedef struct dt_iop_spektrafilm_data_t sf_sim_gpu_t *gpu; /* float tables for process_cl; NULL for exact quality */ uint64_t sim_key; /* hash of everything the sim build depends on */ char sim_error[256]; + char sim_warning[256]; /* multi-sublayer grain GPU constant buffers (see process_cl's grain stage): built from d->gpu's grain_layer_* tables, which only change when d->gpu itself is rebuilt (a new film/paper/quality choice), never @@ -712,6 +720,7 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, uint64_t key = 0xcbf29ce484222325ULL; key = _mix64(key, &p->film_hash, sizeof p->film_hash); + key = _mix64(key, &p->lut_hash, sizeof p->lut_hash); key = _mix64(key, &p->paper_hash, sizeof p->paper_hash); key = _mix64(key, &p->exposure_ev, sizeof p->exposure_ev); key = _mix64(key, &p->print_exposure_ev, sizeof p->print_exposure_ev); @@ -768,6 +777,7 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, } d->sim_key = key; d->sim_error[0] = 0; + d->sim_warning[0] = 0; /* global pack, loaded once */ dt_pthread_mutex_lock(&_pack_lock); @@ -886,6 +896,21 @@ static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d, sp.input_white_xy[0] = sp.output_white_xy[0] = d50_xy[0]; sp.input_white_xy[1] = sp.output_white_xy[1] = d50_xy[1]; + /* Compare what this edit was developed against with what is installed. Only + when the edit actually recorded one -- a 0 means the field predates the + edit, not that anything is wrong. */ + const uint32_t pack_lut = sf_pack_lut_hash(pack); + if(p->lut_hash && pack_lut && p->lut_hash != pack_lut) + /* Print the hash as well as the name. The name carries the spektrafilm + version string, and that is not a reliable identifier: an editable dev + install reports whatever pyproject.toml says, so two materially + different checkouts can both call themselves the same thing. Without + the hash the message reads as nonsense when they do. */ + snprintf(d->sim_warning, sizeof d->sim_warning, + _("developed with a different spectral table\n" + "recorded: %08x installed: %s (%08x)"), + p->lut_hash, sf_pack_lut_id(pack), pack_lut); + d->sim = sf_sim_build(pack, film, paper, &sp, &err); if(!d->sim && err) { @@ -945,7 +970,12 @@ static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p, float pixel_u scatter_scale split). */ const float scat_scale = fmaxf(p->scatter_scale, 1e-3f); const float scat = (p->halation_on && p->scatter_amount > 0.0f) - ? SF_SCATTER_TAIL_MAX_UM * SF_HALATION_PSF_SIGMAS * scat_scale * inv_um + /* SF_SCATTER_TAIL_MAX_UM is already the widest tail + component's sigma; SF_HALATION_PSF_SIGMAS is + sqrt(n_bounces) and belongs to the halation term + above, so applying it here over-padded scatter by + 1.73x. Harmless but wasteful. */ + ? SF_SCATTER_TAIL_MAX_UM * scat_scale * inv_um : 0.0f; /* The widest of film-stage and print-stage diffusion determines the ROI padding — both must fit in the expanded tile. Take the widest component of @@ -1196,7 +1226,13 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c resolution-dependent via pixel_um already, confirmed against the reference at multiple different resolutions. */ float grms[3], gunif[3], gdmin[3]; - sf_sim_film_grain3(sim, grms, gunif, gdmin); /* per-film catalogue grain + /* gdmin here is what the SAMPLER adds -- the sum of the per-sub-layer + floors, not the film's single density_min. They coincide for a + single-layer stock and differ for a multi-sub-layer one; using + density_min there gave the delta a constant positive mean. */ + sf_sim_grain_dmin_total(sim, gdmin); + float gdmin_unused[3]; + sf_sim_film_grain3(sim, grms, gunif, gdmin_unused); /* per-film catalogue grain (rms-granularity, uniformity, density floor) — Portra 400 no longer shares Tri-X's grain signature */ @@ -1303,16 +1339,12 @@ void process(dt_iop_module_t *self, dt_dev_pixelpipe_iop_t *piece, const void *c const float sigma = SF_GRAIN_BLUR_FACTOR * fmaxf(d->p.grain_size, SF_GRAIN_SIZE_MIN) * preview_scale; sf_blur_plane3(gbuf, w, h, sigma, scratch); - /* No DC-centring pass here any more. The positive bias it removed came from - sf_layer_particle clamping a normal approximation at zero; the sampler now - draws a single exact/unbiased Poisson (see spektra_core.h), so the delta - has zero mean by construction. That also removes an image-wide reduction - from a function that only ever sees one ROI or tile -- the old per-channel - mean depended on how the pixelpipe cut the image up, so preview, export - and each tile of a tiled export centred by different amounts. The old - correction could not have been right in any case: the bias was a function - of density (worst in the shadows, at low particle counts), so subtracting - a single scalar left a density-dependent residual behind. */ + /* No DC-centring pass. The delta is zero-mean by construction now: the + sampler draws an unbiased Poisson (spektra_core.h) and the combine above + takes back exactly the floors the sampler added -- see + sf_sim_grain_dmin_total(). Subtracting grain_density_min there instead + left a constant +(sum - density_min) per unit strength, which a per-ROI + mean was previously hiding. */ /* No variance-restoration renorm here -- upstream's own grain finalization (_finalize_grain in grain.py) has none either; it just blurs and lets the natural contrast reduction stand, matching real @@ -2387,6 +2419,17 @@ void gui_reset(dt_iop_module_t *self) /* called by the core whenever a params-linked widget changed */ void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous) { + /* Stamp the spectral table this edit is being made against. Done here because + gui_changed() runs after the widget has written the param and before the + history item is created, so the value lands in the same edit -- and only on + a real user change, so merely opening an image never dirties one. */ + { + dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; + dt_pthread_mutex_lock(&_pack_lock); + if(_pack) p->lut_hash = sf_pack_lut_hash(_pack); + dt_pthread_mutex_unlock(&_pack_lock); + } + dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params; if(!w || w == g->scan_film) _update_print_sensitivity(self); @@ -2411,6 +2454,22 @@ void gui_changed(dt_iop_module_t *self, GtkWidget *w, void *previous) } } +/* sim_error and sim_warning were being recorded and never shown -- a missing + data pack, an unreadable profile or a spectral-table mismatch all produced a + silently wrong or blank render. Route them to the module's trouble banner, + which is darktable's own mechanism for exactly this. */ +static void _update_trouble_message(dt_iop_module_t *self) +{ + const dt_iop_spektrafilm_data_t *d = (const dt_iop_spektrafilm_data_t *)self->data; + if(!d) return; + if(d->sim_error[0]) + dt_iop_set_module_trouble_message(self, _("cannot render"), d->sim_error, NULL); + else if(d->sim_warning[0]) + dt_iop_set_module_trouble_message(self, _("data mismatch"), d->sim_warning, NULL); + else + dt_iop_set_module_trouble_message(self, NULL, NULL, NULL); +} + void gui_update(dt_iop_module_t *self) { dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data; @@ -2524,6 +2583,8 @@ void gui_update(dt_iop_module_t *self) _toggle_sensitivity(g, p); _update_print_sensitivity(self); + + _update_trouble_message(self); } /* Boost that puts the probe lightness of `rgb` at `target_L`. @@ -2867,13 +2928,31 @@ void gui_init(dt_iop_module_t *self) g->scatter_amount = dt_bauhaus_slider_from_params(self, "scatter_amount"); gtk_widget_set_tooltip_text(g->scatter_amount, - _("in-emulsion light scatter, before the halation bounce" - " (1.0 = film-accurate; 0 = off)")); + _("fraction of light that scatters inside the emulsion,\n" + "before the halation bounce. 1.0 is film-accurate and is\n" + "also the maximum -- it means all of it, so nothing of the\n" + "unscattered image remains.\n\n" + "this is why the whole frame softens rather than just high\n" + "contrast edges: the scatter radius is small (a few um on\n" + "film) but it applies everywhere. lower this if you want a\n" + "sharper result than the film itself would give.")); g->scatter_scale = dt_bauhaus_slider_from_params(self, "scatter_scale"); + /* Upstream fixes scatter_spatial_scale at 1.0 -- it is a schema field with no + UI, no preset and no per-stock override. Exposing it is a darktable + addition, so keep the drag range close to the value the film model actually + claims and leave the rest reachable by right-click. At 4.0 on a 50 MP frame + the effective blur is ~14 px across the whole image, far outside anything + the reference produces. */ + dt_bauhaus_slider_set_soft_range(g->scatter_scale, 0.2f, 1.5f); gtk_widget_set_tooltip_text(g->scatter_scale, - _("scatter size: scales the in-emulsion scatter radius" - " (1.0 = film-accurate)")); + _("scales the in-emulsion scatter radius. 1.0 is\n" + "film-accurate, and is what the reference implementation\n" + "always uses -- it does not expose this control.\n\n" + "above 1.0 you are past what the film model claims, and the\n" + "whole frame softens quickly: the radius scales with the\n" + "value, so 4.0 is a four times wider blur everywhere.\n" + "drag up to 1.5, right-click to enter higher values")); g->halation_amount = dt_bauhaus_slider_from_params(self, "halation_amount"); dt_bauhaus_slider_set_soft_range(g->halation_amount, 0.0f, 2.0f);