diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 85bb3b90f2..7b0226d9e7 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -99,12 +99,15 @@ pub enum Process { /// V6, with film grain from a stack of crystal layers (after Aurélien Pierre's model): resolution /// independent, fading out at black and white; V1 to V6 keep their value noise. V7, + /// V7, with the Highlights/Shadows base taken of log luminance without its fine detail (clamped + /// between a fine and a 3× wider median), so gaps in dark lettering aren't lifted into bright blocks. + V8, } impl Process { /// Every process, oldest first. - pub const ALL: [Process; 7] = [Process::V1, Process::V2, Process::V3, Process::V4, Process::V5, Process::V6, Process::V7]; - pub const LATEST: Process = Process::V7; + pub const ALL: [Process; 8] = [Process::V1, Process::V2, Process::V3, Process::V4, Process::V5, Process::V6, Process::V7, Process::V8]; + pub const LATEST: Process = Process::V8; pub const fn version(self) -> ProcessVersion { match self { @@ -115,6 +118,7 @@ impl Process { Process::V5 => ProcessVersion(5), Process::V6 => ProcessVersion(6), Process::V7 => ProcessVersion(7), + Process::V8 => ProcessVersion(8), } } } diff --git a/crates/develop/src/tests_process.rs b/crates/develop/src/tests_process.rs index 7826c23d95..1694a3ea07 100644 --- a/crates/develop/src/tests_process.rs +++ b/crates/develop/src/tests_process.rs @@ -89,14 +89,13 @@ fn v1_is_left_out_when_saving_and_other_processes_are_kept() { #[test] fn unknown_process_numbers_load_and_render_with_the_newest_known() { // written by a newer LightCraft: kept as it is, rendered with the latest process this build has - let newer = Process::LATEST.version().0 + 1; - let s = DevelopSettings::from_json(&json!({"process": newer, "light": {"exposure": 0.5}})).unwrap(); - assert_eq!(s.process, ProcessVersion(newer)); + let s = DevelopSettings::from_json(&json!({"process": LATEST_PLUS_ONE, "light": {"exposure": 0.5}})).unwrap(); + assert_eq!(s.process, ProcessVersion(LATEST_PLUS_ONE)); assert_eq!(s.process.process(), Process::LATEST); assert!(!s.process.is_known()); assert!(!s.process.is_outdated(), "never downgraded by Update to Current Process"); - assert_eq!(s.to_json()["process"], json!(newer)); - assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(newer)); + assert_eq!(s.to_json()["process"], json!(LATEST_PLUS_ONE)); + assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(LATEST_PLUS_ONE)); // below V1 (no LightCraft writes it): rendered as V1, and updatable let zero = DevelopSettings::from_json(&json!({"process": 0})).unwrap().process; assert_eq!((zero.process(), zero.is_known(), zero.is_outdated()), (Process::V1, false, true)); diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 2bd95a4ed0..6be62a9bcf 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -51,6 +51,7 @@ const MODULES: &[Module] = &[ "guided_pre", "guided_ab", "guided_apply", + "clamp_between_k", "wb_k", "nr_lum", "chroma_k", @@ -197,7 +198,11 @@ fn build_jobs( layout: Some(&p.pipeline_layout), module: &p.module, entry_point: Some(entry), - compilation_options: Default::default(), + // Only the median kernels (`blur.wgsl`, `med_tile`) use workgroup memory, and they + // load every element they read before the barrier, so wgpu's default zeroing is not + // needed. On DX12 it is written out as one assignment per element, which made FXC take + // about 87 s over these kernels at every start of a release build. + compilation_options: wgpu::PipelineCompilationOptions { zero_initialize_workgroup_memory: false, ..Default::default() }, cache: None, }); let kernel = Kernel { pipeline, layout: p.layout.clone(), nbuf: p.nbuf }; diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 4d658c3ca0..bc079edb11 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -371,6 +371,9 @@ fn guided_coeffs(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps: gaussian(cx, &ab, w, h, 2, sigma) } +/// Pixels per thread along the pass of `median_h` / `median_v` (`MED_K` in `blur.wgsl`). +const MEDIAN_K: usize = 4; + /// Separable median of a plane (`local::median_hv`). fn median_hv(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, r: usize) -> Buf { if r == 0 || w * h == 0 { @@ -378,21 +381,38 @@ fn median_hv(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, r: usize) -> Buf { } let pr = [w as u32, h as u32, 1, r.min(local::BASE_DETAIL_MAX) as u32]; let tmp = cx.gpu.buffer(w * h); - cx.run("median_h", &pr, &[Some(p), Some(&tmp)], groups2(w, h, [16, 16])); + cx.run("median_h", &pr, &[Some(p), Some(&tmp)], groups2(w.div_ceil(MEDIAN_K), h, [16, 16])); let out = cx.gpu.buffer(w * h); - cx.run("median_v", &pr, &[Some(&tmp), Some(&out)], groups2(w, h, [16, 16])); + cx.run("median_v", &pr, &[Some(&tmp), Some(&out)], groups2(w, h.div_ceil(MEDIAN_K), [16, 16])); out } -/// The highlights/shadows base (`local::tone_base`; `detail` 0: V1, no median). -fn tone_base(cx: &mut Cx<'_>, l: &Buf, w: usize, h: usize, sigma: f32, detail: usize) -> Buf { +/// The highlights/shadows base (`local::tone_base`; `detail` 0: V1, no detail removal). +fn tone_base(cx: &mut Cx<'_>, l: &Buf, w: usize, h: usize, sigma: f32, detail: usize, keeps_gaps: bool) -> Buf { if detail == 0 { return guided_fast(cx, l, w, h, sigma, local::BASE_EPS); } - let m = median_hv(cx, l, w, h, detail); + let m = if keeps_gaps { detail_free(cx, l, w, h, detail) } else { median_hv(cx, l, w, h, detail) }; guided_fast(cx, &m, w, h, sigma, local::BASE_EPS) } +/// Log luminance without detail finer than `r` px (`local::detail_free`): clamped between its +/// median at `r` and the median of a `local::BASE_COARSE_STEP`× downscaled copy, enlarged. +fn detail_free(cx: &mut Cx<'_>, l: &Buf, w: usize, h: usize, r: usize) -> Buf { + if r == 0 || w * h == 0 { + return cx.copy(l); + } + let fine = median_hv(cx, l, w, h, r); + let s = local::BASE_COARSE_STEP; + let (lw, lh) = (w.div_ceil(s).max(1), h.div_ceil(s).max(1)); + let lo = resize(cx, l, (w, h), (lw, lh), 1, Filter::Box); + let lo_median = median_hv(cx, &lo, lw, lh, r); + let coarse = resize(cx, &lo_median, (lw, lh), (w, h), 1, Filter::Bilinear); + let out = cx.gpu.buffer(w * h); + map(cx, "clamp_between_k", w * h, &[], [Some(l), Some(&fine), Some(&coarse)], &out); + out +} + /// Fast guided filter (`local::guided_fast`): coefficients on a subsampled grid. fn guided_fast(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf { let s = local::guided_fast_step(sigma); @@ -1418,7 +1438,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla let base = match sig.base { Some(sg) => { let key = local::base_key(&plan.settings, sg, sig.base_detail); - plane_at(&mut planes.base, key, || tone_base(cx, &log_l, w, h, sg, sig.base_detail)) + plane_at(&mut planes.base, key, || tone_base(cx, &log_l, w, h, sg, sig.base_detail, local::base_keeps_gaps(&plan.settings))) } None => log_l.clone(), }; @@ -1841,6 +1861,76 @@ mod tests { time("guided sigma=1.75", &mut |cx| drop(guided(cx, &src, w, h, 1.75, 0.01))); } + /// The tiled median kernels select exactly what the CPU's sorted window does: edge tiles, + /// planes smaller than a tile or a window, every radius and many ties. + #[test] + fn median_matches_cpu_bit_for_bit() { + // (creating the device writes the crash sentinel another test swaps out) + let _serial = crate::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let Some(gpu) = crate::device() else { return lightcraft_testkit::skip!(Gpu, "no GPU adapter") }; + let _scope = crate::ctx::RenderScope::new(gpu); + let mut seed = 0x9e37_79b9_u32; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + for (w, h) in [(1, 1), (2, 3), (5, 70), (70, 5), (63, 65), (130, 67), (200, 129)] { + // a coarse level set gives ties; a smooth ramp and fine noise give distinct values + let mut p = Plane::new(w, h); + for (i, o) in p.data.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + *o = match (next() % 3, next()) { + (0, v) => (v % 7) as f32 * 0.5 - 2.0, + (1, v) => (x + 2 * y) as f32 * 0.01 + (v % 100) as f32 * 1e-4, + (_, v) => (v % 10_000) as f32 * 1e-3 - 5.0, + }; + } + let src = gpu.upload(&p.data); + for r in 1..=local::BASE_DETAIL_MAX { + let mut cx = Cx::new(gpu); + let out = median_hv(&mut cx, &src, w, h, r); + let got: Vec = cx.read(&out, w * h); + let want = local::median_hv(&p, r); + let bad = got.iter().zip(&want.data).position(|(g, c)| g.to_bits() != c.to_bits()); + assert!(bad.is_none(), "{w}×{h} r={r}: pixel {bad:?}"); + } + } + } + + /// Highlights/shadows base timings on the #3460 frame size: + /// `cargo test --release -p lightcraft-gpu bench_tone_base -- --ignored --nocapture`. + #[test] + #[ignore] + fn bench_tone_base() { + let Some(gpu) = crate::device() else { return eprintln!("no gpu: {:?}", crate::unavailable_reason()) }; + let _scope = crate::ctx::RenderScope::new(gpu); + let (w, h) = (6960, 4640); + let r = local::base_detail_radius(w as f64); + eprintln!("{} ({:?}) r={r}", gpu.info.name, gpu.info.backend); + let l: Vec = (0..w * h).map(|i: usize| ((i.wrapping_mul(2_654_435_761)) % 1000) as f32 / 250.0 - 6.0).collect(); + let src = gpu.upload(&l); + let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| { + let mut ts = Vec::new(); + for _ in 0..9 { + let mut cx = Cx::new(gpu); + cx.sync(); + let t = std::time::Instant::now(); + f(&mut cx); + cx.sync(); + ts.push(t.elapsed().as_secs_f64() * 1e3); + } + ts.sort_by(f64::total_cmp); + eprintln!("{name:<40} min {:.1} ms median {:.1} ms", ts[0], ts[4]); + }; + let sigma = 0.015 * w as f32; + time("median_hv", &mut |cx| drop(median_hv(cx, &src, w, h, r))); + time("detail_free", &mut |cx| drop(detail_free(cx, &src, w, h, r))); + time("guided_fast", &mut |cx| drop(guided_fast(cx, &src, w, h, sigma, local::BASE_EPS))); + time("tone_base", &mut |cx| drop(tone_base(cx, &src, w, h, sigma, r, true))); + } + /// The device scores Remove's candidate sources like the CPU and picks the same one. #[test] fn auto_source_picks_the_cpu_candidate() { diff --git a/crates/gpu/src/wgsl/blur.wgsl b/crates/gpu/src/wgsl/blur.wgsl index 756457ade4..e6a624dffb 100644 --- a/crates/gpu/src/wgsl/blur.wgsl +++ b/crates/gpu/src/wgsl/blur.wgsl @@ -113,56 +113,114 @@ fn minmax(@builtin(global_invocation_id) g: vec3) { } // Separable median of a single-channel plane (`lightcraft_pipeline::local::median_hv`): 2r+1 -// taps (r ≤ 24, clamped edges) along a row (median_h) or a column (median_v), one thread per -// pixel. The median is a selection, so the result matches the CPU's bit for bit. +// taps (1 ≤ r ≤ 24, clamped edges) along a row (median_h) or a column (median_v). The median is a +// selection, so the result matches the CPU's bit for bit. // P: w, h, nc (unused), r. -fn median_of(n: u32, r: u32, v: ptr>) -> f32 { - // partial selection sort up to the middle - for (var i = 0u; i <= r; i++) { - var m = i; - for (var j = i + 1u; j < n; j++) { - if ((*v)[j] < (*v)[m]) { - m = j; - } +// +// Each thread finds the medians of 4 neighbouring pixels along the pass by rank: a tap is the +// median of a window when fewer than r+1 of its taps are smaller and at least r+1 are smaller or +// equal. The 4 windows share all but their first and last 3 taps, so one count over the shared +// taps serves all four. A 16×16 workgroup (64 pixels along the pass, 16 lines across) first loads +// its taps into `med_tile`. On a 6960×4640 plane at r = 10 this takes 47 ms on an M2 Max, against +// 191 ms for a partial selection sort per pixel over a private array of taps, which drivers keep in +// scratch memory because it is indexed dynamically. +const MED_K: u32 = 4u; +const MED_LINE: u32 = 113u; // 16·MED_K + 2·24, odd +// Not zero-initialised (`build_jobs` in ctx.rs turns that off): write every element read. +var med_tile: array; // 16 lines + +// The medians of the 2r+1-tap windows starting at `med_tile[base + k]`, k < 4 (r ≥ 1). Taps 3 to +// n-1 are in all four windows; the three at each end are in some. +fn median4(base: u32, r: u32) -> vec4 { + let n = 2u * r + 1u; + let e0 = vec3(med_tile[base], med_tile[base + 1u], med_tile[base + 2u]); + let e1 = vec3(med_tile[base + n], med_tile[base + n + 1u], med_tile[base + n + 2u]); + var out = vec4(0.0); + var found = vec4(false); + for (var i = 0u; i < n + 3u; i++) { + let c = med_tile[base + i]; + var less = 0u; + var le = 0u; + for (var j = 3u; j < n; j++) { + let v = med_tile[base + j]; + less += select(0u, 1u, v < c); + le += select(0u, 1u, v <= c); + } + let l0 = select(vec3(0u), vec3(1u), e0 < vec3(c)); + let l1 = select(vec3(0u), vec3(1u), e1 < vec3(c)); + let q0 = select(vec3(0u), vec3(1u), e0 <= vec3(c)); + let q1 = select(vec3(0u), vec3(1u), e1 <= vec3(c)); + // window k: taps k..k+n-1 = taps k..2 of the start, the middle, taps n..n+k-1 of the end + let lw = vec4(l0.x + l0.y + l0.z, l0.y + l0.z + l1.x, l0.z + l1.x + l1.y, l1.x + l1.y + l1.z) + vec4(less); + let qw = vec4(q0.x + q0.y + q0.z, q0.y + q0.z + q1.x, q0.z + q1.x + q1.y, q1.x + q1.y + q1.z) + vec4(le); + let has = vec4((i < n), (i >= 1u) && (i < n + 1u), (i >= 2u) && (i < n + 2u), (i >= 3u)); + let hit = has & !found & (lw <= vec4(r)) & (qw > vec4(r)); + out = select(out, vec4(c), hit); + found = found | hit; + if (all(found)) { + break; } - let t = (*v)[i]; - (*v)[i] = (*v)[m]; - (*v)[m] = t; } - return (*v)[r]; + return out; } +// Thread (l.x, l.y) of a workgroup takes line l.x and pixels 4·l.y to 4·l.y + 3 along it, so the +// threads of a SIMD group read 16 different lines at once; with an odd line length those fall in +// different banks of workgroup memory. @compute @workgroup_size(16, 16) -fn median_h(@builtin(global_invocation_id) g: vec3) { +fn median_h(@builtin(local_invocation_id) l: vec3, @builtin(workgroup_id) wg: vec3) { let w = pu(0u); let h = pu(1u); let r = min(pu(3u), 24u); - if (g.x >= w || g.y >= h) { + let span = 16u * MED_K + 2u * r; + let x0 = i32(wg.x * 16u * MED_K) - i32(r); + // load: consecutive threads read consecutive pixels of a row + for (var e = l.y * 16u + l.x; e < 16u * span; e += 256u) { + let line = e / span; + let k = e % span; + let y = min(wg.y * 16u + line, h - 1u); + let x = clamp(x0 + i32(k), 0, i32(w) - 1); + med_tile[line * MED_LINE + k] = src[y * w + u32(x)]; + } + workgroupBarrier(); + let y = wg.y * 16u + l.x; + let x = (wg.x * 16u + l.y) * MED_K; + if (x >= w || y >= h) { return; } - let n = 2u * r + 1u; - var v: array; - let row = g.y * w; - for (var k = 0u; k < n; k++) { - let x = clamp(i32(g.x + k) - i32(r), 0, i32(w) - 1); - v[k] = src[row + u32(x)]; + let m = median4(l.x * MED_LINE + l.y * MED_K, r); + for (var k = 0u; k < MED_K; k++) { + if (x + k < w) { + dst[y * w + x + k] = m[k]; + } } - dst[row + g.x] = median_of(n, r, &v); } @compute @workgroup_size(16, 16) -fn median_v(@builtin(global_invocation_id) g: vec3) { +fn median_v(@builtin(local_invocation_id) l: vec3, @builtin(workgroup_id) wg: vec3) { let w = pu(0u); let h = pu(1u); let r = min(pu(3u), 24u); - if (g.x >= w || g.y >= h) { + let span = 16u * MED_K + 2u * r; + let y0 = i32(wg.y * 16u * MED_K) - i32(r); + // load: consecutive threads read consecutive pixels of a row (16 columns) + for (var e = l.y * 16u + l.x; e < 16u * span; e += 256u) { + let line = e % 16u; + let k = e / 16u; + let x = min(wg.x * 16u + line, w - 1u); + let y = clamp(y0 + i32(k), 0, i32(h) - 1); + med_tile[line * MED_LINE + k] = src[u32(y) * w + x]; + } + workgroupBarrier(); + let x = wg.x * 16u + l.x; + let y = (wg.y * 16u + l.y) * MED_K; + if (x >= w || y >= h) { return; } - let n = 2u * r + 1u; - var v: array; - for (var k = 0u; k < n; k++) { - let y = clamp(i32(g.y + k) - i32(r), 0, i32(h) - 1); - v[k] = src[u32(y) * w + g.x]; + let m = median4(l.x * MED_LINE + l.y * MED_K, r); + for (var k = 0u; k < MED_K; k++) { + if (y + k < h) { + dst[(y + k) * w + x] = m[k]; + } } - dst[g.y * w + g.x] = median_of(n, r, &v); } diff --git a/crates/gpu/src/wgsl/map.wgsl b/crates/gpu/src/wgsl/map.wgsl index 9b84b8d89d..2b5f658931 100644 --- a/crates/gpu/src/wgsl/map.wgsl +++ b/crates/gpu/src/wgsl/map.wgsl @@ -70,6 +70,25 @@ fn guided_apply(@builtin(global_invocation_id) g: vec3, @builtin(num_workgr dst[i] = b[2u * i] * a[i] + b[2u * i + 1u]; } +// `clamp_between` (`local::detail_free`): a clamped between b and c, in either order. +@compute @workgroup_size(256) +fn clamp_between_k(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + let lo = min(b[i], c[i]); + let hi = max(b[i], c[i]); + let v = a[i]; + if (v < lo) { + dst[i] = lo; + } else if (v > hi) { + dst[i] = hi; + } else { + dst[i] = v; + } +} + // Cross guided filter (`masks::guided_cross`), a: guide, b: input. P[1] = 0: (guide, input); // 1: (guide·input, guide²). @compute @workgroup_size(256) diff --git a/crates/gpu/tests/it/equivalence.rs b/crates/gpu/tests/it/equivalence.rs index 427afe424d..a8fa80f812 100644 --- a/crates/gpu/tests/it/equivalence.rs +++ b/crates/gpu/tests/it/equivalence.rs @@ -807,7 +807,7 @@ fn light_v3_operators_match() { } /// Issue #632: the Highlights / Shadows base of each process (V1: the edge-aware filter of log -/// luminance; V2: of its fine median, a radius that grows with the render) matches the CPU's, and +/// luminance; V2 to V7: of its fine median, V8 on: of its detail-free plane, a radius that grows with the render) matches the CPU's, and /// a warm cache never hands one process the other's base. #[test] fn highlights_shadows_base_matches_on_every_process() { @@ -838,7 +838,7 @@ fn highlights_shadows_base_matches_on_every_process() { }]; check(&format!("{p:?} local highlights {w}"), &src, &raw, &s, &req); } - for p in [Process::V1, Process::V2, Process::V4, Process::V1, Process::V2, Process::V4] { + for p in [Process::V1, Process::V2, Process::V4, Process::V8, Process::V1, Process::V2, Process::V4, Process::V8] { let mut s = DevelopSettings { process: p.version(), ..DevelopSettings::default() }; s.light.highlights = -100.0; let warm = lightcraft_gpu::render(&src, &raw, &s, &req, Some(&cache)).expect("gpu"); diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 3bce268a68..a4ec7ddec3 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -148,8 +148,8 @@ impl GrainModel { seed: g.seed, }) }), - Process::V7 if draft => crate::crystal::CrystalGrain::draft(g, px_per_long).map(GrainModel::Crystal), - Process::V7 => crate::crystal::CrystalGrain::new(g, px_per_long).map(GrainModel::Crystal), + Process::V7 | Process::V8 if draft => crate::crystal::CrystalGrain::draft(g, px_per_long).map(GrainModel::Crystal), + Process::V7 | Process::V8 => crate::crystal::CrystalGrain::new(g, px_per_long).map(GrainModel::Crystal), } } } @@ -407,7 +407,7 @@ pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap { }, // V4, with a raw's tone curve rolling off into white in stops: a camera curve past its last // knot, the generic curve from half a stop below its white point instead of clipping (issue #1632) - Process::V5 | Process::V6 | Process::V7 => match info.camera_tone.as_ref().filter(|_| info.raw) { + Process::V5 | Process::V6 | Process::V7 | Process::V8 => match info.camera_tone.as_ref().filter(|_| info.raw) { Some(curve) => ToneMap::camera_rolloff_hdr(curve, 0.0, l.whites, 0.0, peak), None if info.raw => ToneMap::rolloff_hdr(l.contrast, l.whites, l.blacks, peak), None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak), diff --git a/crates/pipeline/src/light_v3.rs b/crates/pipeline/src/light_v3.rs index a7db45ec06..5f3e0f9bf2 100644 --- a/crates/pipeline/src/light_v3.rs +++ b/crates/pipeline/src/light_v3.rs @@ -284,7 +284,7 @@ pub fn applies(s: &DevelopSettings, info: &SourceInfo) -> bool { let camera_look = info.raw && info.camera_tone.is_some(); match s.process.process() { Process::V1 | Process::V2 | Process::V3 => false, - Process::V4 | Process::V5 | Process::V6 | Process::V7 => camera_look, + Process::V4 | Process::V5 | Process::V6 | Process::V7 | Process::V8 => camera_look, } } diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 5fb9ad84ce..749e10139d 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -357,7 +357,7 @@ fn transpose(p: &Plane) -> Plane { } /// Detail scale of the highlights/shadows base from process V2, as a fraction of the long edge: -/// the base is the edge-aware filter of the [`median_hv`] of log luminance at this radius, so +/// the base is the edge-aware filter of log luminance without its detail at this radius, so /// twigs, lines and fine texture brighter or darker than their surroundings take their /// surroundings' shift (and keep their contrast, as with Exposure) while region boundaries stay /// sharp (no halos). V1's base is the edge-aware filter of log luminance itself (issue #632). @@ -375,17 +375,76 @@ pub fn base_detail_radius(px_per_long: f64) -> usize { pub fn tone_base_detail(s: &DevelopSettings, px_per_long: f64) -> usize { match s.process.process() { Process::V1 => 0, - Process::V2 | Process::V3 | Process::V4 | Process::V5 | Process::V6 | Process::V7 => base_detail_radius(px_per_long), + Process::V2 | Process::V3 | Process::V4 | Process::V5 | Process::V6 | Process::V7 | Process::V8 => base_detail_radius(px_per_long), } } /// The highlights/shadows base layer of log luminance `l`: the fast guided filter ([`BASE_EPS`]) -/// of its [`median_hv`] at `detail` px ([`tone_base_detail`]); at 0, of `l` itself (V1). -pub fn tone_base(l: &Plane, sigma: f32, detail: usize) -> Plane { +/// at `detail` px ([`tone_base_detail`]) of its [`detail_free`] plane when `keeps_gaps` +/// ([`base_keeps_gaps`], V8 on), else of its [`median_hv`] (V2 to V7); at 0, of `l` itself (V1). +pub fn tone_base(l: &Plane, sigma: f32, detail: usize, keeps_gaps: bool) -> Plane { if detail == 0 { return guided_fast(l, sigma, BASE_EPS); } - guided_fast(&median_hv(l, detail), sigma, BASE_EPS) + let m = if keeps_gaps { detail_free(l, detail) } else { median_hv(l, detail) }; + guided_fast(&m, sigma, BASE_EPS) +} + +/// Downscale factor of the coarse median in [`detail_free`]: its radius at full size is this many +/// times the fine one's. +pub const BASE_COARSE_STEP: usize = 3; + +/// Log luminance `l` without detail finer than `r` px (the highlights/shadows base of process V8 +/// on takes its edge-aware filter): each value moves toward its [`median_hv`] at `r` only as far as the +/// median of a [`BASE_COARSE_STEP`]× wider neighbourhood agrees (`l` clamped between the two). +/// A twig, line or speck in an even surround has both medians on the surround's side and takes +/// its tone, as #632 wants. The gap between two letter strokes, which a median at `r` fills with the +/// strokes' tone, sits in a wider neighbourhood mostly of the panel's tone: the two medians +/// disagree and the gap keeps its own. The coarse median is taken at `r` of a box-downscaled copy and +/// enlarged bilinearly, like [`guided_fast`]'s coefficients. +pub fn detail_free(l: &Plane, r: usize) -> Plane { + use lightcraft_raster::resample::{Filter, resize}; + if r == 0 || l.data.is_empty() { + return l.clone(); + } + let (w, h) = (l.width, l.height); + let (lw, lh) = (w.div_ceil(BASE_COARSE_STEP).max(1), h.div_ceil(BASE_COARSE_STEP).max(1)); + if (lw, lh) == (w, h) { + // nothing to downscale: both medians are the same + return median_hv(l, r); + } + let lo = median_hv(&resize(l, lw, lh, Filter::Box), r); + // The bilinear enlargement of `lo` (`resize`'s horizontal pass, then its vertical pass taken + // row by row with the same sums) is clamped into `out` as it is made, not stored. + let wide = resize(&lo, w, lh, Filter::Bilinear); + let wy = lightcraft_raster::resample::weights(lh, h, Filter::Bilinear); + let mut out = median_hv(l, r); + for_rows(&mut out.data, w, |y, row| { + let Some((y0, ws)) = wy.get(y) else { return }; + let mut coarse = vec![0.0f32; w]; + for (k, &wt) in ws.iter().enumerate() { + let Some(t) = wide.data.get((y0 + k) * w..(y0 + k + 1) * w) else { continue }; + for (c, &v) in coarse.iter_mut().zip(t) { + *c += v * wt; + } + } + for ((o, &v), &c) in row.iter_mut().zip(l.row(y)).zip(&coarse) { + *o = clamp_between(v, *o, c); + } + }); + out +} + +/// `v` clamped between `a` and `b` (in either order); a NaN bound is ignored, not a panic. +fn clamp_between(v: f32, a: f32, b: f32) -> f32 { + let (lo, hi) = (a.min(b), a.max(b)); + if v < lo { + lo + } else if v > hi { + hi + } else { + v + } } /// Subsampling step of [`guided_fast`] for `sigma` (1 = no subsampling: the plain guided filter). @@ -638,6 +697,12 @@ pub fn sharpen_key(s: &DevelopSettings, sigma: f32) -> SharpenKey { (sigma.to_bits(), s.process.process()) } +/// Whether `s`'s highlights/shadows base takes [`detail_free`] (process V8 on) instead of V2's plain +/// [`median_hv`] (V2 to V7, which shipped with it and stay frozen). +pub fn base_keeps_gaps(s: &DevelopSettings) -> bool { + s.process.process() >= Process::V8 +} + pub fn pixel_sharpen(s: &DevelopSettings) -> bool { s.process.process() >= Process::V3 } @@ -677,7 +742,7 @@ pub const CHROMA_SIGMA: f32 = 0.004; #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct PlaneSigmas { /// Edge-aware base for highlights/shadows ([`tone_base`]: fast guided filter, [`BASE_EPS`]; from - /// process V2 of the [`BASE_DETAIL`] median). + /// process V2 of a median at [`BASE_DETAIL`], from V8 of a detail-free plane). pub base: Option, /// Median radius (px) of that base ([`tone_base_detail`]: 0 under V1). pub base_detail: usize, @@ -806,7 +871,7 @@ pub(crate) fn prepare( }); let l = &log_l; let (base, (clarity_blur, ((texture_blur, sharpen_blur), dark))) = par_join( - || base_sigma.map(|sg| plane_at(sb, base_key(s, sg, base_detail), || timed("base", || tone_base(l, sg, base_detail)))), + || base_sigma.map(|sg| plane_at(sb, base_key(s, sg, base_detail), || timed("base", || tone_base(l, sg, base_detail, base_keeps_gaps(s))))), || { par_join( || clarity_sigma.map(|sg| plane_at(sc, sg.to_bits(), || timed("clarity", || guided_fast(l, sg, CLARITY_EPS)))), @@ -1309,6 +1374,50 @@ mod tests { mod fast_tests { use super::*; + /// [`detail_free`] enlarges the coarse median as it clamps; the result is the plain + /// composition of `resize` and [`median_hv`], bit for bit. + #[test] + fn detail_free_matches_its_reference() { + use lightcraft_raster::resample::{Filter, resize}; + for (w, h) in [(1, 1), (2, 1), (3, 3), (4, 7), (61, 47), (200, 31)] { + let p = Plane::from_fn(w, h, |x, y| ((x * 37 + y * 101) % 23) as f32 * 0.4 - 4.0 + if (x / 5 + y / 7) % 2 == 0 { 2.0 } else { 0.0 }); + for r in [1, 2, 5, 10] { + let fine = median_hv(&p, r); + let (lw, lh) = (w.div_ceil(BASE_COARSE_STEP), h.div_ceil(BASE_COARSE_STEP)); + let coarse = resize(&median_hv(&resize(&p, lw, lh, Filter::Box), r), w, h, Filter::Bilinear); + let want: Vec = (0..w * h).map(|i| clamp_between(p.data[i], fine.data[i], coarse.data[i]).to_bits()).collect(); + let got: Vec = detail_free(&p, r).data.iter().map(|v| v.to_bits()).collect(); + assert_eq!(got, want, "{w}×{h} r={r}"); + } + } + } + + /// CPU highlights/shadows base timings on the #3460 frame size: + /// `cargo test --release -p lightcraft-pipeline bench_tone_base_cpu -- --ignored --nocapture`. + #[test] + #[ignore] + fn bench_tone_base_cpu() { + let (w, h) = (6960, 4640); + let r = base_detail_radius(w as f64); + let p = Plane::from_fn(w, h, |x, y| ((x * 7 + y * 13) % 1000) as f32 / 250.0 - 6.0 + if (x / 40 + y / 40) % 2 == 0 { 1.0 } else { 0.0 }); + let sigma = 0.015 * w as f32; + let time = |name: &str, f: &dyn Fn() -> Plane| { + let mut ts: Vec = (0..5) + .map(|_| { + let t = std::time::Instant::now(); + std::hint::black_box(f()); + t.elapsed().as_secs_f64() * 1e3 + }) + .collect(); + ts.sort_by(f64::total_cmp); + eprintln!("{name:<20} min {:.1} ms median {:.1} ms", ts[0], ts[2]); + }; + time("median_hv", &|| median_hv(&p, r)); + time("detail_free", &|| detail_free(&p, r)); + time("guided_fast", &|| guided_fast(&p, sigma, BASE_EPS)); + time("tone_base", &|| tone_base(&p, sigma, r, true)); + } + /// The previous fast path, including its two materialized coefficient resizes. fn resized_reference(p: &Plane, sigma: f32, eps: f32) -> Plane { use lightcraft_raster::resample::{Filter, resize}; @@ -1353,12 +1462,12 @@ mod fast_tests { } #[test] - fn sampled_tone_base_keeps_the_median_before_the_guided_filter() { + fn sampled_tone_base_keeps_the_detail_free_plane_before_the_guided_filter() { let p = Plane::from_fn(73, 51, |x, y| if x % 9 == 0 { 3.0 } else { -4.0 + ((x + y) % 7) as f32 * 0.03 }); for sigma in [6.0, 24.0, 120.0] { for detail in [0, 1, 3, BASE_DETAIL_MAX] { - let median = median_hv(&p, detail); - assert_eq!(tone_base(&p, sigma, detail).data, resized_reference(&median, sigma, BASE_EPS).data, "{sigma}, {detail}"); + let free = detail_free(&p, detail); + assert_eq!(tone_base(&p, sigma, detail, true).data, resized_reference(&free, sigma, BASE_EPS).data, "{sigma}, {detail}"); } } } diff --git a/crates/pipeline/src/tests_grain.rs b/crates/pipeline/src/tests_grain.rs index 14172a516c..9dc4892453 100644 --- a/crates/pipeline/src/tests_grain.rs +++ b/crates/pipeline/src/tests_grain.rs @@ -35,7 +35,7 @@ fn processes_before_v7_keep_the_value_noise() { Process::V1 | Process::V2 | Process::V3 | Process::V4 | Process::V5 | Process::V6 => { assert!(matches!(model, Some(GrainModel::Value(_))), "{p:?}") } - Process::V7 => assert!(matches!(model, Some(GrainModel::Crystal(_))), "{p:?}"), + Process::V7 | Process::V8 => assert!(matches!(model, Some(GrainModel::Crystal(_))), "{p:?}"), } assert!(GrainModel::new(&on(p, 0.0), 2000.0, false).is_none(), "{p:?}: no grain at Amount 0"); } diff --git a/crates/pipeline/src/tests_tone.rs b/crates/pipeline/src/tests_tone.rs index f76f44775f..f8926e780c 100644 --- a/crates/pipeline/src/tests_tone.rs +++ b/crates/pipeline/src/tests_tone.rs @@ -208,6 +208,98 @@ fn median_orders_special_values_and_ties_like_total_cmp() { } } +/// A row of "8"s (two rings each, strokes 1.3 detail radii `r` wide, neighbours 0.8 radii apart): +/// dark lettering on a bright panel at the size of a bus number, as log luminance. +fn lettering(r: usize) -> (Plane, impl Fn(usize, usize) -> bool) { + let rf = r as f32; + let ring = move |x: f32, y: f32, cx: f32, cy: f32, outer: f32| { + let d = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt(); + d <= outer && d >= outer - 1.3 * rf + }; + let stroke = move |x: usize, y: usize| { + let (x, y) = (x as f32, y as f32); + (0..5).any(|i| { + let cx = 150.0 + i as f32 * 7.4 * rf; + ring(x, y, cx, 200.0 - 2.8 * rf, 2.9 * rf) || ring(x, y, cx, 200.0 + 3.2 * rf, 3.3 * rf) + }) + }; + (Plane::from_fn(900, 400, |x, y| if stroke(x, y) { -4.6 } else { -1.3 }), stroke) +} + +#[test] +fn gaps_between_letters_keep_their_own_tone_in_the_base() { + // At the size of a bus number in a 7000 px photo, a median at the detail radius filled the + // gaps between the digits and around their waists with the letters' tone: 360 panel pixels + // sat over 1 EV (up to 2.3 EV) above their base, so Shadows lifted them by up to 1.3 EV into + // bright blocks. The wider median sees the panel there, so the gaps keep their own tone. + let ppl = 6960.0; + let r = local::base_detail_radius(ppl); + assert_eq!(r, 10); + let (l, stroke) = lettering(r); + let (w, h) = (l.width, l.height); + let base = local::tone_base(&l, 0.015 * ppl as f32, r, true); + // panel pixels at least 3 px from a stroke (the guided filter's own edge softness aside) + let panel = |x: usize, y: usize| !(x.saturating_sub(2)..(x + 3).min(w)).any(|a| (y.saturating_sub(2)..(y + 3).min(h)).any(|b| stroke(a, b))); + let (mut worst, mut under) = (0.0f32, 0usize); + for y in 0..h { + for x in (0..w).filter(|&x| panel(x, y)) { + let d = l.get(x, y) - base.get(x, y); + worst = worst.max(d); + under += usize::from(d > 1.0); + } + } + assert!(under == 0 && worst < 1.0, "{under} panel px over 1 EV above their base; worst {worst} EV"); +} + +#[test] +fn isolated_lines_up_to_the_detail_radius_stay_out_of_the_base() { + // Light lines 1 to 9 px wide on a dark ground (twigs against a dark wall, r = 10): the base + // keeps the ground's tone under each, as under the median (#632), so Shadows lifts them with + // the ground and they keep their contrast. Clamping to the window's quartiles instead let lines + // of 6 px and more a third to two thirds of the way into the base. + let ppl = 6960.0; + let r = local::base_detail_radius(ppl); + let widths = [1usize, 2, 3, 4, 5, 6, 7, 8, 9]; + let l = Plane::from_fn(1300, 200, |x, _| { + let (i, off) = (x / 130, x % 130); + if i < widths.len() && (60..60 + widths[i]).contains(&off) { -2.0 } else { -6.0 } + }); + let base = local::tone_base(&l, 0.015 * ppl as f32, r, true); + for (i, wd) in widths.iter().enumerate() { + let leak = (base.get(i * 130 + 60 + wd / 2, 100) + 6.0) / 4.0; + assert!(leak.abs() < 0.05, "a {wd} px line is {leak} of the way into the base"); + } +} + +#[test] +fn only_process_v8_on_takes_the_detail_free_base() { + // V2 to V7 keep the plain median (V1 has none): they are released and frozen (`tests_legacy`) + for p in Process::ALL { + let s = DevelopSettings { process: p.version(), ..Default::default() }; + assert_eq!(local::base_keeps_gaps(&s), p >= Process::V8, "{p:?}"); + } + let (l, _) = lettering(10); + let plain = local::guided_fast(&local::median_hv(&l, 10), 100.0, local::BASE_EPS); + assert_eq!(local::tone_base(&l, 100.0, 10, false).data, plain.data); + assert_ne!(local::tone_base(&l, 100.0, 10, true).data, plain.data); +} + +#[test] +fn detail_free_keeps_steps_and_regions() { + // a step between two flat regions, and a flat field, come out as they went in + let step = Plane::from_fn(90, 60, |x, _| if x < 45 { -4.0 } else { 0.5 }); + assert_eq!(local::detail_free(&step, 6).data, step.data); + let flat = Plane::from_fn(31, 17, |_, _| -1.25); + assert_eq!(local::detail_free(&flat, 3).data, flat.data); + assert_eq!(local::detail_free(&step, 0).data, step.data); + // tiny and empty planes, and NaNs, are not a panic + for (w, h) in [(1, 1), (2, 3), (5, 1)] { + let p = Plane::from_fn(w, h, |x, y| if (x + y) % 2 == 0 { f32::NAN } else { 1.0 }); + assert_eq!(local::detail_free(&p, 4).data.len(), w * h); + } + assert!(local::detail_free(&Plane::new(0, 0), 4).data.is_empty()); +} + #[test] fn v1_keeps_the_base_it_always_had() { // V1's base is the edge-aware filter of log luminance itself, as before process V2: no median @@ -225,7 +317,7 @@ fn v1_keeps_the_base_it_always_had() { let l = Plane::from_fn(301, 157, |x, y| (((x * 31 + y * 17) * 2_654_435_761usize) % 1000) as f32 / 250.0 - 2.0 + if x % 5 == 0 { 0.5 } else { 0.0 }); for sg in [1.0f32, 4.5, 18.0] { - assert_eq!(local::tone_base(&l, sg, 0).data, local::guided_fast(&l, sg, local::BASE_EPS).data, "sigma {sg}"); + assert_eq!(local::tone_base(&l, sg, 0, true).data, local::guided_fast(&l, sg, local::BASE_EPS).data, "sigma {sg}"); } // V2 takes the median wherever the image is big enough for one let v2 = with("light.highlights", -100.0); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7d1ec1a01a..0666d11e95 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -181,8 +181,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on, sliding open and shut with the histogram's layout change); see Q. HDR | | LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | Alt/Option-drag shows highlight clipping (black, clipped channels in colour) | | LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | 🟡 | `ctl:light.contrast` | From process V4, global Contrast on the camera-look path is fitted per channel in ProPhoto with the sRGB transfer. Check response error 0.136 to 0.038 stops on six held-out Sony photos. Other sources and local adjustments retain V2; broader parity remains unverified (`docs/light-v3.md`). | -| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp. Alt/Option-drag shows highlight clipping | -| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632). Alt/Option-drag shows shadow clipping (white, clipped channels in colour) | +| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp; from process V8 (`local::detail_free`: clamped between the median at that radius and one 3× wider) lettering keeps clean gaps (the median alone turned them into blocks of the letters' tone). Alt/Option-drag shows highlight clipping | +| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632); from process V8, the gaps in dark lettering on a bright panel are not lifted into bright blocks. Alt/Option-drag shows shadow clipping (white, clipped channels in colour) | | LR-EDIT-LIGHT-WHITES | Whites | P0 | 🟡 | `ctl:light.whites`, `crates/pipeline/src/visualize.rs` | V4 preserves V2 Whites for both signs. Calibration is a planned follow-up; measured V2 check response error is 0.075 stops on the Sony test photos (`docs/light-v3.md`). Alt/Option-drag shows highlight clipping (black, clipped channels in colour) | | LR-EDIT-LIGHT-BLACKS | Blacks | P0 | 🟡 | `ctl:light.blacks`, `crates/pipeline/src/visualize.rs` | From process V4, global Blacks on the camera-look path uses a fitted per-channel/luminance mix. Check response error 0.424 to 0.077 stops on six held-out Sony photos; deepest tones still differ. Other sources and local adjustments retain V2 (`docs/light-v3.md`). Alt/Option-drag shows shadow clipping (white, clipped channels in colour) | | LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*`, `crates/pipeline/src/curve.rs` | the graph draws the renderer's own curve model; dragging the line moves the region under the pointer (one undo step) | diff --git a/docs/process-versions.md b/docs/process-versions.md index b27367ed6f..7424cf02e9 100644 --- a/docs/process-versions.md +++ b/docs/process-versions.md @@ -15,7 +15,8 @@ stronger Light sliders, issue #196) without changing a single photo someone has | **V4** | V3 plus calibrated global Contrast (per channel) and Blacks (per-channel/luminance mix) on raws with a camera tone curve. Whites behaves exactly as V2, both signs; its calibration is a planned follow-up. Other sources and local adjustments retain V2 operators. See [calibrated Light operators](light-v3.md). | | **V5** | V4 plus: a raw's tone curve rolls off into white in stops instead of reaching it within about a stop of the sensor's white, so raising Exposure keeps the highlights the sensor recorded (issue #1632). **Camera curve** (the look fitted to the embedded JPEG, or a DNG's `ProfileToneCurve`; `CameraTone`): past its last knot `CameraTone::apply_rolloff` closes the gap to white by `gap / (1 + c·(2^t − 1))` at `t` stops past the knot, with `c` matching the curve's slope there and the gap halving every stop in the end (`tone::ROLLOFF_STOPS`); `ToneMap::camera_rolloff_hdr` builds the table from it. Under V1 to V4 the curve past its last knot (fitted only to camera JPEG tones below 0.85, so 1 to 3 stops below the sensor's white on most photos) closes on white exponentially in linear light: the sensor's white renders at 8-bit 246 to 254 at Exposure 0 and 254 to 255 from Exposure +1. On the 11 CC0 corpus files with a fitted look (8 Sony, 2 Nikon, 1 Fujifilm) it now renders at 239 to 250 at Exposure 0 and 247 to 253 at +1. A DNG curve that reaches white at the sensor's white renders as before. **Generic curve** (raws without a camera curve, `ToneMap::rolloff_hdr`): from half a stop below its white point (`tone::GENERIC_KNEE_STOPS`) the same shoulder takes over, with the extended Reinhard curve's value and slope at the join, where V1 to V4 clip at the white point (scene 1.18 at default settings, ¼ stop above the sensor's white). The sensor's white renders at about 235, 248 and 252 at Exposure 0, +1 and +2 (V1 to V4: 239, 255, 255; the CC0 Canon R8 file and 6 CC0 DNGs without a camera curve). Content past the old clip point, such as a clipped sky, now renders up to 15 codes below white at Exposure 0 and reaches 255 about 5 stops past the sensor's white. Below the last knot or the join, JPEGs and other display-referred sources, and the camera fit itself are unchanged; CPU and GPU share the tone LUT. | | **V6** | V5 plus: colour noise reduction filters the linear colour differences R−Y and B−Y with the joint method (issue #815, `docs/nr-color-joint.md`): edge-aware weights from a denoised guide shared by both differences, anisotropic diffusion over scales, a brake for isolated colour specks; Y is kept exactly. V1 to V5 keep the blurred chromaticity (`local::denoise`). CPU (`local::denoise`) and GPU (`render::denoise`) branch on `local::joint_colour_nr`. Renders with Color 0 are the same as V2's. | -| **V7** (the latest) | V6 plus: film grain from crystal layers ([grain.md](grain.md), `crates/pipeline/src/crystal.rs`), after Aurélien Pierre's model: resolution independent (a preview is the export's grain box-downscaled), fading out at black and white, calibrated to Lightroom Classic's strength and width at mid grey. V1 to V6 keep their two-octave value noise exactly (`finish::ValueGrain`; on the GPU `F_GRAIN_MODEL` 0); `finish::GrainModel::new` picks the model per process. Renders without grain don't depend on this. | +| **V7** | V6 plus: film grain from crystal layers ([grain.md](grain.md), `crates/pipeline/src/crystal.rs`), after Aurélien Pierre's model: resolution independent (a preview is the export's grain box-downscaled), fading out at black and white, calibrated to Lightroom Classic's strength and width at mid grey. V1 to V6 keep their two-octave value noise exactly (`finish::ValueGrain`; on the GPU `F_GRAIN_MODEL` 0); `finish::GrainModel::new` picks the model per process. Renders without grain don't depend on this. | +| **V8** (the latest) | V7 plus: Highlights and Shadows take their edge-aware filter of log luminance without its fine detail (`local::detail_free`: each value clamped between its separable median at the V2 radius and the median of a 3× wider neighbourhood) instead of V2's plain median (`local::base_keeps_gaps`), so the gaps in dark lettering on a bright panel are not lifted into bright blocks (issue #3460). CPU (`local::tone_base`) and GPU (`render::tone_base`) branch on it; the base plane is keyed by process. Renders without Highlights or Shadows (global or local) are the same as V7's. | ## The rule @@ -47,6 +48,20 @@ only what was added, and with the mask's Invert it gave the whole photo. Such ma first component, on every process. It landed in place as a bug fix: intersecting with nothing is not a look anyone chose. +**Lettering under Highlights and Shadows** (V8) no longer grows blocks of the wrong tone. V2's base +is the edge-aware filter of a separable median alone. Dark lettering on a bright panel (a bus +number, a sign) has strokes and gaps about as wide as the median's radius, so each row and column +window was close to half letter and half panel, and the median picked one tone or the other in +rectangles: it filled the gaps between the letters and their counters with the letters' tone. +Shadows then lifted those patches of panel by over 1 EV, into bright blocks beside the letters. From V8 each +value moves toward that median only as far as the median of a 3× wider neighbourhood agrees +(`local::detail_free`). A twig against a wall has both medians on the wall's side and takes its tone, +as before (#632); the gap between two strokes sits in a wider neighbourhood that is mostly panel, the +two medians disagree, and the gap keeps its own tone. The letters themselves count as fine dark detail +on the panel and keep their contrast, as twigs do. V8 differs from V7 under Highlights or Shadows only in detail +about as fine as the radius, mostly where it is packed closely (lettering, grilles). V2 to V7 +are released and keep the blocks (`tests_legacy` locks V1 and V2); Update to Current Process moves a photo past them. + ## Adding a process 1. Add a variant to `Process` (`V2`), append it to `Process::ALL` (oldest first), give it its number in @@ -111,6 +126,7 @@ extra field and retain their existing cached renders. No cache-version bump is n | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | V8: the Highlights/Shadows base keeps the gaps in dark lettering (#3460) | | 2026-11-07 | minor | V7: film grain from crystal layers ([grain.md](grain.md)); V1 to V6 keep their value noise | | 2026-11-05 | minor | V5: a raw's tone curve rolls off into white in stops, so raising Exposure keeps recorded highlights (#1632) | | 2026-10-12 | minor | Calibrated Contrast and Blacks, V2 Whites, resolved-process cache keys and number coordination |