diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index dcd454c6..1c869a5a 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -328,6 +328,10 @@ pub struct Plan<'a> { pub eyes: Vec, /// 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 + /// the result is cut to. + pub keep: Option, } /// Long edge of the small render a windowed render estimates the whole frame's airlight from. @@ -340,12 +344,22 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R Cow::Owned(o) => Cow::Owned(upright::resolve(src, info, &o).into_owned()), }; visualize::adjust_settings(req.overlay, &mut settings); + // a window must not choose a spot's source from the pixels it happens to hold: fix them + // from a fixed-size render of the whole frame first + if req.window.is_some() && settings.spots.iter().any(|sp| sp.source_offset.is_none()) { + let picked: Vec> = + settings.spots.iter().map(|sp| sp.source_offset.or_else(|| spots::pick_source(src, info, &settings, sp, None))).collect(); + for (sp, o) in settings.to_mut().spots.iter_mut().zip(picked) { + sp.source_offset = o; + } + } let s = &*settings; let frame = frame_for(src, info, s, req.apply_crop); let (full_w, full_h) = frame.fit(req.max_w, req.max_h); // sizes and scale are those of the whole output; a window only narrows what is drawn let px_per_long = frame.px_per_long(full_w); let mut fixed_air = None; + let mut keep = None; let (frame, w, h) = match req.window { Some(win) => { let win = win.clamped(full_w, full_h); @@ -354,7 +368,12 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R let (pw, ph) = (((full_w as f64 * k).round() as usize).max(1), ((full_h as f64 * k).round() as usize).max(1)); fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph)); } - (frame.window(full_w, full_h, win), win.w, win.h) + // (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 }; + if work != win { + keep = Some(PixelWindow { x: win.x - work.x, y: win.y - work.y, ..win }); + } + (frame.window(full_w, full_h, work), work.w, work.h) } None => (frame, full_w, full_h), }; @@ -373,7 +392,7 @@ 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), src_long, )); - Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air } + Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air, keep } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -472,11 +491,19 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende } let image = finish::finish(&prep, s, frame, info, req.space, req.proof); lap("finish", &mut t); - let histogram = Histogram::of_srgb8(&image); + let cut = |i: &Rgba8| match plan.keep { + Some(k) => i.crop(k.x, k.y, k.w, k.h), + None => i.clone(), + }; + let histogram = match plan.keep { + Some(_) => Histogram::of_srgb8(&cut(&image)), + None => Histogram::of_srgb8(&image), + }; lap("histogram", &mut t); let mut image = image; let mask = overlay_alpha(req.overlay, &plan, &prep); visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); + let image = if plan.keep.is_some() { cut(&image) } else { image }; Rendered { image, histogram, deep: None } } diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index f9edff72..b81a9102 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -106,6 +106,67 @@ pub fn pick_source( Some(Point::new(n1.x - n0.x, n1.y - n0.y)) } +/// 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 +/// 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() { + 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 + .iter() + .filter_map(|spot| { + let r = (spot.size * ppl).max(1.0); + let pts: Vec<_> = spot.points.iter().map(|p| to_out.apply(*p)).collect(); + let first = pts.first()?; + 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)); + } + // the box `apply` works in: the stroke plus a radius, plus a radius of padding + let t = [x0 - 2.0 * r, y0 - 2.0 * r, x1 + 2.0 * r, y1 + 2.0 * r]; + let s = match spot.source_offset { + Some(o) => { + let b = to_out.apply(o); + let (dx, dy) = (b.x - origin.x, b.y - origin.y); + [t[0] + dx, t[1] + dy, t[2] + dx, t[3] + dy] + } + None => [t[0] - 4.2 * r, t[1] - 4.2 * r, t[2] + 4.2 * r, t[3] + 4.2 * r], + }; + Some((t, s)).filter(|(t, s)| t.iter().chain(s).all(|v| v.is_finite())) + }) + .collect(); + 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]; + for _ in 0..=boxes.len() { + let mut grown = r; + for (t, s) in &boxes { + if touches(&r, t) { + for b in [t, s] { + grown = [grown[0].min(b[0]), grown[1].min(b[1]), grown[2].max(b[2]), grown[3].max(b[3])]; + } + } + } + let grown = [grown[0].max(0.0), grown[1].max(0.0), grown[2].min(fw), grown[3].min(fh)]; + if grown == r || grown[2] - grown[0] > MAX_SPOT_SPAN as f64 || grown[3] - grown[1] > MAX_SPOT_SPAN as f64 { + break; + } + r = grown; + } + let (x, y) = (r[0].floor() as usize, r[1].floor() as usize); + crate::PixelWindow { x, y, w: (r[2].ceil() as usize).saturating_sub(x).max(1), h: (r[3].ceil() as usize).saturating_sub(y).max(1) } + .clamped(full_w, full_h) +} + /// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit. pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) { if spots.is_empty() { @@ -248,4 +309,47 @@ mod tests { assert!(img.get(25, 50)[0] > 0.8); assert!(img.get(5, 50)[0] < 0.2); } + + 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() } + } + + #[test] + fn a_window_grows_to_hold_the_spots_that_touch_it_and_their_sources() { + use crate::PixelWindow; + 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 }; + // none: the window is left alone + assert_eq!(window_for_spots(&[], &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); + // 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); + 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); + } + + #[test] + fn spot_windows_are_bounded_and_survive_hostile_spots() { + use crate::PixelWindow; + let frame = Frame::new(40_000, 30_000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(40_000); + 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); + 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); + 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); + } } diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 1825398e..45f3baca 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -29,9 +29,14 @@ fn window(s: &DevelopSettings, win: PixelWindow) -> Rgba8 { /// Largest per-channel difference and mean absolute difference between `win` (rendered for /// `at`, kept `HALO` px in from its edges) and the same pixels of `full`. fn compare(full: &Rgba8, win: &Rgba8, at: PixelWindow) -> (u8, f64) { + compare_inset(full, win, at, HALO) +} + +/// [`compare`] keeping `inset` px in from the window's edges. +fn compare_inset(full: &Rgba8, win: &Rgba8, at: PixelWindow, inset: usize) -> (u8, f64) { let (mut max, mut sum, mut n) = (0u8, 0f64, 0f64); - for y in HALO..at.h - HALO { - for x in HALO..at.w - HALO { + for y in inset..at.h - inset { + for x in inset..at.w - inset { let (a, b) = (full.get(at.x + x, at.y + y), win.get(x, y)); for k in 0..3 { let d = a[k].abs_diff(b[k]); @@ -198,3 +203,115 @@ fn mask_edge_refinement_does_not_depend_on_the_window() { }); check_noisy("refine", &s, MID, 3, 0.2); } + +// ---- Deep zoom: the wide stages' kernels are far wider than any margin ------------------------- + +/// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware +/// filters and the dark channel have something to be wrong about. +fn blocks() -> Rgb32f { + Rgb32f::from_fn(600, 400, |x, y| { + let cell = |size: usize, k: usize| ((x / size * 7 + y / size * 13 + k) % 5) as f32 / 4.0; + let l = 0.02 + 0.5 * cell(97, 1) + 0.25 * cell(31, 2) + 0.1 * cell(11, 3); + [l * 1.1, l, l * 0.8] + }) +} + +const BIG_W: usize = 7680; +const BIG_H: usize = 5120; + +/// A window of a 7680 × 5120 frame against the same pixels of the whole render. The wide stages +/// (dehaze, clarity, highlights/shadows base) have XX/// can't contain them, so they come from the whole frame at reduced size. +fn check_big(name: &str, s: &DevelopSettings, max_ok: u8, mean_ok: f64) { + let src = blocks(); + let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(BIG_W, BIG_H)).image; + let win = PixelWindow { x: 3000, y: 1800, w: 512, h: 384 }; + let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(BIG_W, BIG_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})"); +} + +#[test] +fn dehaze_at_depth_matches_the_whole_render() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "effects.dehaze", 60.0); + check_big("dehaze", &s, 6, 0.6); +} + +#[test] +fn clarity_at_depth_matches_the_whole_render() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "effects.clarity", 80.0); + check_big("clarity", &s, 6, 0.6); +} + +#[test] +fn highlights_and_shadows_at_depth_match_the_whole_render() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "light.shadows", 80.0); + controls::set(&mut s, "light.highlights", -80.0); + check_big("shadows+highlights", &s, 6, 0.6); +} + +// ---- Spot removal reads outside the spot ------------------------------------------------------- + +/// Like [`check`], over every pixel of the window (a spot at its edge must be right too). +fn check_whole(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let (f, w) = (full(s), window(s, win)); + let (max, mean) = compare_inset(&f, &w, win, 0); + assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size"); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +fn with_spot(mode: lightcraft_develop::SpotMode, at: (f64, f64), offset: Option<(f64, f64)>) -> DevelopSettings { + use lightcraft_develop::Spot; + use lightcraft_geom::Point; + let mut s = DevelopSettings::default(); + s.spots.push(Spot { + mode, + points: vec![Point::new(at.0, at.1)], + size: 0.04, + feather: 30.0, + opacity: 100.0, + source_offset: offset.map(|(x, y)| Point::new(x, y)), + }); + s +} + +// Given a spot whose source patch lies outside the window, the window still shows it healed +#[test] +fn a_spot_with_its_source_outside_the_window_matches_the_whole_render() { + use lightcraft_develop::SpotMode; + // the target is at x ≈ 0.36 of the frame; its source is 0.12 of the width to the right + for mode in [SpotMode::Clone, SpotMode::Heal] { + let s = with_spot(mode, (0.40, 0.5), Some((0.12, 0.05))); + // a window that holds the target (x ≈ 384) but not the source (x ≈ 499..) + check_whole(&format!("{mode:?}"), &s, PixelWindow { x: 250, y: 200, w: 200, h: 200 }, 1, 0.1); + } +} + +// Given a spot just outside the window whose feathered edge reaches in, the window shows it +#[test] +fn a_spot_overlapping_the_window_edge_matches_the_whole_render() { + let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.30, 0.5), Some((-0.1, 0.0))); + check_whole("edge", &s, PixelWindow { x: 300, y: 200, w: 240, h: 240 }, 1, 0.1); +} + +// Given a spot with an automatic source, two windows over the same spot choose the same source +#[test] +fn an_automatic_spot_source_does_not_depend_on_the_window() { + let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.5, 0.5), None); + // windows so tight that the candidate sources can't all be inside them + let (a, b) = (PixelWindow { x: 430, y: 280, w: 110, h: 90 }, PixelWindow { x: 450, y: 290, w: 110, h: 90 }); + let (ia, ib) = (window(&s, a), window(&s, b)); + let (mut max, mut n) = (0u8, 0); + for y in 0..80 { + for x in 0..90 { + let (p, q) = (ia.get(x + 20, y + 10), ib.get(x, y)); + for k in 0..3 { + max = max.max(p[k].abs_diff(q[k])); + n += 1; + } + } + } + assert!(n > 0 && max <= 2, "overlapping windows disagree by up to {max}"); +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 8a9efc95..ed080e14 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -355,8 +355,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None; let drawn_long = target_rect.width().max(target_rect.height()) * ppp; let settled_edge = app.ui.settings.loupe_edge(drawn_long, native_long); - let region_possible = - plain_view && app.renderer.can_render_windows() && crate::region::windows_allowed(&d) && crate::region::needed(drawn_long, settled_edge); + let region_possible = plain_view && app.renderer.can_render_windows() && crate::region::needed(drawn_long, settled_edge); let look = d.hash64(); let mut region_full = None; if region_possible { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8a56dd61..d2ab9763 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -35,13 +35,6 @@ impl RegionView { } } -/// Whether windows can be rendered for these settings. Spot removal reads its source patch from -/// outside the spot, and an automatic one is chosen from the pixels at hand: neither is the same -/// in a window as in the whole frame, so those photos stay on the whole-frame render. -pub fn windows_allowed(s: &lightcraft_develop::DevelopSettings) -> bool { - s.spots.is_empty() -} - /// Context to render around the visible pixels of a frame `full_long` pixels along: enough for the /// wide stages (base, clarity and dehaze planes have sigmas up to 0.02 of it) up to a cap. pub fn margin_for(full_long: usize) -> usize { @@ -152,16 +145,6 @@ mod tests { assert!(!v.is_current(PhotoId(1), (6000, 4000), 12), "another look (an edit, an undo)"); } - // Given a photo with spot removal, no window render: a spot reads pixels beyond the window - #[test] - fn photos_with_spots_stay_on_the_whole_frame_render() { - use lightcraft_develop::{DevelopSettings, Spot}; - let mut s = DevelopSettings::default(); - assert!(windows_allowed(&s)); - s.spots.push(Spot::default()); - assert!(!windows_allowed(&s)); - } - // Given nothing visible, or hostile numbers, there is no window (never a panic) #[test] fn nothing_visible_or_hostile_input_is_no_window() { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index d02de2ec..4ee9efc0 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -276,4 +276,23 @@ mod in_the_loupe { assert_ne!(first.key, second.key); assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second.key), "the new window arrived"); } + + // Given a photo with spot removal, a zoomed view still gets its sharp window + #[test] + fn a_photo_with_spots_gets_a_window_too() { + let (mut h, _) = detail(); + let id = h.app.session.active().unwrap(); + let mut s = (*h.app.session.develop_of(id).unwrap()).clone(); + s.spots.push(lightcraft_develop::Spot { + points: vec![lightcraft_geom::Point::new(0.5, 0.5)], + size: 0.01, + source_offset: Some(lightcraft_geom::Point::new(0.05, 0.0)), + ..Default::default() + }); + h.app.session.set_develop(id, s, "Spot").unwrap(); + h.app.ui.settings.preview_limit = 1600; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(region_tile(&h).is_some(), "a window render"); + } }