diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 8644a85df..5ef3011b7 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -298,8 +298,7 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R let effective = s.effective(); let s: &DevelopSettings = &effective; // the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU - // (a windowed render runs on the CPU until the kernels know the window: vignette and airlight) - if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() || req.window.is_some() { + if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() { return None; } let gpu = device()?; diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 112e89090..7f9405492 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -57,6 +57,8 @@ const FIELDS: &[(&str, usize)] = &[ ("VIG_POWER", 1), ("VIG_HL", 1), ("VIG_STYLE", 1), + // the window's offset in the whole output and the whole output's size (pixels) + ("VIG_VIEW", 4), ("GRAIN", 1), ("GRAIN_AMT", 1), ("GRAIN_SC", 1), @@ -211,6 +213,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.f("VIG_ASPECT_MIX", v.aspect_mix); p.f("VIG_POWER", v.power); p.f("VIG_HL", v.highlights); + p.fs("VIG_VIEW", &fp.view); p.u( "VIG_STYLE", match v.style { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index bb30bc149..c38418616 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -579,7 +579,11 @@ pub fn render( Some(p) if p.key == plan.lin_key => p, _ => Planes { key: plan.lin_key, ..Default::default() }, }; - let prep = prepare(&mut cx, &lin, &plan, req, &mut planes); + let mut prep = prepare(&mut cx, &lin, &plan, req, &mut planes); + // a window dehazes with the whole frame's airlight + if let Some(a) = plan.fixed_air { + prep.air = a; + } lap("planes", &mut t, &mut cx); if let Some(c) = stages { c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes }); @@ -650,7 +654,10 @@ pub fn render( fail(FailKind::Fatal, format!("the GPU returned an incomplete image ({unwritten} of {n} pixels unwritten)")); return None; } - let histogram = Histogram::of_srgb8(&image); + let histogram = match plan.keep { + Some(k) => Histogram::of_srgb8(&image.crop(k.x, k.y, k.w, k.h)), + None => Histogram::of_srgb8(&image), + }; lap("histogram", &mut t, &mut cx); // An entirely black result (upstream work that did not run would give that too): redo it on // the CPU, which costs time only for the rare photo that really is black. @@ -669,6 +676,10 @@ pub fn render( } }); lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); + // spots grew the window the render worked on: cut it back to the request + if let Some(k) = plan.keep { + image = image.crop(k.x, k.y, k.w, k.h); + } Some(Rendered { image, histogram, deep: None }) } @@ -713,7 +724,7 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho fn denoise(cx: &mut Cx<'_>, img: Buf, plan: &Plan<'_>) -> Buf { let (w, h) = (plan.w, plan.h); let n = w * h; - let (lum, col) = local::nr_params(&plan.settings, plan.src_long, w.max(h)); + let (lum, col) = local::nr_params(&plan.settings, plan.src_long, plan.frame.output_long(w, h)); let mut img = img; if let Some(nr) = lum { let l = cx.gpu.buffer(n); diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index edd7114c1..d51308ee0 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -432,14 +432,15 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // --- vignette (display linear, post-crop) if (pu(F_VIG) != 0u) { - let fw = f32(w); - let fh = f32(h); + // the vignette belongs to the whole output frame, not to the window being drawn + let fw = pf(F_VIG_VIEW + 2u); + let fh = pf(F_VIG_VIEW + 3u); let aspect = fw / fh; let amount = pf(F_VIG_AMOUNT); let mixa = pf(F_VIG_ASPECT_MIX); let power = pf(F_VIG_POWER); - let u = (f32(x) + 0.5) / fw * 2.0 - 1.0; - let vv = (f32(y) + 0.5) / fh * 2.0 - 1.0; + let u = (pf(F_VIG_VIEW) + f32(x) + 0.5) / fw * 2.0 - 1.0; + let vv = (pf(F_VIG_VIEW + 1u) + f32(y) + 0.5) / fh * 2.0 - 1.0; let sx = 1.0 + (aspect - 1.0) * mixa; let sy = 1.0 + (1.0 / aspect - 1.0) * mixa; let ax = abs(u * max(sx, 1.0) / max(sx, sy)); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 00cac8bca..b4b52e215 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -660,3 +660,44 @@ fn tint_directions_match_cpu_for_raw_and_rendered_sources() { } } } + +/// Issue #323: windows of a zoomed frame. The GPU renders the window like the CPU does: the +/// vignette and airlight belong to the whole frame, spots grow the window they work on. +#[test] +fn windows_match() { + if !gpu() { + return; + } + use lightcraft_pipeline::PixelWindow; + let src = scene(1, 960, 640); + let info = SourceInfo { raw: true, ..Default::default() }; + let win = PixelWindow { x: 1200, y: 800, w: 640, h: 480 }; + let req = RenderRequest { window: Some(win), ..RenderRequest::fit(3000, 2000) }; + let mut cases = cases(); + cases.push(("vignette at the corner", |s| { + s.vignette.amount = -80.0; + s.vignette.midpoint = 10.0; + })); + cases.push(("spot reading from outside", |s| { + s.spots.push(Spot { + points: vec![Point::new(0.45, 0.45)], + size: 0.03, + feather: 30.0, + opacity: 100.0, + source_offset: Some(Point::new(0.15, 0.0)), + ..Default::default() + }); + })); + for (name, edit) in cases { + let mut s = DevelopSettings::default(); + edit(&mut s); + let name = format!("window: {name}"); + check(&name, &src, &info, &s, &req); + } + // the corner window (where the vignette is strong) + let mut s = DevelopSettings::default(); + s.vignette.amount = -80.0; + s.vignette.midpoint = 10.0; + let corner = RenderRequest { window: Some(PixelWindow { x: 2360, y: 1520, w: 640, h: 480 }), ..RenderRequest::fit(3000, 2000) }; + check("window: vignette corner", &src, &info, &s, &corner); +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 1c869a5af..057fb42fa 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -329,7 +329,7 @@ pub struct Plan<'a> { /// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame). pub fixed_air: Option, /// A windowed render works on a larger window when spots reach into it (see - /// [`spots::window_for_spots`]); this is the requested window inside the rendered one, which + /// [`spots::window_for_reads`]); this is the requested window inside the rendered one, which /// the result is cut to. pub keep: Option, } @@ -369,7 +369,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph)); } // (spots grow the rendered window; only 8-bit renders are cut back to the request) - let work = if req.depth == OutputDepth::U8 { spots::window_for_spots(&s.spots, &frame, full_w, full_h, px_per_long, win) } else { win }; + let work = if req.depth == OutputDepth::U8 { spots::window_for_reads(s, &frame, full_w, full_h, px_per_long, win) } else { win }; if work != win { keep = Some(PixelWindow { x: win.x - work.x, y: win.y - work.y, ..win }); } diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 8d1c76a31..aae261c5a 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -336,11 +336,13 @@ pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 { 1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt()) } -/// The output pixel a dab at `d` samples its reference colour from. +/// The output pixel a dab at `d` samples its reference colour from. The position is taken at f32 +/// precision (like the GPU kernel): a dab on a pixel boundary must not fall on either side of it +/// by an f64 rounding error, which depends on where the window of a zoomed view starts. #[inline] pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize { - let x = (d.x.floor().max(0.0) as usize).min(w - 1); - let y = (d.y.floor().max(0.0) as usize).min(h - 1); + let x = ((d.x as f32).floor().max(0.0) as usize).min(w - 1); + let y = ((d.y as f32).floor().max(0.0) as usize).min(h - 1); y * w + x } diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index b81a91022..4c90dec5a 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -109,19 +109,38 @@ pub fn pick_source( /// The most a window grows to take in the spots that touch it. const MAX_SPOT_SPAN: usize = 8192; -/// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot that -/// reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it +/// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot (or +/// Auto Mask stroke) that reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it /// is given, so a spot cut by the window's edge, or whose source lies outside it, would come out /// different from the same spot in the whole render. Spots with an automatic source (`None`) /// search up to 4.2 radii around themselves. Growth stops at [`MAX_SPOT_SPAN`] pixels. -pub fn window_for_spots(spots: &[Spot], frame: &Frame, full_w: usize, full_h: usize, ppl: f64, win: crate::PixelWindow) -> crate::PixelWindow { - if spots.is_empty() { +pub fn window_for_reads( + s: &lightcraft_develop::DevelopSettings, + frame: &Frame, + full_w: usize, + full_h: usize, + ppl: f64, + win: crate::PixelWindow, +) -> crate::PixelWindow { + let spots = &s.spots; + let autos: Vec<&lightcraft_develop::BrushStroke> = s + .masks + .iter() + .flat_map(|m| &m.components) + .filter_map(|c| match &c.shape { + lightcraft_develop::MaskShape::Brush { strokes } => Some(strokes), + _ => None, + }) + .flatten() + .filter(|st| st.auto_mask) + .collect(); + if spots.is_empty() && autos.is_empty() { return win; } let to_out = frame.norm_to_out(full_w, full_h); let origin = to_out.apply(Point::new(0.0, 0.0)); // (target box, source box) of each spot, in output pixels: [x0, y0, x1, y1] - let boxes: Vec<([f64; 4], [f64; 4])> = spots + let mut boxes: Vec<([f64; 4], [f64; 4])> = spots .iter() .filter_map(|spot| { let r = (spot.size * ppl).max(1.0); @@ -144,6 +163,21 @@ pub fn window_for_spots(spots: &[Spot], frame: &Frame, full_w: usize, full_h: us Some((t, s)).filter(|(t, s)| t.iter().chain(s).all(|v| v.is_finite())) }) .collect(); + // Auto Mask strokes weigh each dab by its similarity to the pixel under the dab's centre, and + // refine the stroke over a few radii: a stroke reaching into the window needs all of that + for st in autos { + let r = (st.size * ppl).max(0.5); + let pts: Vec<_> = st.points.iter().map(|p| to_out.apply(*p)).collect(); + let Some(first) = pts.first() else { continue }; + let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.x, first.y); + for p in &pts { + (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); + } + let b = [x0 - 2.0 * r, y0 - 2.0 * r, x1 + 2.0 * r, y1 + 2.0 * r]; + if b.iter().all(|v| v.is_finite()) { + boxes.push((b, b)); + } + } let (fw, fh) = (full_w as f64, full_h as f64); let mut r = [win.x as f64, win.y as f64, (win.x + win.w) as f64, (win.y + win.h) as f64]; let touches = |r: &[f64; 4], b: &[f64; 4]| b[0] < r[2] && b[2] > r[0] && b[1] < r[3] && b[3] > r[1]; @@ -310,6 +344,10 @@ mod tests { assert!(img.get(5, 50)[0] < 0.2); } + fn with_spots(spots: Vec) -> DevelopSettings { + DevelopSettings { spots, ..Default::default() } + } + fn spot_at(x: f64, y: f64, size: f64, offset: Option) -> Spot { Spot { points: vec![Point::new(x, y)], size, source_offset: offset, ..Default::default() } } @@ -321,17 +359,17 @@ mod tests { let ppl = frame.px_per_long(4000); let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; // none: the window is left alone - assert_eq!(window_for_spots(&[], &frame, 4000, 3000, ppl, win), win); + assert_eq!(window_for_reads(&with_spots(vec![]), &frame, 4000, 3000, ppl, win), win); // a spot far away is no business of this window let far = spot_at(0.9, 0.9, 0.01, Some(Point::new(0.02, 0.0))); - assert_eq!(window_for_spots(&[far], &frame, 4000, 3000, ppl, win), win); + assert_eq!(window_for_reads(&with_spots(vec![far]), &frame, 4000, 3000, ppl, win), win); // a spot inside it, reading from 300 px to the right, widens it to the source let near = spot_at(0.30, 0.38, 0.01, Some(Point::new(0.075, 0.0))); - let g = window_for_spots(&[near], &frame, 4000, 3000, ppl, win); + let g = window_for_reads(&with_spots(vec![near]), &frame, 4000, 3000, ppl, win); assert!(g.x <= win.x && g.x + g.w >= 1200 + 300 + 80 && g.y <= win.y && g.y + g.h >= win.y + win.h, "{g:?}"); // a spot that only reaches in with its edge counts too let edge = spot_at(0.24, 0.38, 0.01, Some(Point::new(-0.05, 0.0))); - assert!(window_for_spots(&[edge], &frame, 4000, 3000, ppl, win).x < win.x); + assert!(window_for_reads(&with_spots(vec![edge]), &frame, 4000, 3000, ppl, win).x < win.x); } #[test] @@ -342,14 +380,35 @@ mod tests { let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; // a spot whose source is a long way off would need an enormous window: not granted let wide = spot_at(0.03, 0.04, 0.01, Some(Point::new(0.6, 0.0))); - let g = window_for_spots(&[wide], &frame, 40_000, 30_000, ppl, win); + let g = window_for_reads(&with_spots(vec![wide]), &frame, 40_000, 30_000, ppl, win); assert!(g.w <= MAX_SPOT_SPAN && g.h <= MAX_SPOT_SPAN, "{g:?}"); for bad in [f64::NAN, f64::INFINITY, 1e300, -1e300] { let s = spot_at(bad, bad, bad, Some(Point::new(bad, bad))); - let g = window_for_spots(&[s], &frame, 40_000, 30_000, ppl, win); + let g = window_for_reads(&with_spots(vec![s]), &frame, 40_000, 30_000, ppl, win); assert_eq!(g, win); } let empty = Spot { points: vec![], ..Default::default() }; - assert_eq!(window_for_spots(&[empty], &frame, 40_000, 30_000, ppl, win), win); + assert_eq!(window_for_reads(&with_spots(vec![empty]), &frame, 40_000, 30_000, ppl, win), win); + } + + // Given an Auto Mask stroke reaching into the window, the window grows to hold all of it + #[test] + fn a_window_grows_to_hold_an_auto_mask_stroke() { + use crate::PixelWindow; + use lightcraft_develop::{BrushStroke, Mask, MaskComponent, MaskOp, MaskShape}; + let frame = Frame::new(4000, 3000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(4000); + let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; + let stroke = + |auto| BrushStroke { points: vec![Point::new(0.30, 0.38), Point::new(0.5, 0.38)], size: 0.01, auto_mask: auto, ..Default::default() }; + let mask = |auto| Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke(auto)] } }], + ..Default::default() + }; + let plain = DevelopSettings { masks: vec![mask(false)], ..Default::default() }; + assert_eq!(window_for_reads(&plain, &frame, 4000, 3000, ppl, win), win, "a plain stroke reads nothing around it"); + let auto = DevelopSettings { masks: vec![mask(true)], ..Default::default() }; + let g = window_for_reads(&auto, &frame, 4000, 3000, ppl, win); + assert!(g.x + g.w >= 2000 && g.x <= 1200, "{g:?}"); } } diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 45f3baca0..0ed5e81e1 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -315,3 +315,29 @@ fn an_automatic_spot_source_does_not_depend_on_the_window() { } assert!(n > 0 && max <= 2, "overlapping windows disagree by up to {max}"); } + +// Given Auto Mask strokes that run in and out of the window, the window shows the whole render's mask +#[test] +fn auto_mask_strokes_match_the_whole_render() { + use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke { + points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(), + size: 0.06, + feather: 40.0, + flow: 80.0, + auto_mask, + erase, + ..Default::default() + }; + let strokes = vec![st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false), st(&[(0.5, 0.58)], true, true)]; + let s = DevelopSettings { + masks: vec![Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }], + adjust: LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() }, + ..Default::default() + }], + ..Default::default() + }; + check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1); +}