mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 18:18:12 -06:00
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:
co-authored by
Claude
parent
b3d392b499
commit
e7cd00ef56
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user