From d977d3e1a4e9f687b3cffcb4fadca41dcb966c1b Mon Sep 17 00:00:00 2001 From: Phil Hart Date: Wed, 4 Nov 2026 23:21:13 -0500 Subject: [PATCH] LR-EDIT-DETAIL-SHARPEN: Radius and Detail work, and sharpening acts at pixel scale (#757) (#762) * LR-EDIT-DETAIL-SHARPEN: Radius and Detail work, and sharpening acts at pixel scale (#757) * LR-EDIT-DETAIL-SHARPEN: an enlarging export keeps every Radius step (#757) The sharpening blur's sigma was capped at 4 px, so a 2x export rendered Radius 2 and 3 alike (both scaled past 4 px). The cap is now the largest sigma the Gaussian taps support, GAUSS_SIGMA_MAX (64), which the CPU and GPU blurs share. Test: enlarged_export_keeps_radius_steps_apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM * LR-EDIT-DETAIL-SHARPEN: preserve process-aware previews after rebase (#757) Use the V3 pixel-scale blur and Masking weight for the Radius, Detail and Masking previews on both renderers. Preserve the legacy texture-band previews for V1/V2 and compute the same Gaussian when sharpening is disabled. Cover process selection, radius changes, and prepared/fresh plane equivalence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/develop/src/settings.rs | 7 +- crates/engine/src/media/source.rs | 2 +- crates/gpu/src/ctx.rs | 5 + crates/gpu/src/params.rs | 12 ++ crates/gpu/src/render.rs | 40 +++- crates/gpu/src/wgsl/finish.wgsl | 27 ++- crates/gpu/src/wgsl/gauss.wgsl | 36 ++++ crates/gpu/tests/it/equivalence.rs | 51 +++++ crates/pipeline/src/finish.rs | 59 +++++- crates/pipeline/src/lib.rs | 31 +++- crates/pipeline/src/local.rs | 103 +++++++++-- crates/pipeline/src/tests.rs | 4 + crates/pipeline/src/tests_sharpen.rs | 266 +++++++++++++++++++++++++++ crates/pipeline/src/tests_tone.rs | 12 +- crates/pipeline/src/visualize.rs | 66 ++++++- crates/raster/src/blur.rs | 59 ++++++ docs/parity-checklist.md | 2 +- docs/process-versions.md | 3 +- 18 files changed, 728 insertions(+), 57 deletions(-) create mode 100644 crates/gpu/src/wgsl/gauss.wgsl create mode 100644 crates/pipeline/src/tests_sharpen.rs diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 8b8d4f8746..f422bba08b 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -84,17 +84,20 @@ pub enum Process { /// Highlights and Shadows keep the local contrast of the tones they move: their edge-aware base /// is taken of a fine median of log luminance (issue #632). V2, + /// Pixel-scale sharpening with working Radius and Detail controls. + V3, } impl Process { /// Every process, oldest first. - pub const ALL: [Process; 2] = [Process::V1, Process::V2]; - pub const LATEST: Process = Process::V2; + pub const ALL: [Process; 3] = [Process::V1, Process::V2, Process::V3]; + pub const LATEST: Process = Process::V3; pub const fn version(self) -> ProcessVersion { match self { Process::V1 => ProcessVersion(1), Process::V2 => ProcessVersion(2), + Process::V3 => ProcessVersion(3), } } } diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs index cf4a5fa2dc..c1ee14ca89 100644 --- a/crates/engine/src/media/source.rs +++ b/crates/engine/src/media/source.rs @@ -1,7 +1,7 @@ use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 74; +pub const RENDER_CACHE_VERSION: u64 = 75; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 3554064626..ff1e287082 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -32,6 +32,11 @@ const MODULES: &[Module] = &[ bindings: &[("src", false, "f32"), ("dst", true, "f32")], entries: &["box_h", "box_v", "minmax", "median_h", "median_v"], }, + Module { + src: include_str!("wgsl/gauss.wgsl"), + bindings: &[("src", false, "f32"), ("taps", false, "f32"), ("dst", true, "f32")], + entries: &["gauss_h", "gauss_v"], + }, Module { src: include_str!("wgsl/resize.wgsl"), bindings: &[("src", false, "f32"), ("table", false, "u32"), ("dst", true, "f32")], diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 61d8b5a80b..6d7f025dfa 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -28,6 +28,11 @@ const FIELDS: &[(&str, usize)] = &[ ("DEHAZE", 1), ("SHARPEN", 1), ("SHARPEN_MASK", 1), + ("SHARPEN_LIMIT", 1), + ("SHARPEN_GAIN", 1), + ("SHARPEN_V3", 1), + ("SHP_OFF", 1), + ("HAS_SHP", 1), ("HAS_CLAR", 1), ("HAS_TEX", 1), ("HAS_DARK", 1), @@ -147,6 +152,8 @@ impl Block { pub struct Present { pub clarity: bool, pub texture: bool, + /// The sharpening blur plane (after the texture plane in `tex` when both are present). + pub sharpen: bool, pub dark: bool, /// The blurred chromaticity follows the mask planes in the `masks` buffer. pub chroma: bool, @@ -195,6 +202,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.f("DEHAZE", fp.dehaze); p.f("SHARPEN", fp.sharpen); p.f("SHARPEN_MASK", fp.sharpen_mask); + p.f("SHARPEN_LIMIT", fp.sharpen_limit); + p.f("SHARPEN_GAIN", fp.sharpen_gain); + p.b("SHARPEN_V3", fp.pixel_sharpen); + p.u("SHP_OFF", if present.texture { (fp.w * fp.h) as u32 } else { 0 }); + p.b("HAS_SHP", present.sharpen); p.b("HAS_CLAR", present.clarity); p.b("HAS_TEX", present.texture); p.b("HAS_DARK", present.dark); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 1f079285af..701cc3c5de 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -44,6 +44,7 @@ struct Planes { base: Option<(local::BaseKey, Arc)>, clarity: Option<(u32, Arc)>, texture: Option<(u32, Arc)>, + sharpen: Option<(local::SharpenKey, Arc)>, dark: Option<(u32, Arc, f32)>, chroma: Option<(u32, Arc)>, } @@ -105,6 +106,7 @@ impl GpuStages { p.base.iter().for_each(|(_, b)| add(b)); p.clarity.iter().for_each(|(_, b)| add(b)); p.texture.iter().for_each(|(_, b)| add(b)); + p.sharpen.iter().for_each(|(_, b)| add(b)); p.dark.iter().for_each(|(_, b, _)| add(b)); } if let Some((_, b)) = &*self.source.lock().unwrap_or_else(|e| e.into_inner()) { @@ -277,6 +279,18 @@ pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize if last == 0 { a } else { b } } +/// Exact Gaussian (`lightcraft_raster::blur::gaussian_taps`) of a one-channel plane. +pub(crate) fn gaussian_fir(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, sigma: f32) -> Buf { + let taps = lightcraft_raster::blur::gauss_taps(sigma); + let taps_buf = cx.gpu.upload(&taps); + let r = (taps.len() / 2) as u32; + let (tmp, out) = (cx.gpu.buffer(w * h), cx.gpu.buffer(w * h)); + let p = [w as u32, h as u32, r]; + cx.run("gauss_h", &p, &[Some(src), Some(&taps_buf), Some(&tmp)], groups2(w, h, [16, 16])); + cx.run("gauss_v", &p, &[Some(&tmp), Some(&taps_buf), Some(&out)], groups2(w, h, [16, 16])); + out +} + /// Resample taps of `lightcraft_raster::resample::resize`, packed for the `resize_*` kernels. fn taps(src: usize, dst: usize, filter: Filter) -> Vec { let w = lightcraft_raster::resample::weights(src, dst, filter); @@ -733,9 +747,22 @@ pub fn render( let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), + sharpen: prep.sharpen.is_some(), dark: prep.dark.is_some(), chroma: masks.is_some() && prep.chroma.is_some(), }; + // the finish kernel's `tex` binding: the texture plane, then the sharpening plane (one binding + // for both keeps the kernel within the storage-buffer count every supported device has) + let tex_buf: Option> = match (&prep.texture, &prep.sharpen) { + (Some(t), Some(sh)) => { + let both = cx.gpu.buffer(2 * n); + cx.copy_into(t, &both, 0); + cx.copy_into(sh, &both, n); + Some(Arc::new(both)) + } + (Some(b), None) | (None, Some(b)) => Some(b.clone()), + (None, None) => None, + }; let (p, aux) = finish_block(&fp, &terms, &present); let aux = gpu.upload(&aux); // cleared, so that work which never ran cannot pass for an image (see the check below) @@ -758,7 +785,7 @@ pub fn render( Some(&prep.log_l), Some(&prep.base), prep.clarity.as_deref(), - prep.texture.as_deref(), + tex_buf.as_deref(), prep.dark.as_deref(), masks.as_ref(), Some(&aux), @@ -836,8 +863,9 @@ pub fn render( let overlay_mask = overlay_mask.or_else(|| { req.overlay.reads_detail().then(|| { let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone(); - let tex = prep.texture.as_deref().map(|b| cx.read_plane(b, w, h)); - lightcraft_pipeline::visualize::sharpen_plane(req.overlay, &plan.settings, &l, tex.as_ref(), plan.px_per_long) + let band = if local::pixel_sharpen(&plan.settings) { &prep.sharpen } else { &prep.texture }; + let blur = band.as_deref().map(|b| cx.read_plane(b, w, h)); + lightcraft_pipeline::visualize::sharpen_plane(req.overlay, &plan.settings, &l, blur.as_ref(), plan.px_per_long, plan.native_long as f64) })? }); lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref()); @@ -1056,6 +1084,7 @@ struct Prep { base: Arc, clarity: Option>, texture: Option>, + sharpen: Option>, dark: Option>, /// Blurred chromaticity (local Moiré / Noise). chroma: Option>, @@ -1066,7 +1095,7 @@ struct Prep { fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, planes: &mut Planes) -> Prep { let (w, h) = (plan.w, plan.h); let n = w * h; - let sig = local::plane_sigmas(&plan.settings, plan.px_per_long, req.quality); + let sig = local::plane_sigmas(&plan.settings, plan.px_per_long, plan.native_long as f64, req.quality); let log_l = match &planes.log_l { Some(b) => b.clone(), None => { @@ -1086,6 +1115,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla }; let clarity = sig.clarity.map(|sg| plane_at(&mut planes.clarity, sg.to_bits(), || guided_fast(cx, &log_l, w, h, sg, local::CLARITY_EPS))); let texture = sig.texture.map(|sg| plane_at(&mut planes.texture, sg.to_bits(), || gaussian(cx, &log_l, w, h, 1, sg))); + let sharpen = sig.sharpen.map(|sg| plane_at(&mut planes.sharpen, local::sharpen_key(&plan.settings, sg), || gaussian_fir(cx, &log_l, w, h, sg))); let (dark, air) = match sig.dark { None => (None, 1.0), Some(sg) => match &planes.dark { @@ -1113,7 +1143,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla gaussian(cx, &ch, w, h, 3, sg) }) }); - Prep { log_l, base, clarity, texture, dark, chroma, air } + Prep { log_l, base, clarity, texture, sharpen, dark, chroma, air } } /// Most brush dabs the mask kernel evaluates per pixel (more: the CPU rasterizes the brush). diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index b5f35eac2a..a7f74b80cb 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -453,19 +453,28 @@ fn main(@builtin(global_invocation_id) gid: vec3) { delta += cl * 0.85 * det * (0.35 + 0.65 * mid); } let tx = pf(F_TEX) + lt[8]; - let sp = lt[13] * 0.6 + pf(F_SHARPEN); - if ((tx != 0.0 || sp != 0.0) && pu(F_HAS_TEX) != 0u) { + if (tx != 0.0 && pu(F_HAS_TEX) != 0u) { let det = l_pre - tex[i]; let tame = 1.0 - 0.6 * sstep(0.4, 1.6, abs(det)); delta += tx * 1.1 * clamp(det, -1.0, 1.0) * tame; - if (sp != 0.0) { - let sm = pf(F_SHARPEN_MASK); - var mk = 1.0; - if (sm > 0.0) { - mk = sstep(sm * 0.25, sm * 0.25 + 0.15, abs(det)); - } - delta += sp * 1.3 * clamp(det, -0.8, 0.8) * mk; + } + let sp = lt[13] * 0.6 + pf(F_SHARPEN); + if (pu(F_SHARPEN_V3) != 0u && sp != 0.0 && pu(F_HAS_SHP) != 0u) { + let det = l_pre - tex[pu(F_SHP_OFF) + i]; + let sm = pf(F_SHARPEN_MASK); + var mk = 1.0; + if (sm > 0.0) { + mk = sstep(sm * 0.1, sm * 0.1 + 0.06, abs(det)); } + delta += sp * pf(F_SHARPEN_GAIN) * 7.0 * mk * det / (1.0 + abs(det) / pf(F_SHARPEN_LIMIT)); + } else if (pu(F_SHARPEN_V3) == 0u && sp != 0.0 && pu(F_HAS_TEX) != 0u) { + let det = l_pre - tex[i]; + let sm = pf(F_SHARPEN_MASK); + var mk = 1.0; + if (sm > 0.0) { + mk = sstep(sm * 0.25, sm * 0.25 + 0.15, abs(det)); + } + delta += sp * 1.3 * clamp(det, -0.8, 0.8) * mk; } // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges if (l_noise != 0.0 && pu(F_HAS_TEX) != 0u) { diff --git a/crates/gpu/src/wgsl/gauss.wgsl b/crates/gpu/src/wgsl/gauss.wgsl new file mode 100644 index 0000000000..a3ad4e7234 --- /dev/null +++ b/crates/gpu/src/wgsl/gauss.wgsl @@ -0,0 +1,36 @@ +// Exact separable Gaussian with explicit taps over a one-channel plane (clamped edges); mirrors +// `lightcraft_raster::blur::gaussian_taps` (same taps, same accumulation order). +// P: w, h, r (taps has 2r+1 entries). Bindings: src, taps, dst. + +@compute @workgroup_size(16, 16) +fn gauss_h(@builtin(global_invocation_id) g: vec3) { + let w = pu(0u); + let h = pu(1u); + let r = i32(pu(2u)); + if (g.x >= w || g.y >= h) { + return; + } + let last = i32(w) - 1; + let row = g.y * w; + var acc = 0.0; + for (var k = 0; k <= 2 * r; k++) { + acc += src[row + u32(clamp(i32(g.x) + k - r, 0, last))] * taps[u32(k)]; + } + dst[row + g.x] = acc; +} + +@compute @workgroup_size(16, 16) +fn gauss_v(@builtin(global_invocation_id) g: vec3) { + let w = pu(0u); + let h = pu(1u); + let r = i32(pu(2u)); + if (g.x >= w || g.y >= h) { + return; + } + let last = i32(h) - 1; + var acc = 0.0; + for (var k = 0; k <= 2 * r; k++) { + acc += src[u32(clamp(i32(g.y) + k - r, 0, last)) * w + g.x] * taps[u32(k)]; + } + dst[g.y * w + g.x] = acc; +} diff --git a/crates/gpu/tests/it/equivalence.rs b/crates/gpu/tests/it/equivalence.rs index 68b9fd2227..01686b034c 100644 --- a/crates/gpu/tests/it/equivalence.rs +++ b/crates/gpu/tests/it/equivalence.rs @@ -242,6 +242,33 @@ fn cases() -> Vec<(&'static str, Edit)> { s.effects.clarity = -60.0; s.effects.texture = -40.0; }), + ("sharpen radius 2.5 detail 100", |s| { + s.detail.sharpen_amount = 100.0; + s.detail.sharpen_radius = 2.5; + s.detail.sharpen_detail = 100.0; + }), + ("sharpen radius 0.6 detail 0", |s| { + s.detail.sharpen_amount = 80.0; + s.detail.sharpen_radius = 0.6; + s.detail.sharpen_detail = 0.0; + }), + ("sharpen + texture + masking", |s| { + s.detail.sharpen_amount = 70.0; + s.detail.sharpen_masking = 30.0; + s.effects.texture = 40.0; + }), + ("local sharpness", |s| { + s.masks = vec![Mask { + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) }, + }], + adjust: lightcraft_develop::LocalAdjustments { sharpness: 80.0, ..Default::default() }, + ..Default::default() + }]; + }), ("sharpen masking", |s| { s.detail.sharpen_amount = 90.0; s.detail.sharpen_masking = 60.0; @@ -664,6 +691,30 @@ fn highlights_shadows_base_matches_on_every_process() { } } +#[test] +fn sharpening_matches_each_process_and_reduced_source() { + if !gpu() { + return; + } + let src = scene(0, 256, 192); + let info = SourceInfo { raw: true, native_long: Some(1024), ..Default::default() }; + let req = RenderRequest::fit(128, 128); + let cache = StageCache::default(); + for process in [Process::V1, Process::V2, Process::V3, Process::V2, Process::V3] { + let mut s = DevelopSettings { process: process.version(), ..Default::default() }; + s.detail.sharpen_amount = 80.0; + s.detail.sharpen_radius = 2.0; + s.detail.sharpen_detail = 75.0; + for nr in [0.0, 50.0, 20.0] { + s.detail.nr_luminance = nr; + check(&format!("{process:?} sharpening NR {nr}"), &src, &info, &s, &req); + let warm = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu"); + let fresh = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu"); + assert_eq!(warm.image, fresh.image, "{process:?} NR {nr}"); + } + } +} + #[test] fn overlays_match() { if !gpu() { diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 99a6e50246..65d7976bac 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -29,6 +29,31 @@ pub fn sharpen_mask_weight(masking: f32, det: f32) -> f32 { if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 } } +/// Masking weight for the V3 pixel-scale sharpening band, also used by its Alt-drag preview. +#[inline] +pub fn pixel_sharpen_mask_weight(masking: f32, det: f32) -> f32 { + if masking > 0.0 { smooth(masking * 0.1, masking * 0.1 + 0.06, det.abs()) } else { 1.0 } +} + +/// Gain of the sharpening detail term (per unit of `Amount / 150`), in stops of luminance per stop of detail. +pub const SHARPEN_GAIN: f32 = 7.0; + +/// Soft-clip limit (stops) of the sharpening detail term for Detail `0..=100`: 0.03 stops (large +/// edge detail is flattened so halos stay small) up to 1.5 stops (practically linear). +pub fn sharpen_limit(detail: f64) -> f32 { + let d = (detail / 100.0).clamp(0.0, 1.0) as f32; + 0.03 * 50f32.powf(d) +} + +/// The sharpening boost (stops) for pixel-scale detail `det` (log luminance minus its blur at the +/// sharpen radius): linear for small detail, soft-clipped at `limit`, and gated by the Masking edge +/// mask (`mask` = Masking / 100) so flat areas and noise can be left alone. +#[inline] +pub fn sharpen_term(det: f32, limit: f32, mask: f32) -> f32 { + let m = pixel_sharpen_mask_weight(mask, det); + SHARPEN_GAIN * m * det / (1.0 + det.abs() / limit) +} + /// Parametric region curve (encoded domain) composed with the master point curve. fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> { let parametric = c.highlights != 0.0 || c.lights != 0.0 || c.darks != 0.0 || c.shadows != 0.0; @@ -178,6 +203,12 @@ pub struct FinishParams { pub dehaze: f32, pub sharpen: f32, pub sharpen_mask: f32, + /// V1/V2 retain their texture-band sharpening; V3 uses the pixel-scale plane. + pub pixel_sharpen: bool, + /// Soft-clip limit (stops) of the sharpening detail term: small damps halos (Detail 0), large keeps all (100). + pub sharpen_limit: f32, + /// Response compensation for a sharpening radius below one output pixel. + pub sharpen_gain: f32, /// Airlight after and before exposure, exposure gain and EV (see [`crate::Prepared`]). pub air: f32, pub air_pre: f32, @@ -256,6 +287,13 @@ impl FinishParams { dehaze, sharpen: (s.detail.sharpen_amount / 150.0) as f32, sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32, + pixel_sharpen: crate::local::pixel_sharpen(s), + sharpen_limit: sharpen_limit(s.detail.sharpen_detail), + sharpen_gain: crate::local::sharpen_gain(crate::local::sharpen_sigma_raw( + s, + px_per_long, + info.native_long.unwrap_or(0).max(frame.ow.max(frame.oh) as usize).max(1) as f64, + )), air: air_pre * gain, air_pre, gain, @@ -292,7 +330,7 @@ pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap { let l = &s.light; match s.process.process() { // V2 changes the Highlights/Shadows base (`local::tone_base`), not the tone map - Process::V1 | Process::V2 => match info.camera_tone.as_ref().filter(|_| info.raw) { + Process::V1 | Process::V2 | Process::V3 => match info.camera_tone.as_ref().filter(|_| info.raw) { Some(curve) => ToneMap::camera_hdr(curve, l.contrast, l.whites, l.blacks, peak), None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak), None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak), @@ -662,17 +700,26 @@ fn local_tone_delta(p: &Prepared, fp: &FinishParams, index: usize, base: f32, l1 delta += clarity * 0.85 * detail * (0.35 + 0.65 * midtone); } let texture = fp.tex + l_tex; - let sharpen = l_sharp * 0.6 + fp.sharpen; - if (texture != 0.0 || sharpen != 0.0) + if texture != 0.0 && let Some(blur) = &p.texture_blur { let detail = p.log_l.data[index] - blur.data[index]; let tame = 1.0 - 0.6 * smooth(0.4, 1.6, detail.abs()); delta += texture * 1.1 * detail.clamp(-1.0, 1.0) * tame; - if sharpen != 0.0 { - let mask = sharpen_mask_weight(fp.sharpen_mask, detail); - delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask; + } + let sharpen = l_sharp * 0.6 + fp.sharpen; + if fp.pixel_sharpen { + if sharpen != 0.0 + && let Some(blur) = &p.sharpen_blur + { + delta += sharpen * fp.sharpen_gain * sharpen_term(p.log_l.data[index] - blur.data[index], fp.sharpen_limit, fp.sharpen_mask); } + } else if sharpen != 0.0 + && let Some(blur) = &p.texture_blur + { + let detail = p.log_l.data[index] - blur.data[index]; + let mask = sharpen_mask_weight(fp.sharpen_mask, detail); + delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask; } if l_noise != 0.0 && let Some(blur) = &p.texture_blur diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 7124383998..2c7700ed4c 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -227,6 +227,8 @@ pub(crate) struct Prepared { pub base: Arc, pub clarity_blur: Option>, pub texture_blur: Option>, + /// Log luminance blurred at the sharpen radius (the pixel-scale band sharpening boosts). + pub sharpen_blur: Option>, pub dark: Option>, /// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise. pub chroma_blur: Option>, @@ -329,7 +331,7 @@ impl StageCache { } let pl = &e.planes; let planes = pl.log_l.iter().chain(pl.base.iter().map(|x| &x.1)).chain(pl.clarity.iter().map(|x| &x.1)); - for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) { + for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.sharpen.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) { add(Arc::as_ptr(p) as usize, size(p)); } } @@ -389,6 +391,8 @@ pub struct Plan<'a> { pub px_per_long: f64, /// Long edge of the source buffer (px). pub src_long: usize, + /// Long edge of the original photo, shared by sharpening and noise reduction. + pub native_long: usize, /// Output pixels per pixel of the original photo ([`SourceInfo::native_long`], else the source /// buffer), at most 1: the scale of noise reduction, whose radii are in the original's pixels. pub detail_scale: f32, @@ -478,7 +482,23 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), detail_scale.to_bits(), )); - Plan { settings, frame, w, h, px_per_long, src_long, detail_scale, geo, lin_key, pre_nr_key, eyes, fixed_air, keep, mattes: info.mattes.clone() } + Plan { + settings, + frame, + w, + h, + px_per_long, + src_long, + native_long, + detail_scale, + geo, + lin_key, + pre_nr_key, + eyes, + fixed_air, + keep, + mattes: info.mattes.clone(), + } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -611,7 +631,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende Some(p) if req.quality == Quality::Draft && p.pre_nr == plan.pre_nr_key => p.for_other_nr(), _ => local::Planes { key: lin_key, pre_nr: plan.pre_nr_key, ..Default::default() }, }; - let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_deref()); + let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, plan.native_long as f64, req.quality, &mut planes, plan.mattes.as_deref()); if let Some(a) = plan.fixed_air { prep.air = a; } @@ -689,7 +709,8 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, /// (the one the render evaluated, or for a hidden mask a fresh evaluation). fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { if o.reads_detail() { - return visualize::sharpen_plane(o, &plan.settings, &prep.log_l, prep.texture_blur.as_deref(), plan.px_per_long); + let band = if local::pixel_sharpen(&plan.settings) { &prep.sharpen_blur } else { &prep.texture_blur }; + return visualize::sharpen_plane(o, &plan.settings, &prep.log_l, band.as_deref(), plan.px_per_long, plan.native_long as f64); } let m = o.mask(&plan.settings)?; if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { @@ -763,6 +784,8 @@ mod tests_mask_geometry; #[cfg(test)] mod tests_nr_scale; #[cfg(test)] +mod tests_sharpen; +#[cfg(test)] mod tests_tone; #[cfg(test)] mod tests_window; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 6d09cf7557..547131b8bf 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -4,7 +4,7 @@ use lightcraft_color::cct::{temp_tint_to_xy, wb_matrix, xy_to_temp_tint}; use lightcraft_color::{REC2020, luminance_2020}; use lightcraft_develop::{DevelopSettings, Process}; -use lightcraft_raster::blur::gaussian; +use lightcraft_raster::blur::{gauss_taps, gaussian, gaussian_taps}; use std::sync::Arc; use lightcraft_raster::{Plane, Rgb32f, par_join}; @@ -375,7 +375,7 @@ 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 => base_detail_radius(px_per_long), + Process::V2 | Process::V3 => base_detail_radius(px_per_long), } } @@ -521,6 +521,8 @@ pub(crate) struct Planes { pub base: Option<(BaseKey, Arc)>, pub clarity: Option<(u32, Arc)>, pub texture: Option<(u32, Arc)>, + /// Sharpening blur (exact Gaussian at the sharpen radius). + pub sharpen: Option<(SharpenKey, Arc)>, /// Dark channel and its airlight. pub dark: Option<(u32, Arc, f32)>, /// Blurred chromaticity (local Moiré / Noise). @@ -532,7 +534,7 @@ impl Planes { /// enough for a draft: the wide blurs (base, clarity, dehaze, chromaticity). Its `key` matches /// no image, so only another draft takes them. pub fn for_other_nr(self) -> Planes { - Planes { key: 0, pre_nr: self.pre_nr, log_l: None, texture: None, ..self } + Planes { key: 0, pre_nr: self.pre_nr, log_l: None, texture: None, sharpen: None, ..self } } } @@ -541,6 +543,16 @@ impl Planes { /// processes, so a photo moved to another one (Update to Current Process, undo) recomputes it. pub type BaseKey = (u32, Process, usize); +pub type SharpenKey = (u32, Process); + +pub fn sharpen_key(s: &DevelopSettings, sigma: f32) -> SharpenKey { + (sigma.to_bits(), s.process.process()) +} + +pub fn pixel_sharpen(s: &DevelopSettings) -> bool { + s.process.process() >= Process::V3 +} + /// The [`BaseKey`] of a base at `sigma` for `s` (`detail`: [`PlaneSigmas::base_detail`]). pub fn base_key(s: &DevelopSettings, sigma: f32, detail: usize) -> BaseKey { (sigma.to_bits(), s.process.process(), detail) @@ -584,6 +596,8 @@ pub struct PlaneSigmas { pub clarity: Option, /// Texture / sharpening band (Gaussian). pub texture: Option, + /// Sharpening blur (exact Gaussian, see [`sharpen_sigma`]). + pub sharpen: Option, /// Dehaze dark channel (Gaussian). pub dark: Option, /// Chromaticity blur for local Moiré / colour noise (Gaussian). @@ -595,7 +609,45 @@ pub fn texture_sigma(px_per_long: f64) -> f32 { (0.0018 * px_per_long as f32).max(0.6) } -pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas { +/// Sharpening blur radius in output pixels before clamping: Radius (source pixels) times the +/// output pixels per original pixel (`native_long` = the original photo's long edge), so a preview and +/// an export show the same detail at the same displayed scale. +pub fn sharpen_sigma_raw(s: &DevelopSettings, px_per_long: f64, native_long: f64) -> f32 { + let r = s.detail.sharpen_radius; + let radius = if r.is_finite() { r.clamp(0.1, 10.0) } else { 1.0 }; + (radius * px_per_long / native_long.max(1.0)) as f32 +} + +/// Smallest and largest sigma (px) of the sharpening blur. The largest is the most the Gaussian taps +/// support (CPU and GPU share them), so an enlarging export keeps every Radius step distinct. +pub const SHARPEN_SIGMA_MIN: f32 = 0.5; +pub const SHARPEN_SIGMA_MAX: f32 = lightcraft_raster::blur::GAUSS_SIGMA_MAX; + +/// The sharpening blur's sigma for a raw value of [`sharpen_sigma_raw`]. +pub fn sharpen_sigma(raw: f32) -> f32 { + raw.clamp(SHARPEN_SIGMA_MIN, SHARPEN_SIGMA_MAX) +} + +/// Match the continuous Gaussian's Nyquist response below half a pixel, using a representable +/// FIR blur. Blend the correction out between half and one pixel: no jump at the floor, and +/// full-pixel sharpening (including Radius 1 at native size) is unchanged. +pub fn sharpen_gain(raw: f32) -> f32 { + if !raw.is_finite() || raw <= 0.0 { + return 0.0; + } + if raw >= 1.0 { + return 1.0; + } + let taps = gauss_taps(sharpen_sigma(raw)); + let radius = taps.len() / 2; + let response = taps.iter().enumerate().map(|(i, &t)| if (i + radius).is_multiple_of(2) { t } else { -t }).sum::(); + let desired = -(-0.5 * std::f32::consts::PI.powi(2) * raw * raw).exp_m1(); + let ratio = desired / (1.0 - response).max(1e-6); + let blend = ((1.0 - raw) / 0.5).clamp(0.0, 1.0); + 1.0 + (ratio - 1.0) * blend +} + +pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, native_long: f64, q: Quality) -> PlaneSigmas { let ppl = px_per_long as f32; // A hidden mask, or one with no components, does not change the picture. Its sliders must // not pick the tone base Clarity reads (#1214). @@ -614,16 +666,19 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS let clarity = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| (0.012 * ppl).max(1.0)); // local Noise and Defringe read the fine detail band too let texture = (s.effects.texture != 0.0 - || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) - || local_any(|a| a.sharpness) || local_any(|a| a.noise) + || (!pixel_sharpen(s) && (s.detail.sharpen_amount != 0.0 || local_any(|a| a.sharpness))) || s.masks.iter().filter(live).any(|m| m.adjust.defringe > 0.0)) .then(|| texture_sigma(px_per_long)); let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); let chroma = (local_any(|a| a.moire) || s.masks.iter().filter(live).any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 }; - PlaneSigmas { base, base_detail, clarity, texture, dark, chroma } + // V1/V2 keep their texture-band sharpening; only V3 requests the pixel-scale plane. + let sharpen_raw = sharpen_sigma_raw(s, px_per_long, native_long); + let sharpen = (pixel_sharpen(s) && sharpen_gain(sharpen_raw) > 0.0 && (s.detail.sharpen_amount != 0.0 || local_any(|a| a.sharpness))) + .then(|| sharpen_sigma(sharpen_raw)); + PlaneSigmas { base, base_detail, clarity, texture, sharpen, dark, chroma } } /// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure), @@ -635,15 +690,23 @@ pub(crate) fn prepare( s: &DevelopSettings, frame: &Frame, px_per_long: f64, + native_long: f64, q: Quality, planes: &mut Planes, mattes: Option<&masks::Mattes>, ) -> Prepared { let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone(); - let PlaneSigmas { base: base_sigma, base_detail, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } = - plane_sigmas(s, px_per_long, q); + let PlaneSigmas { + base: base_sigma, + base_detail, + clarity: clarity_sigma, + texture: texture_sigma, + sharpen: sharpen_sigma_px, + dark: dark_sigma, + chroma: chroma_sigma, + } = plane_sigmas(s, px_per_long, native_long, q); - let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes; + let Planes { base: sb, clarity: sc, texture: st, sharpen: ss, dark: sd, chroma: sch, .. } = planes; let chroma_blur = chroma_sigma.map(|sg| match sch { Some((k, c)) if *k == sg.to_bits() => c.clone(), _ => { @@ -653,14 +716,22 @@ pub(crate) fn prepare( } }); let l = &log_l; - let (base, (clarity_blur, (texture_blur, dark))) = par_join( + 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)))), || { par_join( || clarity_sigma.map(|sg| plane_at(sc, sg.to_bits(), || timed("clarity", || guided_fast(l, sg, CLARITY_EPS)))), || { par_join( - || texture_sigma.map(|sg| plane_at(st, sg.to_bits(), || timed("texture", || gaussian(l, sg)))), + || { + par_join( + || texture_sigma.map(|sg| plane_at(st, sg.to_bits(), || timed("texture", || gaussian(l, sg)))), + || { + sharpen_sigma_px + .map(|sg| plane_at(ss, sharpen_key(s, sg), || timed("sharpen", || gaussian_taps(l, &gauss_taps(sg))))) + }, + ) + }, || { dark_sigma.map(|sg| match sd { Some((k, d, air)) if *k == sg.to_bits() => (d.clone(), *air), @@ -684,7 +755,7 @@ pub(crate) fn prepare( }; let ev = s.light.exposure as f32; let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev, mattes)); - Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long } + Prepared { img, log_l, base, clarity_blur, texture_blur, sharpen_blur, dark, chroma_blur, air, masks, px_per_long } } /// The airlight of the whole output frame, estimated on a small render of it (`proxy_w` px wide): @@ -814,17 +885,17 @@ mod tests { adjust: lightcraft_develop::LocalAdjustments { highlights: 40.0, ..Default::default() }, ..Default::default() }); - assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_none()); + assert!(plane_sigmas(&s, 2000.0, 2000.0, Quality::Full).base.is_none()); s.masks[0].visible = true; s.masks[0].components.clear(); - assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_none()); + assert!(plane_sigmas(&s, 2000.0, 2000.0, Quality::Full).base.is_none()); s.masks[0].components.push(MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, }); - assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_some()); + assert!(plane_sigmas(&s, 2000.0, 2000.0, Quality::Full).base.is_some()); } #[test] diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 5c5f1b95c1..cee4ec1ffb 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -287,12 +287,16 @@ fn drafts_keep_wide_planes_across_a_noise_reduction_change_and_full_renders_do_n let full = RenderRequest { quality: Quality::Full, ..draft }; let mut s = typical_edits(); s.effects.clarity = 60.0; + s.detail.sharpen_amount = 80.0; render_cached(&src, &info, &s, &draft, &cache); let clarity_plane = |c: &StageCache| c.entries.lock().unwrap().first().and_then(|e| e.planes.clarity.clone()).unwrap().1; + let sharpen_plane = |c: &StageCache| c.entries.lock().unwrap().first().and_then(|e| e.planes.sharpen.clone()).unwrap().1; let first = clarity_plane(&cache); + let first_sharpen = sharpen_plane(&cache); s.detail.nr_luminance = 50.0; render_cached(&src, &info, &s, &draft, &cache); assert!(Arc::ptr_eq(&first, &clarity_plane(&cache)), "a draft redid the clarity plane for a noise-reduction change"); + assert!(!Arc::ptr_eq(&first_sharpen, &sharpen_plane(&cache)), "a draft reused the old sharpening plane after noise reduction changed"); for v in [10.0, 45.0, 80.0] { s.detail.nr_luminance = v; s.detail.nr_color = v / 2.0; diff --git a/crates/pipeline/src/tests_sharpen.rs b/crates/pipeline/src/tests_sharpen.rs new file mode 100644 index 0000000000..69850c1600 --- /dev/null +++ b/crates/pipeline/src/tests_sharpen.rs @@ -0,0 +1,266 @@ +//! Sharpening: an unsharp mask at the pixel scale, with Radius (source pixels), Detail (halo +//! damping) and Masking (edge mask), the same at every preview and export size. + +use lightcraft_develop::DevelopSettings; +use lightcraft_raster::{Rgb32f, Rgba8}; + +use crate::{RenderRequest, SourceInfo, render}; + +fn info() -> SourceInfo { + SourceInfo { raw: true, ..Default::default() } +} + +fn render_at(src: &Rgb32f, s: &DevelopSettings, w: usize) -> Rgba8 { + render(src, &info(), s, &RenderRequest::fit(w, w)).image +} + +/// RMS difference of the green channel. +fn rms(a: &Rgba8, b: &Rgba8) -> f64 { + assert_eq!((a.width, a.height), (b.width, b.height)); + let sum: f64 = a.data.iter().zip(&b.data).map(|(p, q)| (p[1] as f64 - q[1] as f64).powi(2)).sum(); + (sum / a.data.len() as f64).sqrt() +} + +/// A grating with the given period (px at 512 wide), in scene-linear light. +fn grating(w: usize, period: f32) -> Rgb32f { + let h = w / 8; + Rgb32f::from_fn(w, h, |x, _| { + let k = w as f32 / 512.0; + let v = 0.18 * (1.0 + 0.4 * (std::f32::consts::TAU * x as f32 / (period * k)).sin()); + [v; 3] + }) +} + +/// A scene with a hard edge, fine texture and a gradient, defined in normalized coordinates so it +/// can be sampled at any size. +fn scene(w: usize) -> Rgb32f { + let h = w * 3 / 4; + Rgb32f::from_fn(w, h, |x, y| { + let (u, v) = (x as f32 / w as f32, y as f32 / w as f32); + let edge = if u > 0.5 { 0.3 } else { 0.08 }; + let tex = 0.03 * (u * 150.0).sin() * (v * 130.0).cos(); + let g = 0.1 * v; + let l = (edge + tex + g).max(0.001); + [l, l * 0.9, l * 0.8] + }) +} + +fn sharp(amount: f64, radius: f64, detail: f64, masking: f64) -> DevelopSettings { + let mut s = DevelopSettings::default(); + s.detail.sharpen_amount = amount; + s.detail.sharpen_radius = radius; + s.detail.sharpen_detail = detail; + s.detail.sharpen_masking = masking; + s +} + +#[test] +fn amount_zero_is_the_identity_whatever_radius_and_detail() { + let src = scene(256); + let plain = render_at(&src, &DevelopSettings::default(), 256); + for (r, d) in [(0.5, 0.0), (1.0, 25.0), (3.0, 100.0)] { + assert_eq!(render_at(&src, &sharp(0.0, r, d, 0.0), 256).data, plain.data, "radius {r} detail {d}"); + } +} + +#[test] +fn radius_changes_the_output() { + let src = scene(256); + let a = render_at(&src, &sharp(60.0, 0.8, 25.0, 0.0), 256); + let b = render_at(&src, &sharp(60.0, 2.5, 25.0, 0.0), 256); + assert_ne!(a.data, b.data); + assert!(rms(&a, &b) > 0.3, "{}", rms(&a, &b)); +} + +#[test] +fn larger_radius_boosts_coarser_detail() { + // gain on a fine (4 px) and a coarse (24 px) grating, for a small and a large radius + let gain = |period: f32, radius: f64| { + let src = grating(512, period); + rms(&render_at(&src, &sharp(80.0, radius, 100.0, 0.0), 512), &render_at(&src, &sharp(0.0, radius, 100.0, 0.0), 512)) + }; + let (fine_small, fine_large) = (gain(4.0, 0.6), gain(4.0, 3.0)); + let (coarse_small, coarse_large) = (gain(24.0, 0.6), gain(24.0, 3.0)); + eprintln!("fine {fine_small} {fine_large}, coarse {coarse_small} {coarse_large}"); + assert!(coarse_large > 2.0 * coarse_small, "coarse: small {coarse_small}, large {coarse_large}"); + assert!(fine_small > coarse_small, "a small radius favours fine detail: {fine_small} vs {coarse_small}"); + // the large radius moves its emphasis to coarser detail + assert!(coarse_large / fine_large > 2.0 * coarse_small / fine_small, "{coarse_large}/{fine_large} vs {coarse_small}/{fine_small}"); +} + +#[test] +fn detail_changes_the_output_and_damps_halos() { + let src = scene(256); + let lo = render_at(&src, &sharp(80.0, 1.5, 0.0, 0.0), 256); + let hi = render_at(&src, &sharp(80.0, 1.5, 100.0, 0.0), 256); + let plain = render_at(&src, &sharp(0.0, 1.5, 0.0, 0.0), 256); + assert_ne!(lo.data, hi.data); + // the overshoot next to the hard edge (u = 0.5) shrinks with Detail 0 + let row = 96 * 256; + let at = |img: &Rgba8, x: usize| img.data[row + x][1] as i32; + let over = |img: &Rgba8| (118..128).map(|x| (at(img, x) - at(&plain, x)).abs()).max().unwrap_or(0); + assert!(over(&lo) < over(&hi), "halo {} (detail 0) vs {} (detail 100)", over(&lo), over(&hi)); +} + +#[test] +fn masking_leaves_flat_areas_alone() { + // gentle noise on a flat field: unmasked sharpening changes it, a high Masking does not + let src = Rgb32f::from_fn(128, 96, |x, y| { + let n = ((x * 7 + y * 13) % 5) as f32 * 0.0015; + [0.18 + n; 3] + }); + let plain = render_at(&src, &sharp(0.0, 1.0, 25.0, 0.0), 128); + let open = render_at(&src, &sharp(100.0, 1.0, 25.0, 0.0), 128); + let masked = render_at(&src, &sharp(100.0, 1.0, 25.0, 100.0), 128); + assert!(rms(&open, &plain) > rms(&masked, &plain), "{} vs {}", rms(&open, &plain), rms(&masked, &plain)); +} + +#[test] +fn sharpening_is_the_same_at_every_size() { + // the same photo previewed at half size and rendered in full: what sharpening adds (sharpened + // minus plain) agrees between the preview and the shrunk full render far better than with zero + let src = scene(512); + let shrink = |img: &Rgba8| -> Vec { + (0..256 * 192) + .map(|i| { + let (x, y) = (i % 256 * 2, i / 256 * 2); + let p = |dx: usize, dy: usize| img.data[(y + dy) * 512 + x + dx][1] as f64; + (p(0, 0) + p(1, 0) + p(0, 1) + p(1, 1)) / 4.0 + }) + .collect() + }; + let added = |a: &[f64], b: &[f64]| -> Vec { a.iter().zip(b).map(|(p, q)| p - q).collect() }; + let norm = |v: &[f64]| (v.iter().map(|x| x * x).sum::() / v.len() as f64).sqrt(); + let (s, off) = (sharp(60.0, 2.0, 25.0, 0.0), sharp(0.0, 2.0, 25.0, 0.0)); + let full = added(&shrink(&render_at(&src, &s, 512)), &shrink(&render_at(&src, &off, 512))); + let half_on = render_at(&src, &s, 256); + let half_off = render_at(&src, &off, 256); + let half: Vec = half_on.data.iter().zip(&half_off.data).map(|(p, q)| p[1] as f64 - q[1] as f64).collect(); + let miss = norm(&added(&full, &half)); + eprintln!("size invariance: effect {} (half) {} (full, shrunk), disagreement {miss}", norm(&half), norm(&full)); + assert!(norm(&half) > 0.5, "the sharpening shows at half size"); + assert!(miss < 0.6 * norm(&half), "preview and export disagree: {miss} vs an effect of {}", norm(&half)); +} + +#[test] +fn subpixel_blur_response_is_continuous_and_does_not_vanish() { + use crate::local::{sharpen_gain, sharpen_sigma}; + use lightcraft_raster::{ + Plane, + blur::{gauss_taps, gaussian_taps}, + }; + // An alternating grating measures the actual FIR response at Nyquist, away from edges. + let src = Plane::from_fn(256, 16, |x, _| if x % 2 == 0 { 1.0 } else { -1.0 }); + let response = |sigma| { + let blur = gaussian_taps(&src, &gauss_taps(sharpen_sigma(sigma))); + (16..240).map(|x| (src.data[x] - blur.data[x]) * src.data[x]).sum::() / 224.0 * sharpen_gain(sigma) + }; + for sigma in [0.1, 0.25, 0.34, 0.4, 0.4999, 0.5001] { + let desired = -(-0.5 * std::f32::consts::PI.powi(2) * sigma * sigma).exp_m1(); + assert!((response(sigma) - desired).abs() < 0.001, "sigma {sigma}: {} vs {desired}", response(sigma)); + } + assert!((response(0.4999) - response(0.5001)).abs() < 0.001); + assert!(response(0.75) > response(0.5)); + assert_eq!(sharpen_gain(1.0), 1.0); + assert_eq!(sharpen_gain(2.0), 1.0); +} + +#[test] +fn reduced_source_uses_the_original_pixel_radius() { + let original = scene(512); + let told = SourceInfo { native_long: Some(512), ..info() }; + let req = RenderRequest::fit(128, 128); + let s = sharp(100.0, 2.0, 100.0, 0.0); + // Use the pipeline's own resampling for the smaller source level. Mixing resampling + // kernels adds edge ringing unrelated to whether the original pixel radius is retained. + let p = crate::plan(&original, &info(), &s, &req); + let reduced = p.frame.sample(&original, p.w, p.h); + let mut off = s.clone(); + off.detail.sharpen_amount = 0.0; + let from_original = render(&original, &info(), &s, &req).image; + let from_reduced = render(&reduced, &told, &s, &req).image; + let unknown = render(&reduced, &info(), &s, &req).image; + let resampling = rms(&render(&original, &info(), &off, &req).image, &render(&reduced, &told, &off, &req).image); + let (a, b) = (rms(&from_original, &from_reduced), rms(&from_original, &unknown)); + assert!(a < resampling + 0.5, "original-size radius: {a}, resampling {resampling}"); + assert!(b > a + 0.3, "copy-size radius: {b}, original-size radius: {a}"); +} + +#[test] +fn old_processes_keep_their_sharpening_and_cache_switches_are_exact() { + use crate::{StageCache, render_cached}; + use lightcraft_develop::Process; + use std::sync::Arc; + let src = Arc::new(scene(256)); + let req = RenderRequest::fit(256, 256); + let cache = StageCache::default(); + for process in [Process::V1, Process::V2, Process::V3, Process::V2, Process::V3] { + let mut s = sharp(80.0, 0.6, 0.0, 30.0); + s.process = process.version(); + let plain = render(&src, &info(), &s, &req).image; + assert_eq!(render_cached(&src, &info(), &s, &req, &cache).image, plain); + s.detail.sharpen_radius = 3.0; + s.detail.sharpen_detail = 100.0; + let changed = render(&src, &info(), &s, &req).image; + if process < Process::V3 { + assert_eq!(changed, plain, "{process:?}"); + } else { + assert_ne!(changed, plain); + } + for nr in [60.0, 20.0] { + s.detail.nr_luminance = nr; + assert_eq!(render_cached(&src, &info(), &s, &req, &cache).image, render(&src, &info(), &s, &req).image, "{process:?}, NR {nr}"); + } + } +} + +#[test] +fn texture_does_not_depend_on_sharpening_settings() { + let src = scene(256); + let mut a = DevelopSettings::default(); + a.effects.texture = 50.0; + let mut b = a.clone(); + b.detail.sharpen_radius = 3.0; + b.detail.sharpen_detail = 100.0; + assert_eq!(render_at(&src, &a, 256).data, render_at(&src, &b, 256).data); +} + +#[test] +fn local_sharpness_uses_the_sharpen_band() { + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let src = scene(256); + let mut s = DevelopSettings { + masks: vec![Mask { + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.01) }, + }], + adjust: LocalAdjustments { sharpness: 100.0, ..Default::default() }, + ..Default::default() + }], + ..Default::default() + }; + let plain = render_at(&src, &DevelopSettings::default(), 256); + let with = render_at(&src, &s, 256); + assert!(rms(&with, &plain) > 0.05, "{}", rms(&with, &plain)); + s.detail.sharpen_radius = 3.0; + assert_ne!(render_at(&src, &s, 256).data, with.data, "Radius reaches the local sharpness band"); +} + +/// An enlarging export (2× the original) scales Radius 2 and 3 to 4 and 6 px: they stay distinct +/// rather than meeting at a 4 px cap. +#[test] +fn enlarged_export_keeps_radius_steps_apart() { + use crate::Quality; + use crate::local::plane_sigmas; + let sigma = |r: f64| plane_sigmas(&sharp(100.0, r, 0.0, 0.0), 1024.0, 512.0, Quality::Full).sharpen.unwrap(); + assert_eq!((sigma(2.0), sigma(3.0)), (4.0, 6.0)); + let src = scene(128); + let enlarged = SourceInfo { native_long: Some(64), ..info() }; + let at = |r: f64| render(&src, &enlarged, &sharp(100.0, r, 0.0, 0.0), &RenderRequest::fit(128, 128)).image; + let d = rms(&at(2.0), &at(3.0)); + assert!(d > 0.1, "Radius 2 and 3 render alike at 2x: {d}"); +} diff --git a/crates/pipeline/src/tests_tone.rs b/crates/pipeline/src/tests_tone.rs index 87e1b659ac..adfda3fc0d 100644 --- a/crates/pipeline/src/tests_tone.rs +++ b/crates/pipeline/src/tests_tone.rs @@ -152,8 +152,8 @@ fn zero_highlights_and_shadows_render_without_the_base() { controls::set(&mut s, "effects.clarity", 40.0); // no base plane is computed or read (the per-pixel stage sees log luminance): the render is // the one it always was - assert_eq!(local::plane_sigmas(&s, 1200.0, Quality::Full).base, None); - assert!(local::plane_sigmas(&with("light.shadows", 1.0), 1200.0, Quality::Full).base.is_some()); + assert_eq!(local::plane_sigmas(&s, 1200.0, 1200.0, Quality::Full).base, None); + assert!(local::plane_sigmas(&with("light.shadows", 1.0), 1200.0, 1200.0, Quality::Full).base.is_some()); } #[test] @@ -216,7 +216,7 @@ fn v1_keeps_the_base_it_always_had() { assert_eq!(ProcessVersion::legacy().process(), Process::V1); for ppl in [300.0, 1200.0, 5568.0, 1e6] { for q in [Quality::Full, Quality::Draft] { - let sig = local::plane_sigmas(&v1, ppl, q); + let sig = local::plane_sigmas(&v1, ppl, ppl, q); assert!(sig.base.is_some()); assert_eq!(sig.base_detail, 0, "V1 at {ppl} px"); assert_eq!(local::tone_base_detail(&v1, ppl), 0); @@ -229,8 +229,8 @@ fn v1_keeps_the_base_it_always_had() { } // V2 takes the median wherever the image is big enough for one let v2 = with("light.highlights", -100.0); - assert_eq!(local::plane_sigmas(&v2, 5568.0, Quality::Full).base_detail, local::base_detail_radius(5568.0)); - assert_eq!(local::plane_sigmas(&v2, 300.0, Quality::Full).base_detail, 0); + assert_eq!(local::plane_sigmas(&v2, 5568.0, 5568.0, Quality::Full).base_detail, local::base_detail_radius(5568.0)); + assert_eq!(local::plane_sigmas(&v2, 300.0, 300.0, Quality::Full).base_detail, 0); } #[test] @@ -259,7 +259,7 @@ fn the_base_plane_is_cached_per_process() { let warm = render_cached(&src, &info, s, &req, &cache).image; assert!(warm == render(&src, &info, s, &req).image, "process {:?}", s.process); } - let sig = local::plane_sigmas(&s2, W as f64, Quality::Full); + let sig = local::plane_sigmas(&s2, W as f64, W as f64, Quality::Full); let sg = sig.base.unwrap(); assert_ne!(local::base_key(&s1, sg, 0), local::base_key(&s2, sg, 0), "the process is part of the key"); assert_ne!(local::base_key(&s2, sg, 0), local::base_key(&s2, sg, sig.base_detail)); diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index cda511baa8..63bd7dc858 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -244,19 +244,25 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } /// The plane a sharpening view draws ([`Overlay::reads_detail`]), from the render's log luminance -/// `log_l` and its texture blur (`None` when the render didn't need it: blurred here with the same -/// sigma). [`Overlay::SharpenDetail`]: the detail band (log luminance minus its blur, in EV); +/// `log_l` and its process-specific sharpening blur (`None` when the render didn't need it: +/// blurred here with the same kernel and sigma). [`Overlay::SharpenDetail`]: the detail band (log luminance minus its blur, in EV); /// [`Overlay::SharpenMask`]: the sharpening weight under the photo's Masking. Both renderers call /// this with their own planes. -pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, texture: Option<&Plane>, px_per_long: f64) -> Option { +pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, band: Option<&Plane>, px_per_long: f64, native_long: f64) -> Option { if !o.reads_detail() { return None; } let own; - let blur = match texture.filter(|b| (b.width, b.height) == (log_l.width, log_l.height)) { + let pixel = crate::local::pixel_sharpen(s); + let blur = match band.filter(|b| (b.width, b.height) == (log_l.width, log_l.height)) { Some(b) => b, None => { - own = lightcraft_raster::blur::gaussian(log_l, crate::local::texture_sigma(px_per_long)); + own = if pixel { + let sigma = crate::local::sharpen_sigma(crate::local::sharpen_sigma_raw(s, px_per_long, native_long)); + lightcraft_raster::blur::gaussian_taps(log_l, &lightcraft_raster::blur::gauss_taps(sigma)) + } else { + lightcraft_raster::blur::gaussian(log_l, crate::local::texture_sigma(px_per_long)) + }; &own } }; @@ -268,7 +274,13 @@ pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, texture: Op .zip(&blur.data) .map(|(l, b)| { let det = l - b; - if mask { crate::finish::sharpen_mask_weight(masking, det) } else { det } + if !mask { + det + } else if pixel { + crate::finish::pixel_sharpen_mask_weight(masking, det) + } else { + crate::finish::sharpen_mask_weight(masking, det) + } }) .collect(); Some(Plane { width: log_l.width, height: log_l.height, data }) @@ -596,6 +608,48 @@ mod tests { assert_eq!(render(&src, &info, &s, &req(Overlay::None)).image, plain.image); } + #[test] + fn sharpening_preview_follows_the_process_radius_and_render_planes() { + use lightcraft_develop::Process; + use lightcraft_raster::blur::{gaussian, gaussian_taps}; + let l = Plane { width: 80, height: 16, data: (0..1280).map(|i| ((i % 80) as f32 * 0.73).sin() * 0.3).collect() }; + let mut s = DevelopSettings::default(); + let mut bands = Vec::new(); + for process in Process::ALL { + s.process = process.version(); + s.detail.sharpen_radius = 3.0; + s.detail.sharpen_masking = 70.0; + let pixel = crate::local::pixel_sharpen(&s); + let blur = if pixel { + let sigma = crate::local::sharpen_sigma(crate::local::sharpen_sigma_raw(&s, 80.0, 160.0)); + gaussian_taps(&l, &lightcraft_raster::blur::gauss_taps(sigma)) + } else { + gaussian(&l, crate::local::texture_sigma(80.0)) + }; + for o in [Overlay::SharpenDetail, Overlay::SharpenMask] { + let cached = sharpen_plane(o, &s, &l, Some(&blur), 80.0, 160.0).unwrap(); + let fresh = sharpen_plane(o, &s, &l, None, 80.0, 160.0).unwrap(); + assert_eq!(cached.data, fresh.data, "{process:?} {o:?}"); + for ((&actual, &log), &base) in fresh.data.iter().zip(&l.data).zip(&blur.data) { + let det = log - base; + let expected = if o == Overlay::SharpenDetail { + det + } else if pixel { + crate::finish::pixel_sharpen_mask_weight(0.7, det) + } else { + crate::finish::sharpen_mask_weight(0.7, det) + }; + assert_eq!(actual, expected); + } + } + bands.push(sharpen_plane(Overlay::SharpenDetail, &s, &l, None, 80.0, 160.0).unwrap().data); + } + assert_eq!(bands[0], bands[1], "the legacy previews stay unchanged"); + assert_ne!(bands[1], bands[2], "V3 previews use the pixel-scale band"); + s.detail.sharpen_radius = 1.0; + assert_ne!(bands[2], sharpen_plane(Overlay::SharpenDetail, &s, &l, None, 80.0, 160.0).unwrap().data); + } + #[test] fn sharpening_previews_show_the_mask_and_detail() { // a smooth ramp (left) and a hard edge (at x = 60) diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 36e3a64cf2..9fefea9ca2 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -126,8 +126,67 @@ pub fn gaussian(img: &Image, sigma: f32) -> Image { a } +/// Largest sigma [`gauss_taps`] makes taps for (larger ones are clamped to it). The GPU blur reads +/// the same taps, so it is the limit of both paths. +pub const GAUSS_SIGMA_MAX: f32 = 64.0; + +/// Normalized Gaussian taps `[w(-r) .. w(r)]` for `sigma`, `r = ceil(3 sigma)`. +/// +/// An exact kernel, unlike the three-box approximation of [`gaussian`], which cannot represent +/// a sigma under about 0.8 px (its boxes collapse to the identity): sharpening needs those. +pub fn gauss_taps(sigma: f32) -> Vec { + let sigma = if sigma.is_nan() { 0.05 } else { sigma.clamp(0.05, GAUSS_SIGMA_MAX) }; + let r = (3.0 * sigma).ceil() as i32; + let k = -0.5 / (sigma * sigma); + let mut t: Vec = (-r..=r).map(|i| ((i * i) as f32 * k).exp()).collect(); + let sum: f32 = t.iter().sum(); + t.iter_mut().for_each(|v| *v /= sum); + t +} + +/// Separable blur with odd-length symmetric `taps` (clamped edges), see [`gauss_taps`]. +pub fn gaussian_taps(img: &Image, taps: &[f32]) -> Image { + let (w, h) = (img.width, img.height); + if w == 0 || h == 0 || taps.len() < 3 { + return img.clone(); + } + let r = (taps.len() / 2) as isize; + let mut a = Image::::new(w, h); + par_rows(&mut a.data, w, |y, row| { + let src = &img.data[y * w..(y + 1) * w]; + for (x, o) in row.iter_mut().enumerate() { + let mut acc = T::zero(); + for (k, t) in taps.iter().enumerate() { + let xi = (x as isize + k as isize - r).clamp(0, w as isize - 1) as usize; + acc = acc.madd(src[xi], *t); + } + *o = acc; + } + }); + let mut b = Image::::new(w, h); + par_rows(&mut b.data, w, |y, row| { + for (k, t) in taps.iter().enumerate() { + let yi = (y as isize + k as isize - r).clamp(0, h as isize - 1) as usize; + for (o, v) in row.iter_mut().zip(&a.data[yi * w..(yi + 1) * w]) { + *o = o.madd(*v, *t); + } + } + }); + b +} + #[cfg(test)] mod tests { + #[test] + fn fir_gaussian_has_bounded_finite_taps_for_hostile_sigmas() { + for sigma in [f32::NAN, f32::NEG_INFINITY, f32::INFINITY, f32::MAX, -5.0, 0.0, 0.1, 4.0] { + let taps = super::gauss_taps(sigma); + assert!(taps.len() <= 385 && taps.len() % 2 == 1); + assert!(taps.iter().all(|v| v.is_finite() && *v >= 0.0)); + assert!((taps.iter().sum::() - 1.0).abs() < 1e-5); + } + } + /// The box radii of the formula as it was, for the check below. fn box_radii_before(sigma: f32) -> [usize; 3] { let n = 3.0f32; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d693fd8a7e..fea5c08248 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -204,7 +204,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | | LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | | LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | -| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | 🟡 | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking`, `crates/pipeline/src/visualize.rs` | Amount and Masking work; Radius and Detail are stored (XMP round trip) but do not change the render yet (the band is a fixed 0.18 % of the long edge). Alt/Option-drag previews: Amount greyscale, Radius / Detail the detail band sharpening amplifies, Masking the mask (white = sharpened) | +| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking`, `crates/pipeline/src/visualize.rs` | Process V3: pixel-scale sharpening, working Radius and Detail, original-photo scale in previews and exports; V1/V2 preserve the previous rendering (`crates/pipeline/src/tests_sharpen.rs`). Alt/Option-drag previews: Amount greyscale, Radius / Detail the detail band sharpening amplifies, Masking the mask (white = sharpened) | | LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960). Alt/Option-drag on Luminance, Detail and Contrast shows the photo in greyscale | | LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | preview-size independent like luminance NR (#960) | | LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) | diff --git a/docs/process-versions.md b/docs/process-versions.md index 7e6788f11e..318e6d8931 100644 --- a/docs/process-versions.md +++ b/docs/process-versions.md @@ -8,7 +8,8 @@ stronger Light sliders, issue #196) without changing a single photo someone has | Process | What it changes | |---|---| | **V1** | LightCraft's rendering from before process versions existed. Frozen. | -| **V2** (the latest) | Highlights and Shadows keep fine detail (issue #632): their base layer (`local::tone_base`) is the edge-aware filter of a separable median of log luminance (radius 0.15 % of the long edge, at most 24 px, `local::BASE_DETAIL`) instead of log luminance itself, so twigs, lines and texture take their surroundings' shift and keep their contrast; region edges stay sharp. CPU (`local::prepare`) and GPU prepare branch on `local::tone_base_detail` (0 = no median under V1); the base plane is cached per process (`local::BaseKey`). Renders without Highlights or Shadows (global or local) are the same as V1's. Tone map unchanged. | +| **V2** | Highlights and Shadows keep fine detail (issue #632): their base layer (`local::tone_base`) is the edge-aware filter of a separable median of log luminance (radius 0.15 % of the long edge, at most 24 px, `local::BASE_DETAIL`) instead of log luminance itself, so twigs, lines and texture take their surroundings' shift and keep their contrast; region edges stay sharp. CPU (`local::prepare`) and GPU prepare branch on `local::tone_base_detail` (0 = no median under V1); the base plane is cached per process (`local::BaseKey`). Renders without Highlights or Shadows (global or local) are the same as V1's. Tone map unchanged. | +| **V3** (the latest) | Pixel-scale sharpening (issue #757): Radius uses the original photo’s pixels, Detail controls halo damping, and downsized sources retain a response-matched sharpening band. V1 and V2 keep texture-band sharpening exactly. The sharpen plane is keyed by radius and process and invalidated after noise-reduction changes. The Highlights/Shadows base and tone map are unchanged from V2. | ## The rule