diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index d354283b1..c99bde452 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -110,6 +110,13 @@ impl Frame { self.crop.rect = Rect::from_center(r.center(), r.width() * t, r.height() * t); } + /// Long edge, in pixels, of the whole output this frame draws (its own size unless it is a + /// window of a larger one): what size-relative effects (noise reduction, edge refinement) + /// must scale with, so a window matches the same pixels of the whole render. + pub fn output_long(&self, w: usize, h: usize) -> usize { + self.view.map_or(w.max(h), |v| v.full_w.max(v.full_h) as usize) + } + /// This frame narrowed to `win` of its `full_w × full_h` output: the same mapping, but the /// output is only that window (the crop rectangle shrinks to it; flips are as drawn). pub fn window(&self, full_w: usize, full_h: usize, win: crate::PixelWindow) -> Frame { diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 45bc1ff43..dcd454c66 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -446,7 +446,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende // Without a cache the resampled buffer is ours: work on it in place. let mut img = if shared.is_some() { (*sampled).clone() } else { Arc::unwrap_or_clone(sampled.clone()) }; lin_cpu(&mut img, info, &plan); - local::denoise(&mut img, s, src_long, w.max(h)); + local::denoise(&mut img, s, src_long, frame.output_long(w, h)); Arc::new(img) } }; diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 96911e8bb..8d1c76a31 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -60,7 +60,7 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l // Refine Edges: the mask's edges follow the photo's (window up to ~4 % of the long edge, // the width of a soft brush edge or gradient) let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32; - let sigma = (0.04 * w.max(h) as f32 * k).max(1.0); + let sigma = (0.04 * frame.output_long(w, h) as f32 * k).max(1.0); let refined = guided_cross(log_l, &alpha, sigma, 0.02); for (a, r) in alpha.data.iter_mut().zip(&refined.data) { *a += (r - *a) * k.sqrt(); diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 045b09437..1825398e7 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -150,3 +150,51 @@ fn windows_outside_or_degenerate_are_clamped() { assert!(r.width >= 1 && r.height >= 1 && r.width <= W && r.height <= H, "{win:?}: {}×{}", r.width, r.height); } } + +/// A textured, noisy source: what noise reduction and edge refinement act on. +fn noisy_scene() -> Rgb32f { + let base = scene(); + Rgb32f::from_fn(base.width, base.height, |x, y| { + let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841); + v ^= v >> 13; + v = v.wrapping_mul(0x5bd1_e995); + v ^= v >> 15; + let n = 1.0 + 0.25 * ((v & 0xffff) as f32 / 32768.0 - 1.0); + base.data[y * base.width + x].map(|c| c * n) + }) +} + +fn check_noisy(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let src = noisy_scene(); + let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image; + let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) }).image; + let (max, mean) = compare(&f, &w, win); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +// Given noise reduction, its strength follows the whole frame's size, not the window's +#[test] +fn noise_reduction_does_not_depend_on_the_window() { + for (id, v) in [("detail.nrLuminance", 80.0), ("detail.nrColor", 80.0)] { + let mut s = DevelopSettings::default(); + controls::set(&mut s, id, v); + check_noisy(id, &s, MID, 3, 0.15); + } +} + +// Given a mask with Refine Edges, the refinement width follows the whole frame's size +#[test] +fn mask_edge_refinement_does_not_depend_on_the_window() { + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + id: 1, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { exposure: 1.5, ..Default::default() }, + refine: 100.0, + ..Default::default() + }); + check_noisy("refine", &s, MID, 3, 0.2); +}