From e7cd00ef56dc2db7ea6535e5aaa0e89a75862c4f Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Tue, 13 Oct 2026 00:29:56 +0200 Subject: [PATCH] M5.6: a zoomed view with Dehaze keeps its airlight proxy (each pan and slider step resampled the whole original) A windowed render dehazes with the whole frame's airlight, estimated on a 384 px proxy of the frame (`local::frame_airlight`). Every window asked for that proxy again, so every pan of a 1:1 view and every slider step while zoomed sampled the whole original down to it on the CPU: ~200 ms of CPU on a 64 MP raw. The proxy (taken before white balance) is now kept for the last 4 (source fingerprint, frame, size); white balance, the dark channel and the percentile still run on it every time. Same pixels: the GPU window of `render_bench` is byte-identical. `optics::fingerprint` indexed the first pixel of an empty image; it now hashes only the dimensions then. render_bench ONLY=window GPU_ONLY=1 on a 64 MP Sony ILCE-7RM6 raw (RTX 4070 Ti SUPER, 4-CPU quota), CPU time: pan 208-212 -> 9-11 ms, exposure change 200-204 -> 4 ms, NR change 211-217 -> 13 ms. Co-Authored-By: Claude --- crates/pipeline/src/geometry.rs | 2 +- crates/pipeline/src/local.rs | 55 ++++++++++++++++++++++++++++++++- crates/pipeline/src/optics.rs | 12 +++++-- docs/whats-new.md | 4 +++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index bea792c19..1c4f20260 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -16,7 +16,7 @@ use crate::optics::{Warp, reorient_lens}; pub const BLANK: [f32; 3] = [0.55, 0.55, 0.55]; /// The geometric frame of a render. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Frame { /// Source (EXIF-oriented) dimensions in pixels. pub src_w: usize, diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index ce3994cd8..a99530777 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -543,12 +543,35 @@ pub(crate) fn prepare( /// 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); + let mut img = Rgb32f::clone(&frame_proxy(src, frame, 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)) } +/// How many airlight proxies [`frame_proxy`] keeps (one per photo and frame being zoomed into; each ≤ 1.8 MB). +const FRAME_PROXIES_KEPT: usize = 4; + +/// `frame.sample(src, w, h)`, kept for the last few (source, frame, size): sampling a 64 MP original down to the +/// airlight proxy reads all of it (~190 ms of CPU), and every window of a zoomed view with Dehaze asked for it again, +/// on each pan and slider tick. The proxy is taken before white balance, so a WB change still hits. +fn frame_proxy(src: &Rgb32f, frame: &Frame, w: usize, h: usize) -> Arc { + type Entry = (u64, Frame, usize, usize, Arc); + static CACHE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let key = crate::optics::fingerprint(src); + let found = |c: &[Entry]| c.iter().find(|e| e.0 == key && e.1 == *frame && (e.2, e.3) == (w, h)).map(|e| e.4.clone()); + if let Some(img) = found(&CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)) { + return img; + } + let img = Arc::new(frame.sample(src, w, h)); + let mut c = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + c.push((key, frame.clone(), w, h, img.clone())); + if c.len() > FRAME_PROXIES_KEPT { + c.remove(0); + } + img +} + /// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel. pub const AIRLIGHT_STEP: usize = 7; @@ -600,6 +623,36 @@ mod tests { assert!((luminance_2020(c) - 0.3).abs() < 0.01); } + // A windowed render's airlight proxy is kept per (source, frame, size): the airlight is the one sampling anew gives, + // and another source or frame of the same size gets its own, never the proxy kept for the last one + #[test] + fn the_kept_airlight_proxy_matches_a_fresh_one_and_misses_on_another_source_or_frame() { + let (info, s) = (SourceInfo::default(), DevelopSettings::default()); + let hazy = Rgb32f::from_fn(300, 200, |x, y| { + let v = 0.05 + 0.6 * x as f32 / 300.0; + [v, v * 0.95, v * (0.8 + 0.2 * y as f32 / 200.0)] + }); + let other = Rgb32f::from_fn(300, 200, |x, y| [0.02 + 0.1 * ((x + y) % 7) as f32 / 7.0; 3]); + let fresh = |src: &Rgb32f, frame: &Frame| { + let mut img = frame.sample(src, 48, 32); + white_balance(&mut img, &info, &s); + airlight(&gaussian(&img.map(dark_of), (0.02 * frame.px_per_long(48)).max(1.0) as f32)) + }; + let frame = crate::frame_for(&hazy, &info, &s, true); + let kept = frame_proxy(&hazy, &frame, 48, 32); + assert!(Arc::ptr_eq(&kept, &frame_proxy(&hazy, &frame, 48, 32)), "the second ask is served from the cache"); + assert_eq!(frame_airlight(&hazy, &info, &s, &frame, 48, 32).to_bits(), fresh(&hazy, &frame).to_bits()); + assert_eq!(frame_airlight(&hazy, &info, &s, &frame, 48, 32).to_bits(), fresh(&hazy, &frame).to_bits()); + // another source of the same size + assert_ne!(fresh(&other, &frame), fresh(&hazy, &frame)); + assert_eq!(frame_airlight(&other, &info, &s, &frame, 48, 32).to_bits(), fresh(&other, &frame).to_bits()); + // the same source, cropped to its darker half + let mut cropped = frame.clone(); + cropped.crop.rect.x1 = 0.5; + assert_ne!(fresh(&hazy, &cropped), fresh(&hazy, &frame)); + assert_eq!(frame_airlight(&hazy, &info, &s, &cropped, 48, 32).to_bits(), fresh(&hazy, &cropped).to_bits()); + } + #[test] fn exposure_doubles() { let mut s = DevelopSettings::default(); diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs index 843f1e822..f0756ecc1 100644 --- a/crates/pipeline/src/optics.rs +++ b/crates/pipeline/src/optics.rs @@ -407,8 +407,9 @@ pub(crate) fn fingerprint(img: &Rgb32f) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ ((img.width as u64) << 32 | img.height as u64); let n = img.data.len(); for i in 0..512usize { - let p = img.data[(i.wrapping_mul(2_654_435_761) ^ i) % n.max(1)]; - for c in p { + // (an empty image has no pixels to sample: its dimensions are the fingerprint) + let Some(p) = img.data.get((i.wrapping_mul(2_654_435_761) ^ i) % n.max(1)) else { break }; + for c in *p { h ^= c.to_bits() as u64; h = h.wrapping_mul(0x0100_0000_01b3); } @@ -648,6 +649,13 @@ fn min_filter(p: &Plane, r: usize, max: bool) -> Plane { mod tests { use super::*; + // An empty image (no pixel to sample) has a fingerprint, its dimensions' (indexing its first pixel panicked) + #[test] + fn an_empty_image_has_a_fingerprint() { + assert_eq!(fingerprint(&Rgb32f::new(0, 0)), fingerprint(&Rgb32f::new(0, 0))); + assert_ne!(fingerprint(&Rgb32f::new(0, 3)), fingerprint(&Rgb32f::new(3, 0))); + } + /// splitmix64: deterministic pseudo-random numbers for the warp tests. struct Rng(u64); diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..9f2c50746 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -8,6 +8,10 @@ previews for up to a second. - A thumbnail of the photo open in Detail is made from its already decoded preview instead of decoding the file again. +### Dehaze while zoomed in +- With Dehaze on, panning a photo zoomed to 1:1 or moving a slider while zoomed no longer samples the whole + original again to find the haze colour. On a 64 MP raw (Sony A7R VI) that took ~0.2 s of CPU per pan and per + slider step; a slider step now takes ~5 ms on the GPU. ## 2026-10-12 ### Canon CR2 highlights