LR-EDIT-EFFECTS-DEHAZE: process V3 dehaze (patch dark channel, eased response, no black halos; #1237)

Dehaze estimated haze from a Gaussian of each pixel's darkest channel, so
ordinary tones read as haze (-0.5 EV on a haze-free textured ground at +50)
and shadows next to bright sky clipped to black from +10. Process V3 takes
the Gaussian of a patch minimum (0.5 % of the long edge, separable, O(1) per
pixel on the CPU; min_h/min_v on the GPU), eases the response in, floors
the transmission at 0.15 and never removes more veil than 90 % of the
pixel's darkest channel. V1 and V2 render as before.

Sources: He, Sun, Tang, "Single Image Haze Removal Using Dark Channel
Prior" (CVPR 2009); van Herk / Gil-Werman running min; own easing and veil
limit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-13 02:17:19 +11:00
co-authored by Claude Opus 5.5
parent 25ef65c81e
commit 6b7b686093
12 changed files with 317 additions and 21 deletions
+6 -2
View File
@@ -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),
}
}
}
+1 -1
View File
@@ -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"),
+2
View File
@@ -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);
+17 -3
View File
@@ -44,7 +44,7 @@ struct Planes {
base: Option<(local::BaseKey, Arc<Buf>)>,
clarity: Option<(u32, Arc<Buf>)>,
texture: Option<(u32, Arc<Buf>)>,
dark: Option<(u32, Arc<Buf>, f32)>,
dark: Option<(u64, Arc<Buf>, f32)>,
chroma: Option<(u32, Arc<Buf>)>,
}
@@ -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)
}
},
+37
View File
@@ -133,3 +133,40 @@ fn median_v(@builtin(global_invocation_id) g: vec3<u32>) {
}
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<u32>) {
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<u32>) {
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;
}
+8 -1
View File
@@ -326,7 +326,14 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
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<f32>(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<f32>(0.0));
} else {
+25 -2
View File
@@ -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<T: Copy + Default + Send>(
clar,
tex,
dehaze,
dehaze_v3,
sharpen,
sharpen_mask,
air,
@@ -386,6 +406,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
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<T: Copy + Default + Send>(
&& 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 {
+2
View File
@@ -684,6 +684,8 @@ pub(crate) fn for_rows<T: Send>(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;
+84 -10
View File
@@ -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<Plane>)>,
pub texture: Option<(u32, Arc<Plane>)>,
/// Dark channel and its airlight.
pub dark: Option<(u32, Arc<Plane>, f32)>,
pub dark: Option<(u64, Arc<Plane>, f32)>,
/// Blurred chromaticity (local Moiré / Noise).
pub chroma: Option<(u32, Arc<Rgb32f>)>,
}
@@ -448,6 +511,8 @@ pub struct PlaneSigmas {
pub texture: Option<f32>,
/// Dehaze dark channel (Gaussian).
pub dark: Option<f32>,
/// 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<f32>,
}
@@ -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.
+132
View File
@@ -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})");
}
}
}
}
+1 -1
View File
@@ -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 |
+2 -1
View File
@@ -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