diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 0474e2a0..fad1a816 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -108,6 +108,7 @@ impl WireJob { space: lightcraft_engine::pipeline::OutputSpace::Srgb, depth: lightcraft_engine::pipeline::OutputDepth::U8, proof: None, + window: None, } } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 5ef3011b..8644a85d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -298,7 +298,8 @@ 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 - if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() { + // (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() { return None; } let gpu = device()?; diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index f352004e..6b422ee9 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -190,6 +190,8 @@ pub struct FinishParams { pub w: usize, pub h: usize, pub px_per_long: f64, + /// The window's offset and the whole output's size (pixels): `(0, 0, w, h)` unless windowed. + pub view: [f32; 4], } impl FinishParams { @@ -255,6 +257,7 @@ impl FinishParams { w, h, px_per_long, + view: frame.view.map_or([0.0, 0.0, w as f32, h as f32], |v| [v.x as f32, v.y as f32, v.full_w as f32, v.full_h as f32]), } } } @@ -344,7 +347,9 @@ pub(crate) fn finish_with( let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect(); let out_to_norm = fp.out_to_norm; let long = fp.ow.max(fp.oh); - let aspect = w as f32 / h as f32; + let [vx, vy, vw, vh] = fp.view; + // the vignette belongs to the whole output frame, not to the window being drawn + let aspect = vw / vh; let srgb = srgb_lut(); let mut out = vec![T::default(); w * h]; @@ -502,8 +507,8 @@ pub(crate) fn finish_with( // --- vignette (display linear, post-crop) if let Some(v) = vig { - let u = (x as f32 + 0.5) / w as f32 * 2.0 - 1.0; - let vv = (y as f32 + 0.5) / h as f32 * 2.0 - 1.0; + let u = (vx + x as f32 + 0.5) / vw * 2.0 - 1.0; + let vv = (vy + y as f32 + 0.5) / vh * 2.0 - 1.0; let sx = 1.0 + (aspect - 1.0) * v.aspect_mix; let sy = 1.0 + (1.0 / aspect - 1.0) * v.aspect_mix; let (ax, ay) = ((u * sx.max(1.0) / sx.max(sy)).abs(), (vv * sy.max(1.0) / sx.max(sy)).abs()); diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index af45526b..d354283b 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -31,6 +31,18 @@ pub struct Frame { pub flip_v: bool, /// Lens corrections / perspective (None = identity). pub warp: Option, + /// Set when this frame draws only a window of a larger output (see [`Frame::window`]). + pub view: Option, +} + +/// Where a windowed [`Frame`]'s pixels sit in the full output: its offset and the full output's +/// size, for effects defined by the whole frame (post-crop vignette). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewWindow { + pub x: f64, + pub y: f64, + pub full_w: f64, + pub full_h: f64, } impl Frame { @@ -62,6 +74,7 @@ impl Frame { flip_h: apply_crop && s.crop.flip_h, flip_v: apply_crop && s.crop.flip_v, warp: (!warp.is_identity()).then_some(warp), + view: None, }; if apply_crop && s.geometry.constrain_crop { f.constrain_to_image(); @@ -97,6 +110,23 @@ impl Frame { self.crop.rect = Rect::from_center(r.center(), r.width() * t, r.height() * t); } + /// 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 { + let (fw, fh) = (full_w.max(1) as f64, full_h.max(1) as f64); + let (x0, x1) = (win.x as f64 / fw, (win.x + win.w) as f64 / fw); + let (y0, y1) = (win.y as f64 / fh, (win.y + win.h) as f64 / fh); + // flips happen on the output before it maps onto the crop: undo them for the range + let (x0, x1) = if self.flip_h { (1.0 - x1, 1.0 - x0) } else { (x0, x1) }; + let (y0, y1) = if self.flip_v { (1.0 - y1, 1.0 - y0) } else { (y0, y1) }; + let r = self.crop.rect; + let (rw, rh) = (r.width(), r.height()); + let mut f = self.clone(); + f.crop.rect = Rect { x0: r.x0 + x0 * rw, y0: r.y0 + y0 * rh, x1: r.x0 + x1 * rw, y1: r.y0 + y1 * rh }; + f.view = Some(ViewWindow { x: win.x as f64, y: win.y as f64, full_w: fw, full_h: fh }); + f + } + /// Add automatically estimated lateral CA (`[α_R, α_B]`, see [`crate::optics::estimate_lateral_ca`]). pub fn add_lateral_ca(&mut self, ca: [f64; 2]) { if ca == [0.0; 2] { diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 3feaa153..45bc1ff4 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -99,6 +99,31 @@ pub struct RenderRequest { pub depth: OutputDepth, /// Soft proofing (CPU only; see [`Proof`]). pub proof: Option, + /// Render only this window of the output that `max_w × max_h` describes (a zoomed view): + /// the result is that window's pixels, as in the whole render. See [`PixelWindow`]. + pub window: Option, +} + +/// A window of the (virtual) full output, in its pixels: what a zoomed view needs, rendered +/// without materializing the whole frame. The output frame is the one `max_w × max_h` fits, after +/// crop, straighten and flips, as drawn. Out-of-range windows are clamped into it. Spatial stages +/// (clarity, dehaze, sharpening…) read their neighbours, so ask for a margin around what is kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PixelWindow { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + +impl PixelWindow { + /// This window inside a `full_w × full_h` frame: at least one pixel, wholly inside. + pub fn clamped(self, full_w: usize, full_h: usize) -> PixelWindow { + let (fw, fh) = (full_w.max(1), full_h.max(1)); + let x = self.x.min(fw - 1); + let y = self.y.min(fh - 1); + PixelWindow { x, y, w: self.w.clamp(1, fw - x), h: self.h.clamp(1, fh - y) } + } } impl RenderRequest { @@ -112,6 +137,7 @@ impl RenderRequest { space: OutputSpace::Srgb, depth: OutputDepth::U8, proof: None, + window: None, } } } @@ -300,8 +326,13 @@ pub struct Plan<'a> { pub lin_key: u64, /// Red eye / pet eye corrections with their detected pupils, in output pixels. pub eyes: Vec, + /// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame). + pub fixed_air: Option, } +/// Long edge of the small render a windowed render estimates the whole frame's airlight from. +const AIRLIGHT_PROXY_EDGE: usize = 384; + /// Resolve `s` against `src` for `req` (see [`Plan`]). pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &RenderRequest) -> Plan<'a> { let mut settings: Cow<'a, DevelopSettings> = match profiles::effective(s) { @@ -311,8 +342,22 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R visualize::adjust_settings(req.overlay, &mut settings); let s = &*settings; let frame = frame_for(src, info, s, req.apply_crop); - let (w, h) = frame.fit(req.max_w, req.max_h); - let px_per_long = frame.px_per_long(w); + 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 (frame, w, h) = match req.window { + Some(win) => { + let win = win.clamped(full_w, full_h); + if s.section_enabled("effects") && (s.effects.dehaze != 0.0 || s.masks.iter().any(|m| m.adjust.dehaze != 0.0)) { + let k = (AIRLIGHT_PROXY_EDGE as f64 / full_w.max(full_h) as f64).min(1.0); + 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) + } + None => (frame, full_w, full_h), + }; let src_long = src.width.max(src.height); let geo = hash_of((format!("{frame:?}"), w, h)); let (wb_t, wb_tint) = local::effective_wb(info, s); @@ -328,7 +373,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 } + Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -410,7 +455,10 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende Some(p) if p.key == lin_key => p, _ => local::Planes { key: lin_key, ..Default::default() }, }; - let prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes); + let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes); + if let Some(a) = plan.fixed_air { + prep.air = a; + } lap("prepare", &mut t); if let Some((a, c)) = shared { c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes }); @@ -510,3 +558,5 @@ mod tests; mod tests_geometry; #[cfg(test)] mod tests_local; +#[cfg(test)] +mod tests_window; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index cedb7cd1..61ec8482 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -328,6 +328,15 @@ pub(crate) fn prepare(img: Arc, s: &DevelopSettings, frame: &Frame, px_p Prepared { img, log_l, base, clarity_blur, texture_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): +/// a windowed render must dehaze with the frame's airlight, not that of the pixels it holds. +pub(crate) fn frame_airlight(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, frame: &Frame, proxy_w: usize, proxy_h: usize) -> f32 { + let mut img = frame.sample(src, proxy_w, proxy_h); + white_balance(&mut img, info, s); + let sigma = (0.02 * frame.px_per_long(proxy_w)).max(1.0) as f32; + airlight(&gaussian(&img.map(dark_of), sigma)) +} + /// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel. pub const AIRLIGHT_STEP: usize = 7; diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs new file mode 100644 index 00000000..045b0943 --- /dev/null +++ b/crates/pipeline/src/tests_window.rs @@ -0,0 +1,152 @@ +//! Region rendering (issue #323): a window of the virtual full-size output is rendered on its own +//! and must match the same pixels of the full render, so zoomed views can be sharp at any zoom +//! without materializing the whole frame. + +use lightcraft_develop::{DevelopSettings, controls}; +use lightcraft_geom::{Orientation, Rect}; +use lightcraft_raster::{Rgb32f, Rgba8}; + +use crate::{PixelWindow, RenderRequest, SourceInfo, render}; + +const W: usize = 960; +const H: usize = 640; +/// Context rendered around the pixels a caller keeps (spatial filters read their neighbours). +const HALO: usize = 64; + +fn scene() -> Rgb32f { + lightcraft_scenes::demo_library()[0].render(480, 320) +} + +fn full(s: &DevelopSettings) -> Rgba8 { + render(&scene(), &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image +} + +fn window(s: &DevelopSettings, win: PixelWindow) -> Rgba8 { + let req = RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) }; + render(&scene(), &SourceInfo::default(), s, &req).image +} + +/// 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) { + let (mut max, mut sum, mut n) = (0u8, 0f64, 0f64); + for y in HALO..at.h - HALO { + for x in HALO..at.w - HALO { + 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]); + max = max.max(d); + sum += d as f64; + n += 1.0; + } + } + } + (max, sum / n) +} + +fn check(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let (f, w) = (full(s), window(s, win)); + assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size"); + let (max, mean) = compare(&f, &w, win); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +const MID: PixelWindow = PixelWindow { x: 300, y: 180, w: 400, h: 300 }; + +// Given the window is the whole output, then it is the full render, byte for byte +#[test] +fn the_whole_frame_as_a_window_is_the_full_render() { + let s = DevelopSettings::default(); + let f = full(&s); + let w = window(&s, PixelWindow { x: 0, y: 0, w: f.width, h: f.height }); + assert_eq!(f.data, w.data); +} + +// Given default settings, a window in the middle matches the full render +#[test] +fn a_window_matches_the_full_render() { + check("default", &DevelopSettings::default(), MID, 1, 0.1); +} + +// Given a window at each corner, it matches too (edges of the frame are not special) +#[test] +fn windows_at_the_corners_match() { + let s = DevelopSettings::default(); + for (x, y) in [(0, 0), (W - 400, 0), (0, H - 300), (W - 400, H - 300)] { + check("corner", &s, PixelWindow { x, y, w: 400, h: 300 }, 1, 0.1); + } +} + +// Given crop, straighten and flip, the window still lands on the same pixels +#[test] +fn a_cropped_straightened_flipped_view_matches() { + let mut s = DevelopSettings::default(); + s.crop.geometry.rect = Rect { x0: 0.1, y0: 0.15, x1: 0.85, y1: 0.9 }; + s.crop.geometry.angle = 4.0; + s.crop.flip_h = true; + check("crop+angle+flipH", &s, MID, 3, 0.3); + s.crop.flip_h = false; + s.crop.flip_v = true; + check("crop+angle+flipV", &s, MID, 3, 0.3); +} + +// Given a rotated photo, the same +#[test] +fn rotated_and_mirrored_orientations_match() { + for o in [Orientation::Rotate90, Orientation::Rotate270, Orientation::FlipH, Orientation::Transpose] { + let s = DevelopSettings { orientation: o, ..Default::default() }; + let f = full(&s); + let win = PixelWindow { x: f.width / 4, y: f.height / 4, w: f.width / 2, h: f.height / 2 }; + check(&format!("{o:?}"), &s, win, 3, 0.3); + } +} + +// Given a post-crop vignette, the window shows the vignette of the whole frame, not its own +#[test] +fn the_vignette_belongs_to_the_frame_not_the_window() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "vignette.amount", -90.0); + controls::set(&mut s, "vignette.midpoint", 10.0); + // the vignette is really there: the corner is much darker than without it + let (with, without) = (full(&s), full(&DevelopSettings::default())); + let corner = |i: &Rgba8| i.get(W - 20, H - 20)[1] as i32; + assert!(corner(&without) - corner(&with) > 30, "{} vs {}", corner(&without), corner(&with)); + // …and a window over that corner shows it, at the same place + check("vignette", &s, PixelWindow { x: W - 400, y: H - 300, w: 400, h: 300 }, 2, 0.2); + check("vignette middle", &s, MID, 2, 0.2); +} + +// Given spatial adjustments, the window agrees away from its halo +#[test] +fn spatial_adjustments_agree_inside_the_halo() { + for (id, v) in + [("effects.clarity", 70.0), ("effects.dehaze", 40.0), ("detail.sharpenAmount", 80.0), ("light.shadows", 60.0), ("effects.texture", 60.0)] + { + let mut s = DevelopSettings::default(); + controls::set(&mut s, id, v); + check(id, &s, MID, 6, 0.8); + } +} + +// Given a huge virtual frame (48000 px: an 800 % zoom), only the window is rendered +#[test] +fn a_window_of_a_huge_frame_is_cheap() { + let req = RenderRequest { window: Some(PixelWindow { x: 20_000, y: 12_000, w: 256, h: 192 }), ..RenderRequest::fit(48_000, 32_000) }; + let r = render(&scene(), &SourceInfo::default(), &DevelopSettings::default(), &req); + assert_eq!((r.image.width, r.image.height), (256, 192)); +} + +// Hostile windows (from UI state) are clamped into the frame, never a panic or a huge buffer +#[test] +fn windows_outside_or_degenerate_are_clamped() { + let s = DevelopSettings::default(); + for win in [ + PixelWindow { x: W + 500, y: 5, w: 100, h: 100 }, + PixelWindow { x: 0, y: 0, w: 0, h: 0 }, + PixelWindow { x: usize::MAX, y: usize::MAX, w: usize::MAX, h: usize::MAX }, + PixelWindow { x: 900, y: 600, w: 5000, h: 5000 }, + ] { + let r = window(&s, win); + assert!(r.width >= 1 && r.height >= 1 && r.width <= W && r.height <= H, "{win:?}: {}×{}", r.width, r.height); + } +}