diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 1895776b3a..d5d6a396a7 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -84,17 +84,21 @@ pub enum Process { /// Highlights and Shadows keep the local contrast of the tones they move: their edge-aware base /// is taken of a fine median of log luminance (issue #632). V2, + /// Dehaze estimates haze from a patch-minimum dark channel, eases in, and never pushes a + /// pixel below a tenth of its darkest channel (issue #1237). + V3, } impl Process { /// Every process, oldest first. - pub const ALL: [Process; 2] = [Process::V1, Process::V2]; - pub const LATEST: Process = Process::V2; + pub const ALL: [Process; 3] = [Process::V1, Process::V2, Process::V3]; + pub const LATEST: Process = Process::V3; pub const fn version(self) -> ProcessVersion { match self { Process::V1 => ProcessVersion(1), Process::V2 => ProcessVersion(2), + Process::V3 => ProcessVersion(3), } } } diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index fb000c031e..8a1cd7fe74 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -32,7 +32,7 @@ const MODULES: &[Module] = &[ Module { src: include_str!("wgsl/blur.wgsl"), bindings: &[("src", false, "f32"), ("dst", true, "f32")], - entries: &["box_h", "box_v", "median_h", "median_v"], + entries: &["box_h", "box_v", "median_h", "median_v", "min_h", "min_v"], }, Module { src: include_str!("wgsl/resize.wgsl"), diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 7f9405492c..a7f07da86d 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -26,6 +26,7 @@ const FIELDS: &[(&str, usize)] = &[ ("CLAR", 1), ("TEX", 1), ("DEHAZE", 1), + ("DEHAZE_V3", 1), ("SHARPEN", 1), ("SHARPEN_MASK", 1), ("HAS_CLAR", 1), @@ -175,6 +176,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.f("CLAR", fp.clar); p.f("TEX", fp.tex); p.f("DEHAZE", fp.dehaze); + p.b("DEHAZE_V3", fp.dehaze_v3); p.f("SHARPEN", fp.sharpen); p.f("SHARPEN_MASK", fp.sharpen_mask); p.b("HAS_CLAR", present.clarity); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 44458350f0..ddb0ac3ef7 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -44,7 +44,7 @@ struct Planes { base: Option<(local::BaseKey, Arc)>, clarity: Option<(u32, Arc)>, texture: Option<(u32, Arc)>, - dark: Option<(u32, Arc, f32)>, + dark: Option<(u64, Arc, f32)>, chroma: Option<(u32, Arc)>, } @@ -326,6 +326,19 @@ fn median_hv(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, r: usize) -> Buf { out } +/// Separable minimum of a plane (`local::min_hv`). +fn min_hv(cx: &mut Cx<'_>, p: Buf, w: usize, h: usize, r: usize) -> Buf { + if r == 0 || w * h == 0 { + return p; + } + let pr = [w as u32, h as u32, 1, r.min(local::DEHAZE_PATCH_MAX) as u32]; + let tmp = cx.gpu.buffer(w * h); + cx.run("min_h", &pr, &[Some(&p), Some(&tmp)], groups2(w, h, [16, 16])); + let out = cx.gpu.buffer(w * h); + cx.run("min_v", &pr, &[Some(&tmp), Some(&out)], groups2(w, h, [16, 16])); + out +} + /// The highlights/shadows base (`local::tone_base`; `detail` 0: V1, no median). fn tone_base(cx: &mut Cx<'_>, l: &Buf, w: usize, h: usize, sigma: f32, detail: usize) -> Buf { if detail == 0 { @@ -856,10 +869,11 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla let (dark, air) = match sig.dark { None => (None, 1.0), Some(sg) => match &planes.dark { - Some((k, d, air)) if *k == sg.to_bits() => (Some(d.clone()), *air), + Some((k, d, air)) if *k == local::dark_key(sg, sig.dark_patch) => (Some(d.clone()), *air), _ => { let d0 = cx.gpu.buffer(n); map(cx, "dark_k", n, &[], [Some(lin), None, None], &d0); + let d0 = min_hv(cx, d0, w, h, sig.dark_patch); let d = gaussian(cx, &d0, w, h, 1, sg); // airlight: a percentile of every 7th value, as on the CPU let m = n.div_ceil(local::AIRLIGHT_STEP); @@ -868,7 +882,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla let air = local::airlight_of(cx.read(&sub, m)); cx.flush(); let b = Arc::new(d); - planes.dark = Some((sg.to_bits(), b.clone(), air)); + planes.dark = Some((local::dark_key(sg, sig.dark_patch), b.clone(), air)); (Some(b), air) } }, diff --git a/crates/gpu/src/wgsl/blur.wgsl b/crates/gpu/src/wgsl/blur.wgsl index b0e28370d9..2583c3cf66 100644 --- a/crates/gpu/src/wgsl/blur.wgsl +++ b/crates/gpu/src/wgsl/blur.wgsl @@ -133,3 +133,40 @@ fn median_v(@builtin(global_invocation_id) g: vec3) { } dst[g.y * w + g.x] = median_of(n, r, &v); } + +// Separable minimum of a single-channel plane (`lightcraft_pipeline::local::min_hv`): 2r+1 taps +// (clamped to the image) along a row (min_h) or a column (min_v). P: w, h, nc (unused), r. +@compute @workgroup_size(16, 16) +fn min_h(@builtin(global_invocation_id) g: vec3) { + let w = pu(0u); + let h = pu(1u); + let r = pu(3u); + if (g.x >= w || g.y >= h) { + return; + } + let row = g.y * w; + let a = select(0u, g.x - r, g.x >= r); + let b = min(g.x + r, w - 1u); + var m = src[row + a]; + for (var x = a + 1u; x <= b; x++) { + m = min(m, src[row + x]); + } + dst[row + g.x] = m; +} + +@compute @workgroup_size(16, 16) +fn min_v(@builtin(global_invocation_id) g: vec3) { + let w = pu(0u); + let h = pu(1u); + let r = pu(3u); + if (g.x >= w || g.y >= h) { + return; + } + let a = select(0u, g.y - r, g.y >= r); + let b = min(g.y + r, h - 1u); + var m = src[a * w + g.x]; + for (var y = a + 1u; y <= b; y++) { + m = min(m, src[y * w + g.x]); + } + dst[g.y * w + g.x] = m; +} diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index d51308ee0e..22cb3630f3 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -326,7 +326,14 @@ fn main(@builtin(global_invocation_id) gid: vec3) { if (dz != 0.0 && pu(F_HAS_DARK) != 0u) { let d = clamp(dark[i] / pf(F_AIR_PRE), 0.0, 1.0); let air = pf(F_AIR); - if (dz > 0.0) { + if (dz > 0.0 && pu(F_DEHAZE_V3) != 0u) { + // process V3 (`finish::dehaze_v3_pixel`) + let k0 = clamp(dz, 0.0, 1.0); + let k = k0 * (0.4 + 0.6 * k0); + let t = max(1.0 - 0.95 * k * d, 0.15); + let veil = min(air * (1.0 - t), 0.9 * max(min(c.x, min(c.y, c.z)), 0.0)); + c = max((c - veil) / t, vec3(0.0)); + } else if (dz > 0.0) { let t = max(1.0 - 0.95 * min(dz, 1.0) * d, 0.12); c = max((c - air * (1.0 - t)) / t, vec3(0.0)); } else { diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index eb65d51a2b..5d6b97a35a 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -111,6 +111,22 @@ pub const MASK_TERMS: usize = 21; /// Number of alpha-weighted terms at the start of [`mask_terms`]. pub const MASK_SUMS: usize = 17; +/// Positive dehaze from process V3 (#1237) of scene-linear `c`: `dz` the strength (0..1, with a +/// local adjustment's added), `d` the dark channel over the airlight (0..1), `air` the airlight. +/// The strength eases in (`dz·(0.4 + 0.6·dz)`: +10 acts at about half V1's), the transmission is +/// floored at 0.15, and the veil removed never exceeds 90 % of the pixel's darkest channel, so no +/// pixel is pushed to black (V1 clipped shadows next to bright sky from +10). The GPU mirrors it in +/// `finish.wgsl`. Sources: He, Sun, Tang, "Single Image Haze Removal Using Dark Channel Prior" +/// (CVPR 2009), with own easing and veil limit. +#[inline] +pub fn dehaze_v3_pixel(c: [f32; 3], dz: f32, d: f32, air: f32) -> [f32; 3] { + let k = dz.clamp(0.0, 1.0); + let k = k * (0.4 + 0.6 * k); + let t = (1.0 - 0.95 * k * d).max(0.15); + let veil = (air * (1.0 - t)).min(0.9 * c[0].min(c[1]).min(c[2]).max(0.0)); + c.map(|v| ((v - veil) / t).max(0.0)) +} + /// A mask's local adjustments as the per-pixel stage uses them (alpha-weighted sums), in order: /// exposure, temp, tint, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze, /// saturation, hue, sharpness, noise, moiré, defringe, then the colour overlay: on (0/1), cos and @@ -174,6 +190,8 @@ pub struct FinishParams { pub clar: f32, pub tex: f32, pub dehaze: f32, + /// Dehaze's process-V3 response ([`dehaze_v3_pixel`]) instead of V1's. + pub dehaze_v3: bool, pub sharpen: f32, pub sharpen_mask: f32, /// Airlight after and before exposure, exposure gain and EV (see [`crate::Prepared`]). @@ -243,6 +261,7 @@ impl FinishParams { clar, tex, dehaze, + dehaze_v3: matches!(s.process.process(), Process::V3), sharpen: (s.detail.sharpen_amount / 150.0) as f32, sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32, air: air_pre * gain, @@ -281,7 +300,7 @@ pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap { let l = &s.light; match s.process.process() { // V2 changes the Highlights/Shadows base (`local::tone_base`), not the tone map - Process::V1 | Process::V2 => match info.camera_tone.as_ref().filter(|_| info.raw) { + Process::V1 | Process::V2 | Process::V3 => match info.camera_tone.as_ref().filter(|_| info.raw) { Some(curve) => ToneMap::camera_hdr(curve, l.contrast, l.whites, l.blacks, peak), None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak), None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak), @@ -377,6 +396,7 @@ pub(crate) fn finish_with( clar, tex, dehaze, + dehaze_v3, sharpen, sharpen_mask, air, @@ -386,6 +406,7 @@ pub(crate) fn finish_with( grain, .. } = fp; + let dehaze_v3 = *dehaze_v3; let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) = (*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev); let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect(); @@ -451,7 +472,9 @@ pub(crate) fn finish_with( && let Some(dark) = &p.dark { let d = (dark.data[i] / air_pre).clamp(0.0, 1.0); - if dz > 0.0 { + if dz > 0.0 && dehaze_v3 { + c = dehaze_v3_pixel(c, dz, d, air); + } else if dz > 0.0 { let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12); c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0)); } else { diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 6fbb156e89..3ea5e43c3e 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -684,6 +684,8 @@ pub(crate) fn for_rows(data: &mut [T], w: usize, f: impl Fn(usize, &mut #[cfg(test)] mod tests; #[cfg(test)] +mod tests_dehaze; +#[cfg(test)] mod tests_geometry; #[cfg(test)] mod tests_hdr; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index ce3994cd8b..21d9628bd9 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -243,6 +243,69 @@ fn median_row(src: &[f32], out: &mut [f32], r: usize) { } } +/// Separable minimum (`2r+1` px along each row, then each column; clamped edges): a square +/// erosion. Dehaze's patch dark channel from process V3 (#1237). +pub fn min_hv(p: &Plane, r: usize) -> Plane { + if r == 0 || p.data.is_empty() { + return p.clone(); + } + let rows = |src: &Plane| { + let mut out = Plane::new(src.width, src.height); + for_rows(&mut out.data, src.width, |y, row| min_row(src.row(y), row, r)); + out + }; + transpose(&rows(&transpose(&rows(p)))) +} + +/// Minimum of `src` over `2r+1` taps (clamped to the row) into `out`, in O(1) per pixel (van Herk / +/// Gil–Werman: block-wise running minima from both ends). +fn min_row(src: &[f32], out: &mut [f32], r: usize) { + let n = src.len(); + let k = 2 * r + 1; + // padded with +inf: r before, enough after to complete the last block + let len = (n + 2 * r).div_ceil(k) * k; + let at = |i: usize| if i >= r { src.get(i - r).copied().unwrap_or(f32::INFINITY) } else { f32::INFINITY }; + let mut fwd = vec![f32::INFINITY; len]; + let mut bwd = vec![f32::INFINITY; len]; + for i in 0..len { + let v = at(i); + fwd[i] = if i % k == 0 { v } else { v.min(fwd[i - 1]) }; + } + for i in (0..len).rev() { + let v = at(i); + bwd[i] = if i % k == k - 1 || i + 1 == len { v } else { v.min(bwd[i + 1]) }; + } + for (x, o) in out.iter_mut().enumerate().take(n) { + // window [x, x + 2r] in padded coordinates + *o = bwd.get(x).copied().unwrap_or(f32::INFINITY).min(fwd.get(x + 2 * r).copied().unwrap_or(f32::INFINITY)); + } +} + +/// Patch radius of dehaze's dark channel from process V3, as a fraction of the long edge. +pub const DEHAZE_PATCH: f64 = 0.005; +/// Largest [`dehaze_patch_radius`] (px). +pub const DEHAZE_PATCH_MAX: usize = 64; + +/// Patch (minimum) radius of the dehaze dark channel of `s`'s process at `px_per_long`: 0 (the +/// per-pixel minimum) before V3. +pub fn dehaze_patch_radius(s: &DevelopSettings, px_per_long: f64) -> usize { + match s.process.process() { + Process::V1 | Process::V2 => 0, + Process::V3 => ((DEHAZE_PATCH * px_per_long).round().max(1.0) as usize).min(DEHAZE_PATCH_MAX), + } +} + +/// Cache key of the dark-channel plane: its blur radius and patch radius. +pub fn dark_key(sigma: f32, patch: usize) -> u64 { + u64::from(sigma.to_bits()) | ((patch as u64) << 32) +} + +/// The dehaze dark channel: the Gaussian (`sigma`) of the `patch`-px minimum of each pixel's +/// darkest channel. +pub fn dark_plane(img: &Rgb32f, sigma: f32, patch: usize) -> Plane { + gaussian(&min_hv(&img.map(dark_of), patch), sigma) +} + fn transpose(p: &Plane) -> Plane { let (w, h) = (p.width, p.height); let mut t = Plane::new(h, w); @@ -273,7 +336,7 @@ pub fn base_detail_radius(px_per_long: f64) -> usize { pub fn tone_base_detail(s: &DevelopSettings, px_per_long: f64) -> usize { match s.process.process() { Process::V1 => 0, - Process::V2 => base_detail_radius(px_per_long), + Process::V2 | Process::V3 => base_detail_radius(px_per_long), } } @@ -384,7 +447,7 @@ pub(crate) struct Planes { pub clarity: Option<(u32, Arc)>, pub texture: Option<(u32, Arc)>, /// Dark channel and its airlight. - pub dark: Option<(u32, Arc, f32)>, + pub dark: Option<(u64, Arc, f32)>, /// Blurred chromaticity (local Moiré / Noise). pub chroma: Option<(u32, Arc)>, } @@ -448,6 +511,8 @@ pub struct PlaneSigmas { pub texture: Option, /// Dehaze dark channel (Gaussian). pub dark: Option, + /// Patch radius (px) of that dark channel ([`dehaze_patch_radius`]: 0 before V3). + pub dark_patch: usize, /// Chromaticity blur for local Moiré / colour noise (Gaussian). pub chroma: Option, } @@ -476,7 +541,8 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 }; - PlaneSigmas { base, base_detail, clarity, texture, dark, chroma } + let dark_patch = if dark.is_some() { dehaze_patch_radius(s, px_per_long) } else { 0 }; + PlaneSigmas { base, base_detail, clarity, texture, dark, dark_patch, chroma } } /// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure), @@ -493,8 +559,15 @@ pub(crate) fn prepare( mattes: Option<&masks::Mattes>, ) -> Prepared { let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone(); - let PlaneSigmas { base: base_sigma, base_detail, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } = - plane_sigmas(s, px_per_long, q); + let PlaneSigmas { + base: base_sigma, + base_detail, + clarity: clarity_sigma, + texture: texture_sigma, + dark: dark_sigma, + dark_patch, + chroma: chroma_sigma, + } = plane_sigmas(s, px_per_long, q); let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes; let chroma_blur = chroma_sigma.map(|sg| match sch { @@ -516,11 +589,11 @@ pub(crate) fn prepare( || texture_sigma.map(|sg| plane_at(st, sg.to_bits(), || timed("texture", || gaussian(l, sg)))), || { dark_sigma.map(|sg| match sd { - Some((k, d, air)) if *k == sg.to_bits() => (d.clone(), *air), + Some((k, d, air)) if *k == dark_key(sg, dark_patch) => (d.clone(), *air), _ => { - let d = timed("dehaze", || Arc::new(gaussian(&img.map(dark_of), sg))); + let d = timed("dehaze", || Arc::new(dark_plane(&img, sg, dark_patch))); let air = airlight(&d); - *sd = Some((sg.to_bits(), d.clone(), air)); + *sd = Some((dark_key(sg, dark_patch), d.clone(), air)); (d, air) } }) @@ -545,8 +618,9 @@ pub(crate) fn prepare( 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)) + let ppl = frame.px_per_long(proxy_w); + let sigma = (0.02 * ppl).max(1.0) as f32; + airlight(&dark_plane(&img, sigma, dehaze_patch_radius(s, ppl))) } /// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel. diff --git a/crates/pipeline/src/tests_dehaze.rs b/crates/pipeline/src/tests_dehaze.rs new file mode 100644 index 0000000000..b5d09ccb35 --- /dev/null +++ b/crates/pipeline/src/tests_dehaze.rs @@ -0,0 +1,132 @@ +//! Dehaze from process V3 (issue #1237): haze is estimated from a patch-minimum dark channel, the +//! response eases in, and no pixel is pushed to black; V1 and V2 keep their rendering. + +use lightcraft_develop::{DevelopSettings, Process, controls}; +use lightcraft_raster::{Rgb32f, Rgba8}; + +use crate::{RenderRequest, SourceInfo, finish::dehaze_v3_pixel, local, render}; + +const W: usize = 600; +const H: usize = 300; + +/// Left: bright sky (0.8); right: a textured ground (a 0.03 / 0.30 chequer of 6 px cells) whose +/// patches all hold dark pixels: no haze there. +fn scene() -> Rgb32f { + Rgb32f::from_fn(W, H, |x, y| { + let v = if x < W / 2 { + 0.8 + } else if (x / 6 + y / 6).is_multiple_of(2) { + 0.03 + } else { + 0.30 + }; + [v; 3] + }) +} + +fn shot(p: Process, dehaze: f64) -> Rgba8 { + let mut s = DevelopSettings { process: p.version(), ..DevelopSettings::default() }; + controls::set(&mut s, "effects.dehaze", dehaze); + render(&scene(), &SourceInfo::default(), &s, &RenderRequest::fit(W, H)).image +} + +fn luma(p: [u8; 4]) -> f32 { + 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32 +} + +/// Mean luma and the share of black (< 2) pixels of the ground, columns `x0..x1`. +fn ground(img: &Rgba8, x0: usize, x1: usize) -> (f32, f32) { + let (mut sum, mut black, mut n) = (0.0, 0.0, 0.0); + for y in 20..H - 20 { + for x in x0..x1 { + let v = luma(img.data[y * W + x]); + sum += v; + black += f32::from(u8::from(v < 2.0)); + n += 1.0; + } + } + (sum / n, black / n) +} + +#[test] +fn v3_leaves_haze_free_texture_and_shadows_by_the_sky_alone() { + let ev = |a: f32, b: f32| (b / a).log2(); + for p in [Process::V1, Process::V3] { + let (m0, _) = ground(&shot(p, 0.0), W / 2 + 20, W); + let (m50, _) = ground(&shot(p, 50.0), W / 2 + 20, W); + let (_, black10) = ground(&shot(p, 10.0), W / 2, W / 2 + 12); + let (_, black50) = ground(&shot(p, 50.0), W / 2, W / 2 + 12); + eprintln!("{p:?}: ground {m0:.1} -> {m50:.1} at +50 ({:.2} EV); black by the sky {black10:.2} (+10) {black50:.2} (+50)", ev(m0, m50)); + if p == Process::V3 { + assert!(ev(m0, m50) > -0.25, "V3 darkens a haze-free ground by {:.2} EV at +50", ev(m0, m50)); + assert!(black10 < 0.01 && black50 < 0.01, "V3 clips shadows by the sky: {black10} {black50}"); + } else { + // V1 is frozen with the faults the issue measured + assert!(ev(m0, m50) < -0.4, "V1 changed: {:.2} EV", ev(m0, m50)); + } + } +} + +#[test] +fn v3_dehaze_still_clears_a_uniform_veil() { + // a hazy scene: a chequer lifted by a veil + let hazy = Rgb32f::from_fn(W, H, |x, y| [if (x / 6 + y / 6).is_multiple_of(2) { 0.42 } else { 0.6 }; 3]); + let contrast = |d: f64| { + let mut s = DevelopSettings { process: Process::V3.version(), ..DevelopSettings::default() }; + controls::set(&mut s, "effects.dehaze", d); + let img = render(&hazy, &SourceInfo::default(), &s, &RenderRequest::fit(W, H)).image; + let (a, b) = (luma(img.data[100 * W + 100]), luma(img.data[100 * W + 106])); + (a - b).abs() + }; + let (c0, c50, c100) = (contrast(0.0), contrast(50.0), contrast(100.0)); + assert!(c50 > c0 * 1.3 && c100 > c50, "{c0} {c50} {c100}"); +} + +#[test] +fn v3_pixel_never_goes_black_and_eases_in() { + // a shadow (0.05) whose blurred dark channel is the sky's: V1 clipped it to 0 at +10 + let air = 0.8; + for dz in [0.1, 0.25, 0.5, 1.0] { + let c = dehaze_v3_pixel([0.05, 0.06, 0.07], dz, 0.9, air); + assert!(c.iter().all(|v| *v > 0.0), "{dz}: {c:?}"); + } + let small = dehaze_v3_pixel([0.4; 3], 0.1, 0.5, air)[0]; + let v1_t = 1.0 - 0.95 * 0.1 * 0.5; + let v1 = (0.4 - air * (1.0 - v1_t)) / v1_t; + assert!((0.4 - small) < 0.6 * (0.4 - v1), "eases in: {small} vs V1 {v1}"); + assert_eq!(dehaze_v3_pixel([0.3, 0.2, 0.1], 0.0, 0.7, air), [0.3, 0.2, 0.1]); +} + +#[test] +fn patch_radius_only_from_v3_and_min_filter_erodes() { + let s = |p: Process| DevelopSettings { process: p.version(), ..DevelopSettings::default() }; + assert_eq!(local::dehaze_patch_radius(&s(Process::V1), 3000.0), 0); + assert_eq!(local::dehaze_patch_radius(&s(Process::V2), 3000.0), 0); + assert_eq!(local::dehaze_patch_radius(&s(Process::V3), 3000.0), 15); + assert_eq!(local::dehaze_patch_radius(&s(Process::V3), 1e9), local::DEHAZE_PATCH_MAX); + let p = lightcraft_raster::Plane::from_fn(9, 5, |x, y| if (x, y) == (4, 2) { 0.0 } else { 1.0 }); + let m = local::min_hv(&p, 1); + assert_eq!(m.get(3, 1), 0.0); + assert_eq!(m.get(5, 3), 0.0); + assert_eq!(m.get(6, 2), 1.0); + assert_eq!(m.get(0, 0), 1.0); +} + +#[test] +fn min_filter_matches_brute_force() { + for (w, h, r) in [(1, 1, 3), (7, 3, 1), (13, 11, 2), (40, 9, 5), (5, 30, 9)] { + let p = lightcraft_raster::Plane::from_fn(w, h, |x, y| ((x * 37 + y * 61) % 23) as f32); + let m = local::min_hv(&p, r); + for y in 0..h { + for x in 0..w { + let mut want = f32::INFINITY; + for yy in y.saturating_sub(r)..(y + r + 1).min(h) { + for xx in x.saturating_sub(r)..(x + r + 1).min(w) { + want = want.min(p.get(xx, yy)); + } + } + assert_eq!(m.get(x, y), want, "{w}x{h} r {r} at ({x}, {y})"); + } + } + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c5..dd6274089e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -200,7 +200,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; Shift-drag locks the hue and changes saturation only, showing the spoke as a guide; the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | -| LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | +| LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | From process V3 haze comes from a patch-minimum dark channel, the response eases in and no pixel is pushed to black (#1237); V1/V2 keep the old, too-strong response. Not yet measured against Lightroom | | LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | | LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | | LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | diff --git a/docs/process-versions.md b/docs/process-versions.md index ea31d39507..38681bfe57 100644 --- a/docs/process-versions.md +++ b/docs/process-versions.md @@ -8,7 +8,8 @@ stronger Light sliders, issue #196) without changing a single photo someone has | Process | What it changes | |---|---| | **V1** | LightCraft's rendering from before process versions existed. Frozen. | -| **V2** (the latest) | Highlights and Shadows keep fine detail (issue #632): their base layer (`local::tone_base`) is the edge-aware filter of a separable median of log luminance (radius 0.15 % of the long edge, at most 24 px, `local::BASE_DETAIL`) instead of log luminance itself, so twigs, lines and texture take their surroundings' shift and keep their contrast; region edges stay sharp. CPU (`local::prepare`) and GPU prepare branch on `local::tone_base_detail` (0 = no median under V1); the base plane is cached per process (`local::BaseKey`). Renders without Highlights or Shadows (global or local) are the same as V1's. Tone map unchanged. | +| **V2** | Highlights and Shadows keep fine detail (issue #632): their base layer (`local::tone_base`) is the edge-aware filter of a separable median of log luminance (radius 0.15 % of the long edge, at most 24 px, `local::BASE_DETAIL`) instead of log luminance itself, so twigs, lines and texture take their surroundings' shift and keep their contrast; region edges stay sharp. CPU (`local::prepare`) and GPU prepare branch on `local::tone_base_detail` (0 = no median under V1); the base plane is cached per process (`local::BaseKey`). Renders without Highlights or Shadows (global or local) are the same as V1's. Tone map unchanged. | +| **V3** (the latest) | Dehaze (issue #1237): the haze plane is the Gaussian of a patch minimum (`local::min_hv`, radius 0.5 % of the long edge, at most 64 px, `local::dehaze_patch_radius`) of each pixel's darkest channel instead of the Gaussian of that channel itself, so textured, haze-free regions are no longer read as haze; positive amounts ease in (`k·(0.4 + 0.6·k)`), the transmission floor is 0.15, and the veil removed never exceeds 90 % of the pixel's darkest channel (`finish::dehaze_v3_pixel`), so shadows next to bright sky no longer clip to black. Negative dehaze is V1's. The dark plane's cache key carries the patch radius (`local::dark_key`). Renders without Dehaze are the same as V2's. | ## The rule