mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-12 00:42:44 -06:00
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:
co-authored by
Claude Opus 5.5
parent
25ef65c81e
commit
6b7b686093
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user