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 <noreply@anthropic.com>
This commit is contained in:
dev-null-undefined
2026-10-13 13:07:34 +02:00
co-authored by Claude
parent b3d392b499
commit e7cd00ef56
4 changed files with 69 additions and 4 deletions
+1 -1
View File
@@ -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,
+54 -1
View File
@@ -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<Rgb32f> {
type Entry = (u64, Frame, usize, usize, Arc<Rgb32f>);
static CACHE: std::sync::Mutex<Vec<Entry>> = 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();
+10 -2
View File
@@ -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);
+4
View File
@@ -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