LR-EDIT-LIGHT-SHADOWS: fix Highlights/Shadows block artefacts in fine detail; 4× faster GPU median

Fixes #3460
Related: #2936 (slow GPU kernel compiles on Windows/DX12). This PR builds the GPU kernels without wgpu's workgroup-memory zeroing, which took a DX12 release start from ~87 s to 1.4 s on a GTX 1070 with the new median kernels; it may shorten the compiles there too, but does not close it.

## Problem

Raising Shadows on a photo with dark lettering on a bright panel makes bright blocks grow beside the
letters. On a Canon CR3 of a bus (6960×4640) with a black fleet number on a yellow panel, Shadows +75
puts bright yellow rectangles in the gaps where the digits pinch together and inside the 4's counter.
At Shadows +5 there is none of this; at +75 it shows at any zoom. Narrow gaps between dark objects
generally can do it: the pinned corpus has the same thing in the gap between two posts under a machine
(Hasselblad, `coverage-6568.fff`).

## Cause

From process V2 the Highlights/Shadows base (`local::tone_base`) is the edge-aware filter of a
separable median of log luminance, at a radius of 0.15 % of the long edge (10 px here), so that thin
detail (twigs, lines, #632) takes its surroundings' shift. Text strokes and gaps are about that wide,
so each row and column window is close to half letter and half panel, and the median picks one tone
or the other in rectangles: it fills the gaps between the digits and the 4's counter with the letters'
tone. The guided filter keeps those blocks, so panel pixels whose base reads as deep shadow get
Shadows' full lift (`2^(1.7·s·w^1.5)`, up to ~1.3 EV at +75).

## Examples

Top: before, bottom: after.

The fleet number on the bus photo, Whites +54, Blacks +31, Shadows +75, at 300 %:

<img width="1020" height="976" alt="example_photo_pair" src="https://github.com/user-attachments/assets/cc5b164f-612d-4d7d-bda3-db6afd8b177f" />


The gap between two posts in the corpus' Hasselblad `coverage-6568.fff` (CC0, raw.pixls.us),
Shadows +100, at 300 %:

<!-- image: example_corpus_pair.png -->
<img width="900" height="1216" alt="example_corpus_pair" src="https://github.com/user-attachments/assets/feb4b240-194e-40b1-b5b9-c4419f840df6" />

Synthetic lettering at the scale of a bus number in a 7000 px photo, Shadows +75, at 200 %:

<!-- image: example_pair.png -->
<img width="880" height="820" alt="example_pair" src="https://github.com/user-attachments/assets/bd02e600-dda0-4648-85e9-f89ce046eb08" />

## Change

`local::detail_free` (CPU) and its GPU twin: each value of log luminance is clamped between its
separable median at r (as before) and the median of a 3× wider neighbourhood (the median at r of a
box-downscaled copy, enlarged bilinearly, like `guided_fast`'s coefficients). The base is the guided
filter of that plane.

- A twig or speck in an even surround has both medians on the surround's side and takes the nearer,
  as under the median alone (#632 unchanged).
- The gap between two strokes sits in a wider neighbourhood that is mostly panel: the medians
  disagree and the gap keeps its own tone. The strokes themselves count as fine dark detail on the
  panel (both medians agree) and keep their contrast, as twigs do.
- Steps between regions stay exact (the fine median keeps them).
- GPU: the coarse median reuses the box/bilinear resize, plus one `clamp_between_k` map kernel.
  CPU and GPU match (max 1 level).
- GPU median kernels rewritten (`median_h` / `median_v`): each workgroup loads its taps into
  workgroup memory and selects by rank, 4 neighbouring outputs per thread. The old partial selection
  sort over a private 49-tap array sat in scratch memory. Same selection, so bit for bit the CPU's
  (`median_matches_cpu_bit_for_bit`, every r 1–24); renders are byte-identical to the old kernels.
  On 6960×4640 at r = 10 (M2 Max) the median takes 191 → 47 ms.
- GPU kernels are built without wgpu's zeroing of workgroup memory (as `nn.rs` and `faces.rs` already
  are): on DX12 it made FXC take ~87 s over the new median kernels at every start of a release build
  (GTX 1070, 1.4 s without it). Every element of `med_tile` the kernels read is loaded before the barrier;
  renders are byte-identical.
- Cost on the 6960×4640 bus CR3 with Whites +54, Blacks +31, Shadows +75 (M2 Max): GPU planes
  231 ms on `main` → 113 ms here (257 ms with this PR's first kernels); CPU base ~388 → ~453 ms.
  The CPU path enlarges the coarse median while it clamps, into the fine median in place
  (`detail_free_matches_its_reference`, bit for bit).
- New process V8 (`Process::LATEST`). Existing edits keep their process and render as before, so `RENDER_CACHE_VERSION` is unchanged (it is 76 on `main`).
- Docs: `process-versions.md` (V8 row, a paragraph on the lettering fix, revision history), `parity-checklist.md`
  (LR-EDIT-LIGHT-HIGHLIGHTS / -SHADOWS notes).

An earlier revision of this PR clamped each value to the quartiles of its window instead. It fixed
the lettering but stopped treating isolated lines between r/2 and r wide as detail (twigs of 3–6 px
against a dark wall were lifted less than the wall, as under V1), which the corpus run below caught.
A fixed window can't tell an isolated line from a gap between strokes; a wider neighbourhood can.

## Process version

The new base applies from a new process, **V8** (V7 plus `local::detail_free`; `local::base_keeps_gaps` is true from V8, CPU and GPU). v0.19.0 shipped V3 to V6, and crystal grain (#3630) took V7 on `main`, so V1 to V7 render exactly as before: `tests_legacy` still locks V1 and V2, and `only_process_v8_on_takes_the_detail_free_base` checks that every earlier process keeps the plain median. New photos get V8; photos already edited keep the blocks until Update to Current Process.

The faster median kernels and the workgroup-memory change don't change pixels, so they apply to every process. They stay in this PR: the zeroing only costs time on the new median kernels (on `main` the kernels compile in 0.8 s on the GTX 1070), so on its own it would speed up nothing.

## How it was checked

- [x] New test `gaps_between_letters_keep_their_own_tone_in_the_base`: a row of synthetic "8"s
  sized like the bus number. With the median, 360 panel pixels sit more than 1 EV above their base
  (worst 2.25 EV); now none do (worst 0.8 EV). It fails with the median alone.
- [x] New test `isolated_lines_up_to_the_detail_radius_stay_out_of_the_base`: light lines 1–9 px wide
  on a dark ground (r = 10) stay out of the base, as under the median. It fails with the quartile
  clamp (a 6 px line 0.31 of the way into the base).
- [x] New test `detail_free_keeps_steps_and_regions`: steps and flat fields pass through unchanged;
  tiny, empty and NaN planes don't panic.
- [x] The #632 tests (twig/stalk contrast, local contrast, no halo at a strong edge, V1 unchanged)
  and the median reference tests pass unchanged.
- [x] `lightcraft-gpu` integration tests on a real adapter, including
  `highlights_shadows_base_matches_on_every_process` (max difference 1 level).
- [x] Corpus A/B: all 451 pinned raws (`cargo xtask corpus --download`) rendered at full size before
  and after, with Shadows +100 and with Highlights −100 (902 pairs, GPU). No failures. RMSE median
  0.18 % (Shadows) / 0.10 % (Highlights), max 0.86 %; pixels changed by more than 3 % at most 0.07 %
  of a photo; worst 8×8-block change median 10 / 7 levels, 99th percentile 44 / 41, max 61. I looked
  at the 22 largest local changes (old over new, at the worst block): no new artefacts. Most are a
  slightly different tone in small gaps among foliage or branches; one is the Hasselblad gap above.
- [x] User-visible change: rendered the bus photo at full size and at 2400 px with Whites +54,
  Blacks +31 and Shadows +5 / +75, and looked at the number, the plate, foliage against the sky and a
  brick lattice fence.
- [x] New tests `median_matches_cpu_bit_for_bit` (GPU, M2 Max) and `detail_free_matches_its_reference` (CPU).
- [x] After merging `main` and moving the base to V7 (Linux, Ryzen 5 7545U, RADV): `cargo test --release` of `lightcraft-develop`, `-pipeline` (including `tests_legacy`), `-catalog`, `-engine`, and `-gpu` on the real adapter (skipping `running_out_of_device_memory_falls_back_without_stopping_the_gpu`, which fills RAM on an APU; see above); `cargo fmt`, `cargo clippy --workspace --all-targets -D warnings`, `cargo xtask layers`, `roots`, `assets`, `parity` and `wasm`. Not run: the full `cargo xtask ci`, and the macOS-only `tests_light::v1_and_v2_render_as_before_v3` (V1 and V2 don't change, so it shouldn't need re-capturing).
- [x] After merging `main` again (with crystal grain as V7) and moving the base to V8 (Linux, Ryzen 5 7545U, RADV): `cargo test --release` of `lightcraft-develop`, `-pipeline` (including `tests_legacy` and the grain tests), `-catalog`, `-engine`, and `-gpu` on the real adapter (skipping `running_out_of_device_memory_falls_back_without_stopping_the_gpu`); `cargo fmt`, `cargo clippy --workspace --all-targets -D warnings`, `cargo xtask layers`, `roots`, `assets` and `parity`. Not run: `wasm` and the full `cargo xtask ci`.
- [x] `cargo xtask ci --quick` (with `CRAFT_FONTS_DIR`): all steps pass. After the median change: all steps pass again (without `CRAFT_FONTS_DIR`), and the CR3 renders
  byte-identical to the first kernels on GPU and CPU.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Highlights and Shadows adjustments better preserve clean gaps in fine details such as lettering while maintaining the existing treatment of broader image features.
  * Tone adjustments are less likely to include isolated, narrow lines in the base used for processing.
  * The updated detail handling helps retain fine structure while reducing unwanted influence from closely packed details.
* **Documentation**
  * Updated process notes to explain how fine-detail handling affects Highlights and Shadows adjustments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jeremy Hooke
2026-11-11 07:31:45 +01:00
committed by GitHub
parent c2e9c456c9
commit bb414d220a
14 changed files with 458 additions and 66 deletions
+6 -2
View File
@@ -99,12 +99,15 @@ pub enum Process {
/// V6, with film grain from a stack of crystal layers (after Aurélien Pierre's model): resolution
/// independent, fading out at black and white; V1 to V6 keep their value noise.
V7,
/// V7, with the Highlights/Shadows base taken of log luminance without its fine detail (clamped
/// between a fine and a 3× wider median), so gaps in dark lettering aren't lifted into bright blocks.
V8,
}
impl Process {
/// Every process, oldest first.
pub const ALL: [Process; 7] = [Process::V1, Process::V2, Process::V3, Process::V4, Process::V5, Process::V6, Process::V7];
pub const LATEST: Process = Process::V7;
pub const ALL: [Process; 8] = [Process::V1, Process::V2, Process::V3, Process::V4, Process::V5, Process::V6, Process::V7, Process::V8];
pub const LATEST: Process = Process::V8;
pub const fn version(self) -> ProcessVersion {
match self {
@@ -115,6 +118,7 @@ impl Process {
Process::V5 => ProcessVersion(5),
Process::V6 => ProcessVersion(6),
Process::V7 => ProcessVersion(7),
Process::V8 => ProcessVersion(8),
}
}
}
+4 -5
View File
@@ -89,14 +89,13 @@ fn v1_is_left_out_when_saving_and_other_processes_are_kept() {
#[test]
fn unknown_process_numbers_load_and_render_with_the_newest_known() {
// written by a newer LightCraft: kept as it is, rendered with the latest process this build has
let newer = Process::LATEST.version().0 + 1;
let s = DevelopSettings::from_json(&json!({"process": newer, "light": {"exposure": 0.5}})).unwrap();
assert_eq!(s.process, ProcessVersion(newer));
let s = DevelopSettings::from_json(&json!({"process": LATEST_PLUS_ONE, "light": {"exposure": 0.5}})).unwrap();
assert_eq!(s.process, ProcessVersion(LATEST_PLUS_ONE));
assert_eq!(s.process.process(), Process::LATEST);
assert!(!s.process.is_known());
assert!(!s.process.is_outdated(), "never downgraded by Update to Current Process");
assert_eq!(s.to_json()["process"], json!(newer));
assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(newer));
assert_eq!(s.to_json()["process"], json!(LATEST_PLUS_ONE));
assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(LATEST_PLUS_ONE));
// below V1 (no LightCraft writes it): rendered as V1, and updatable
let zero = DevelopSettings::from_json(&json!({"process": 0})).unwrap().process;
assert_eq!((zero.process(), zero.is_known(), zero.is_outdated()), (Process::V1, false, true));
+6 -1
View File
@@ -51,6 +51,7 @@ const MODULES: &[Module] = &[
"guided_pre",
"guided_ab",
"guided_apply",
"clamp_between_k",
"wb_k",
"nr_lum",
"chroma_k",
@@ -197,7 +198,11 @@ fn build_jobs(
layout: Some(&p.pipeline_layout),
module: &p.module,
entry_point: Some(entry),
compilation_options: Default::default(),
// Only the median kernels (`blur.wgsl`, `med_tile`) use workgroup memory, and they
// load every element they read before the barrier, so wgpu's default zeroing is not
// needed. On DX12 it is written out as one assignment per element, which made FXC take
// about 87 s over these kernels at every start of a release build.
compilation_options: wgpu::PipelineCompilationOptions { zero_initialize_workgroup_memory: false, ..Default::default() },
cache: None,
});
let kernel = Kernel { pipeline, layout: p.layout.clone(), nbuf: p.nbuf };
+96 -6
View File
@@ -371,6 +371,9 @@ fn guided_coeffs(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps:
gaussian(cx, &ab, w, h, 2, sigma)
}
/// Pixels per thread along the pass of `median_h` / `median_v` (`MED_K` in `blur.wgsl`).
const MEDIAN_K: usize = 4;
/// Separable median of a plane (`local::median_hv`).
fn median_hv(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, r: usize) -> Buf {
if r == 0 || w * h == 0 {
@@ -378,21 +381,38 @@ fn median_hv(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, r: usize) -> Buf {
}
let pr = [w as u32, h as u32, 1, r.min(local::BASE_DETAIL_MAX) as u32];
let tmp = cx.gpu.buffer(w * h);
cx.run("median_h", &pr, &[Some(p), Some(&tmp)], groups2(w, h, [16, 16]));
cx.run("median_h", &pr, &[Some(p), Some(&tmp)], groups2(w.div_ceil(MEDIAN_K), h, [16, 16]));
let out = cx.gpu.buffer(w * h);
cx.run("median_v", &pr, &[Some(&tmp), Some(&out)], groups2(w, h, [16, 16]));
cx.run("median_v", &pr, &[Some(&tmp), Some(&out)], groups2(w, h.div_ceil(MEDIAN_K), [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 {
/// The highlights/shadows base (`local::tone_base`; `detail` 0: V1, no detail removal).
fn tone_base(cx: &mut Cx<'_>, l: &Buf, w: usize, h: usize, sigma: f32, detail: usize, keeps_gaps: bool) -> Buf {
if detail == 0 {
return guided_fast(cx, l, w, h, sigma, local::BASE_EPS);
}
let m = median_hv(cx, l, w, h, detail);
let m = if keeps_gaps { detail_free(cx, l, w, h, detail) } else { median_hv(cx, l, w, h, detail) };
guided_fast(cx, &m, w, h, sigma, local::BASE_EPS)
}
/// Log luminance without detail finer than `r` px (`local::detail_free`): clamped between its
/// median at `r` and the median of a `local::BASE_COARSE_STEP`× downscaled copy, enlarged.
fn detail_free(cx: &mut Cx<'_>, l: &Buf, w: usize, h: usize, r: usize) -> Buf {
if r == 0 || w * h == 0 {
return cx.copy(l);
}
let fine = median_hv(cx, l, w, h, r);
let s = local::BASE_COARSE_STEP;
let (lw, lh) = (w.div_ceil(s).max(1), h.div_ceil(s).max(1));
let lo = resize(cx, l, (w, h), (lw, lh), 1, Filter::Box);
let lo_median = median_hv(cx, &lo, lw, lh, r);
let coarse = resize(cx, &lo_median, (lw, lh), (w, h), 1, Filter::Bilinear);
let out = cx.gpu.buffer(w * h);
map(cx, "clamp_between_k", w * h, &[], [Some(l), Some(&fine), Some(&coarse)], &out);
out
}
/// Fast guided filter (`local::guided_fast`): coefficients on a subsampled grid.
fn guided_fast(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf {
let s = local::guided_fast_step(sigma);
@@ -1418,7 +1438,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
let base = match sig.base {
Some(sg) => {
let key = local::base_key(&plan.settings, sg, sig.base_detail);
plane_at(&mut planes.base, key, || tone_base(cx, &log_l, w, h, sg, sig.base_detail))
plane_at(&mut planes.base, key, || tone_base(cx, &log_l, w, h, sg, sig.base_detail, local::base_keeps_gaps(&plan.settings)))
}
None => log_l.clone(),
};
@@ -1841,6 +1861,76 @@ mod tests {
time("guided sigma=1.75", &mut |cx| drop(guided(cx, &src, w, h, 1.75, 0.01)));
}
/// The tiled median kernels select exactly what the CPU's sorted window does: edge tiles,
/// planes smaller than a tile or a window, every radius and many ties.
#[test]
fn median_matches_cpu_bit_for_bit() {
// (creating the device writes the crash sentinel another test swaps out)
let _serial = crate::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(gpu) = crate::device() else { return lightcraft_testkit::skip!(Gpu, "no GPU adapter") };
let _scope = crate::ctx::RenderScope::new(gpu);
let mut seed = 0x9e37_79b9_u32;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
for (w, h) in [(1, 1), (2, 3), (5, 70), (70, 5), (63, 65), (130, 67), (200, 129)] {
// a coarse level set gives ties; a smooth ramp and fine noise give distinct values
let mut p = Plane::new(w, h);
for (i, o) in p.data.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
*o = match (next() % 3, next()) {
(0, v) => (v % 7) as f32 * 0.5 - 2.0,
(1, v) => (x + 2 * y) as f32 * 0.01 + (v % 100) as f32 * 1e-4,
(_, v) => (v % 10_000) as f32 * 1e-3 - 5.0,
};
}
let src = gpu.upload(&p.data);
for r in 1..=local::BASE_DETAIL_MAX {
let mut cx = Cx::new(gpu);
let out = median_hv(&mut cx, &src, w, h, r);
let got: Vec<f32> = cx.read(&out, w * h);
let want = local::median_hv(&p, r);
let bad = got.iter().zip(&want.data).position(|(g, c)| g.to_bits() != c.to_bits());
assert!(bad.is_none(), "{w}×{h} r={r}: pixel {bad:?}");
}
}
}
/// Highlights/shadows base timings on the #3460 frame size:
/// `cargo test --release -p lightcraft-gpu bench_tone_base -- --ignored --nocapture`.
#[test]
#[ignore]
fn bench_tone_base() {
let Some(gpu) = crate::device() else { return eprintln!("no gpu: {:?}", crate::unavailable_reason()) };
let _scope = crate::ctx::RenderScope::new(gpu);
let (w, h) = (6960, 4640);
let r = local::base_detail_radius(w as f64);
eprintln!("{} ({:?}) r={r}", gpu.info.name, gpu.info.backend);
let l: Vec<f32> = (0..w * h).map(|i: usize| ((i.wrapping_mul(2_654_435_761)) % 1000) as f32 / 250.0 - 6.0).collect();
let src = gpu.upload(&l);
let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| {
let mut ts = Vec::new();
for _ in 0..9 {
let mut cx = Cx::new(gpu);
cx.sync();
let t = std::time::Instant::now();
f(&mut cx);
cx.sync();
ts.push(t.elapsed().as_secs_f64() * 1e3);
}
ts.sort_by(f64::total_cmp);
eprintln!("{name:<40} min {:.1} ms median {:.1} ms", ts[0], ts[4]);
};
let sigma = 0.015 * w as f32;
time("median_hv", &mut |cx| drop(median_hv(cx, &src, w, h, r)));
time("detail_free", &mut |cx| drop(detail_free(cx, &src, w, h, r)));
time("guided_fast", &mut |cx| drop(guided_fast(cx, &src, w, h, sigma, local::BASE_EPS)));
time("tone_base", &mut |cx| drop(tone_base(cx, &src, w, h, sigma, r, true)));
}
/// The device scores Remove's candidate sources like the CPU and picks the same one.
#[test]
fn auto_source_picks_the_cpu_candidate() {
+89 -31
View File
@@ -113,56 +113,114 @@ fn minmax(@builtin(global_invocation_id) g: vec3<u32>) {
}
// Separable median of a single-channel plane (`lightcraft_pipeline::local::median_hv`): 2r+1
// taps (r ≤ 24, clamped edges) along a row (median_h) or a column (median_v), one thread per
// pixel. The median is a selection, so the result matches the CPU's bit for bit.
// taps (1 ≤ r ≤ 24, clamped edges) along a row (median_h) or a column (median_v). The median is a
// selection, so the result matches the CPU's bit for bit.
// P: w, h, nc (unused), r.
fn median_of(n: u32, r: u32, v: ptr<function, array<f32, 49>>) -> f32 {
// partial selection sort up to the middle
for (var i = 0u; i <= r; i++) {
var m = i;
for (var j = i + 1u; j < n; j++) {
if ((*v)[j] < (*v)[m]) {
m = j;
}
//
// Each thread finds the medians of 4 neighbouring pixels along the pass by rank: a tap is the
// median of a window when fewer than r+1 of its taps are smaller and at least r+1 are smaller or
// equal. The 4 windows share all but their first and last 3 taps, so one count over the shared
// taps serves all four. A 16×16 workgroup (64 pixels along the pass, 16 lines across) first loads
// its taps into `med_tile`. On a 6960×4640 plane at r = 10 this takes 47 ms on an M2 Max, against
// 191 ms for a partial selection sort per pixel over a private array of taps, which drivers keep in
// scratch memory because it is indexed dynamically.
const MED_K: u32 = 4u;
const MED_LINE: u32 = 113u; // 16·MED_K + 2·24, odd
// Not zero-initialised (`build_jobs` in ctx.rs turns that off): write every element read.
var<workgroup> med_tile: array<f32, 1808>; // 16 lines
// The medians of the 2r+1-tap windows starting at `med_tile[base + k]`, k < 4 (r ≥ 1). Taps 3 to
// n-1 are in all four windows; the three at each end are in some.
fn median4(base: u32, r: u32) -> vec4<f32> {
let n = 2u * r + 1u;
let e0 = vec3<f32>(med_tile[base], med_tile[base + 1u], med_tile[base + 2u]);
let e1 = vec3<f32>(med_tile[base + n], med_tile[base + n + 1u], med_tile[base + n + 2u]);
var out = vec4<f32>(0.0);
var found = vec4<bool>(false);
for (var i = 0u; i < n + 3u; i++) {
let c = med_tile[base + i];
var less = 0u;
var le = 0u;
for (var j = 3u; j < n; j++) {
let v = med_tile[base + j];
less += select(0u, 1u, v < c);
le += select(0u, 1u, v <= c);
}
let l0 = select(vec3<u32>(0u), vec3<u32>(1u), e0 < vec3<f32>(c));
let l1 = select(vec3<u32>(0u), vec3<u32>(1u), e1 < vec3<f32>(c));
let q0 = select(vec3<u32>(0u), vec3<u32>(1u), e0 <= vec3<f32>(c));
let q1 = select(vec3<u32>(0u), vec3<u32>(1u), e1 <= vec3<f32>(c));
// window k: taps k..k+n-1 = taps k..2 of the start, the middle, taps n..n+k-1 of the end
let lw = vec4<u32>(l0.x + l0.y + l0.z, l0.y + l0.z + l1.x, l0.z + l1.x + l1.y, l1.x + l1.y + l1.z) + vec4<u32>(less);
let qw = vec4<u32>(q0.x + q0.y + q0.z, q0.y + q0.z + q1.x, q0.z + q1.x + q1.y, q1.x + q1.y + q1.z) + vec4<u32>(le);
let has = vec4<bool>((i < n), (i >= 1u) && (i < n + 1u), (i >= 2u) && (i < n + 2u), (i >= 3u));
let hit = has & !found & (lw <= vec4<u32>(r)) & (qw > vec4<u32>(r));
out = select(out, vec4<f32>(c), hit);
found = found | hit;
if (all(found)) {
break;
}
let t = (*v)[i];
(*v)[i] = (*v)[m];
(*v)[m] = t;
}
return (*v)[r];
return out;
}
// Thread (l.x, l.y) of a workgroup takes line l.x and pixels 4·l.y to 4·l.y + 3 along it, so the
// threads of a SIMD group read 16 different lines at once; with an odd line length those fall in
// different banks of workgroup memory.
@compute @workgroup_size(16, 16)
fn median_h(@builtin(global_invocation_id) g: vec3<u32>) {
fn median_h(@builtin(local_invocation_id) l: vec3<u32>, @builtin(workgroup_id) wg: vec3<u32>) {
let w = pu(0u);
let h = pu(1u);
let r = min(pu(3u), 24u);
if (g.x >= w || g.y >= h) {
let span = 16u * MED_K + 2u * r;
let x0 = i32(wg.x * 16u * MED_K) - i32(r);
// load: consecutive threads read consecutive pixels of a row
for (var e = l.y * 16u + l.x; e < 16u * span; e += 256u) {
let line = e / span;
let k = e % span;
let y = min(wg.y * 16u + line, h - 1u);
let x = clamp(x0 + i32(k), 0, i32(w) - 1);
med_tile[line * MED_LINE + k] = src[y * w + u32(x)];
}
workgroupBarrier();
let y = wg.y * 16u + l.x;
let x = (wg.x * 16u + l.y) * MED_K;
if (x >= w || y >= h) {
return;
}
let n = 2u * r + 1u;
var v: array<f32, 49>;
let row = g.y * w;
for (var k = 0u; k < n; k++) {
let x = clamp(i32(g.x + k) - i32(r), 0, i32(w) - 1);
v[k] = src[row + u32(x)];
let m = median4(l.x * MED_LINE + l.y * MED_K, r);
for (var k = 0u; k < MED_K; k++) {
if (x + k < w) {
dst[y * w + x + k] = m[k];
}
}
dst[row + g.x] = median_of(n, r, &v);
}
@compute @workgroup_size(16, 16)
fn median_v(@builtin(global_invocation_id) g: vec3<u32>) {
fn median_v(@builtin(local_invocation_id) l: vec3<u32>, @builtin(workgroup_id) wg: vec3<u32>) {
let w = pu(0u);
let h = pu(1u);
let r = min(pu(3u), 24u);
if (g.x >= w || g.y >= h) {
let span = 16u * MED_K + 2u * r;
let y0 = i32(wg.y * 16u * MED_K) - i32(r);
// load: consecutive threads read consecutive pixels of a row (16 columns)
for (var e = l.y * 16u + l.x; e < 16u * span; e += 256u) {
let line = e % 16u;
let k = e / 16u;
let x = min(wg.x * 16u + line, w - 1u);
let y = clamp(y0 + i32(k), 0, i32(h) - 1);
med_tile[line * MED_LINE + k] = src[u32(y) * w + x];
}
workgroupBarrier();
let x = wg.x * 16u + l.x;
let y = (wg.y * 16u + l.y) * MED_K;
if (x >= w || y >= h) {
return;
}
let n = 2u * r + 1u;
var v: array<f32, 49>;
for (var k = 0u; k < n; k++) {
let y = clamp(i32(g.y + k) - i32(r), 0, i32(h) - 1);
v[k] = src[u32(y) * w + g.x];
let m = median4(l.x * MED_LINE + l.y * MED_K, r);
for (var k = 0u; k < MED_K; k++) {
if (y + k < h) {
dst[(y + k) * w + x] = m[k];
}
}
dst[g.y * w + g.x] = median_of(n, r, &v);
}
+19
View File
@@ -70,6 +70,25 @@ fn guided_apply(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgr
dst[i] = b[2u * i] * a[i] + b[2u * i + 1u];
}
// `clamp_between` (`local::detail_free`): a clamped between b and c, in either order.
@compute @workgroup_size(256)
fn clamp_between_k(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
let i = lin_index(g, nw);
if (i >= pu(0u)) {
return;
}
let lo = min(b[i], c[i]);
let hi = max(b[i], c[i]);
let v = a[i];
if (v < lo) {
dst[i] = lo;
} else if (v > hi) {
dst[i] = hi;
} else {
dst[i] = v;
}
}
// Cross guided filter (`masks::guided_cross`), a: guide, b: input. P[1] = 0: (guide, input);
// 1: (guide·input, guide²).
@compute @workgroup_size(256)
+2 -2
View File
@@ -807,7 +807,7 @@ fn light_v3_operators_match() {
}
/// Issue #632: the Highlights / Shadows base of each process (V1: the edge-aware filter of log
/// luminance; V2: of its fine median, a radius that grows with the render) matches the CPU's, and
/// luminance; V2 to V7: of its fine median, V8 on: of its detail-free plane, a radius that grows with the render) matches the CPU's, and
/// a warm cache never hands one process the other's base.
#[test]
fn highlights_shadows_base_matches_on_every_process() {
@@ -838,7 +838,7 @@ fn highlights_shadows_base_matches_on_every_process() {
}];
check(&format!("{p:?} local highlights {w}"), &src, &raw, &s, &req);
}
for p in [Process::V1, Process::V2, Process::V4, Process::V1, Process::V2, Process::V4] {
for p in [Process::V1, Process::V2, Process::V4, Process::V8, Process::V1, Process::V2, Process::V4, Process::V8] {
let mut s = DevelopSettings { process: p.version(), ..DevelopSettings::default() };
s.light.highlights = -100.0;
let warm = lightcraft_gpu::render(&src, &raw, &s, &req, Some(&cache)).expect("gpu");
+3 -3
View File
@@ -148,8 +148,8 @@ impl GrainModel {
seed: g.seed,
})
}),
Process::V7 if draft => crate::crystal::CrystalGrain::draft(g, px_per_long).map(GrainModel::Crystal),
Process::V7 => crate::crystal::CrystalGrain::new(g, px_per_long).map(GrainModel::Crystal),
Process::V7 | Process::V8 if draft => crate::crystal::CrystalGrain::draft(g, px_per_long).map(GrainModel::Crystal),
Process::V7 | Process::V8 => crate::crystal::CrystalGrain::new(g, px_per_long).map(GrainModel::Crystal),
}
}
}
@@ -407,7 +407,7 @@ pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap {
},
// V4, with a raw's tone curve rolling off into white in stops: a camera curve past its last
// knot, the generic curve from half a stop below its white point instead of clipping (issue #1632)
Process::V5 | Process::V6 | Process::V7 => match info.camera_tone.as_ref().filter(|_| info.raw) {
Process::V5 | Process::V6 | Process::V7 | Process::V8 => match info.camera_tone.as_ref().filter(|_| info.raw) {
Some(curve) => ToneMap::camera_rolloff_hdr(curve, 0.0, l.whites, 0.0, peak),
None if info.raw => ToneMap::rolloff_hdr(l.contrast, l.whites, l.blacks, peak),
None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak),
+1 -1
View File
@@ -284,7 +284,7 @@ pub fn applies(s: &DevelopSettings, info: &SourceInfo) -> bool {
let camera_look = info.raw && info.camera_tone.is_some();
match s.process.process() {
Process::V1 | Process::V2 | Process::V3 => false,
Process::V4 | Process::V5 | Process::V6 | Process::V7 => camera_look,
Process::V4 | Process::V5 | Process::V6 | Process::V7 | Process::V8 => camera_look,
}
}
+119 -10
View File
@@ -357,7 +357,7 @@ fn transpose(p: &Plane) -> Plane {
}
/// Detail scale of the highlights/shadows base from process V2, as a fraction of the long edge:
/// the base is the edge-aware filter of the [`median_hv`] of log luminance at this radius, so
/// the base is the edge-aware filter of log luminance without its detail at this radius, so
/// twigs, lines and fine texture brighter or darker than their surroundings take their
/// surroundings' shift (and keep their contrast, as with Exposure) while region boundaries stay
/// sharp (no halos). V1's base is the edge-aware filter of log luminance itself (issue #632).
@@ -375,17 +375,76 @@ 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 | Process::V3 | Process::V4 | Process::V5 | Process::V6 | Process::V7 => base_detail_radius(px_per_long),
Process::V2 | Process::V3 | Process::V4 | Process::V5 | Process::V6 | Process::V7 | Process::V8 => base_detail_radius(px_per_long),
}
}
/// The highlights/shadows base layer of log luminance `l`: the fast guided filter ([`BASE_EPS`])
/// of its [`median_hv`] at `detail` px ([`tone_base_detail`]); at 0, of `l` itself (V1).
pub fn tone_base(l: &Plane, sigma: f32, detail: usize) -> Plane {
/// at `detail` px ([`tone_base_detail`]) of its [`detail_free`] plane when `keeps_gaps`
/// ([`base_keeps_gaps`], V8 on), else of its [`median_hv`] (V2 to V7); at 0, of `l` itself (V1).
pub fn tone_base(l: &Plane, sigma: f32, detail: usize, keeps_gaps: bool) -> Plane {
if detail == 0 {
return guided_fast(l, sigma, BASE_EPS);
}
guided_fast(&median_hv(l, detail), sigma, BASE_EPS)
let m = if keeps_gaps { detail_free(l, detail) } else { median_hv(l, detail) };
guided_fast(&m, sigma, BASE_EPS)
}
/// Downscale factor of the coarse median in [`detail_free`]: its radius at full size is this many
/// times the fine one's.
pub const BASE_COARSE_STEP: usize = 3;
/// Log luminance `l` without detail finer than `r` px (the highlights/shadows base of process V8
/// on takes its edge-aware filter): each value moves toward its [`median_hv`] at `r` only as far as the
/// median of a [`BASE_COARSE_STEP`]× wider neighbourhood agrees (`l` clamped between the two).
/// A twig, line or speck in an even surround has both medians on the surround's side and takes
/// its tone, as #632 wants. The gap between two letter strokes, which a median at `r` fills with the
/// strokes' tone, sits in a wider neighbourhood mostly of the panel's tone: the two medians
/// disagree and the gap keeps its own. The coarse median is taken at `r` of a box-downscaled copy and
/// enlarged bilinearly, like [`guided_fast`]'s coefficients.
pub fn detail_free(l: &Plane, r: usize) -> Plane {
use lightcraft_raster::resample::{Filter, resize};
if r == 0 || l.data.is_empty() {
return l.clone();
}
let (w, h) = (l.width, l.height);
let (lw, lh) = (w.div_ceil(BASE_COARSE_STEP).max(1), h.div_ceil(BASE_COARSE_STEP).max(1));
if (lw, lh) == (w, h) {
// nothing to downscale: both medians are the same
return median_hv(l, r);
}
let lo = median_hv(&resize(l, lw, lh, Filter::Box), r);
// The bilinear enlargement of `lo` (`resize`'s horizontal pass, then its vertical pass taken
// row by row with the same sums) is clamped into `out` as it is made, not stored.
let wide = resize(&lo, w, lh, Filter::Bilinear);
let wy = lightcraft_raster::resample::weights(lh, h, Filter::Bilinear);
let mut out = median_hv(l, r);
for_rows(&mut out.data, w, |y, row| {
let Some((y0, ws)) = wy.get(y) else { return };
let mut coarse = vec![0.0f32; w];
for (k, &wt) in ws.iter().enumerate() {
let Some(t) = wide.data.get((y0 + k) * w..(y0 + k + 1) * w) else { continue };
for (c, &v) in coarse.iter_mut().zip(t) {
*c += v * wt;
}
}
for ((o, &v), &c) in row.iter_mut().zip(l.row(y)).zip(&coarse) {
*o = clamp_between(v, *o, c);
}
});
out
}
/// `v` clamped between `a` and `b` (in either order); a NaN bound is ignored, not a panic.
fn clamp_between(v: f32, a: f32, b: f32) -> f32 {
let (lo, hi) = (a.min(b), a.max(b));
if v < lo {
lo
} else if v > hi {
hi
} else {
v
}
}
/// Subsampling step of [`guided_fast`] for `sigma` (1 = no subsampling: the plain guided filter).
@@ -638,6 +697,12 @@ pub fn sharpen_key(s: &DevelopSettings, sigma: f32) -> SharpenKey {
(sigma.to_bits(), s.process.process())
}
/// Whether `s`'s highlights/shadows base takes [`detail_free`] (process V8 on) instead of V2's plain
/// [`median_hv`] (V2 to V7, which shipped with it and stay frozen).
pub fn base_keeps_gaps(s: &DevelopSettings) -> bool {
s.process.process() >= Process::V8
}
pub fn pixel_sharpen(s: &DevelopSettings) -> bool {
s.process.process() >= Process::V3
}
@@ -677,7 +742,7 @@ pub const CHROMA_SIGMA: f32 = 0.004;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlaneSigmas {
/// Edge-aware base for highlights/shadows ([`tone_base`]: fast guided filter, [`BASE_EPS`]; from
/// process V2 of the [`BASE_DETAIL`] median).
/// process V2 of a median at [`BASE_DETAIL`], from V8 of a detail-free plane).
pub base: Option<f32>,
/// Median radius (px) of that base ([`tone_base_detail`]: 0 under V1).
pub base_detail: usize,
@@ -806,7 +871,7 @@ pub(crate) fn prepare(
});
let l = &log_l;
let (base, (clarity_blur, ((texture_blur, sharpen_blur), dark))) = par_join(
|| base_sigma.map(|sg| plane_at(sb, base_key(s, sg, base_detail), || timed("base", || tone_base(l, sg, base_detail)))),
|| base_sigma.map(|sg| plane_at(sb, base_key(s, sg, base_detail), || timed("base", || tone_base(l, sg, base_detail, base_keeps_gaps(s))))),
|| {
par_join(
|| clarity_sigma.map(|sg| plane_at(sc, sg.to_bits(), || timed("clarity", || guided_fast(l, sg, CLARITY_EPS)))),
@@ -1309,6 +1374,50 @@ mod tests {
mod fast_tests {
use super::*;
/// [`detail_free`] enlarges the coarse median as it clamps; the result is the plain
/// composition of `resize` and [`median_hv`], bit for bit.
#[test]
fn detail_free_matches_its_reference() {
use lightcraft_raster::resample::{Filter, resize};
for (w, h) in [(1, 1), (2, 1), (3, 3), (4, 7), (61, 47), (200, 31)] {
let p = Plane::from_fn(w, h, |x, y| ((x * 37 + y * 101) % 23) as f32 * 0.4 - 4.0 + if (x / 5 + y / 7) % 2 == 0 { 2.0 } else { 0.0 });
for r in [1, 2, 5, 10] {
let fine = median_hv(&p, r);
let (lw, lh) = (w.div_ceil(BASE_COARSE_STEP), h.div_ceil(BASE_COARSE_STEP));
let coarse = resize(&median_hv(&resize(&p, lw, lh, Filter::Box), r), w, h, Filter::Bilinear);
let want: Vec<u32> = (0..w * h).map(|i| clamp_between(p.data[i], fine.data[i], coarse.data[i]).to_bits()).collect();
let got: Vec<u32> = detail_free(&p, r).data.iter().map(|v| v.to_bits()).collect();
assert_eq!(got, want, "{w}×{h} r={r}");
}
}
}
/// CPU highlights/shadows base timings on the #3460 frame size:
/// `cargo test --release -p lightcraft-pipeline bench_tone_base_cpu -- --ignored --nocapture`.
#[test]
#[ignore]
fn bench_tone_base_cpu() {
let (w, h) = (6960, 4640);
let r = base_detail_radius(w as f64);
let p = Plane::from_fn(w, h, |x, y| ((x * 7 + y * 13) % 1000) as f32 / 250.0 - 6.0 + if (x / 40 + y / 40) % 2 == 0 { 1.0 } else { 0.0 });
let sigma = 0.015 * w as f32;
let time = |name: &str, f: &dyn Fn() -> Plane| {
let mut ts: Vec<f64> = (0..5)
.map(|_| {
let t = std::time::Instant::now();
std::hint::black_box(f());
t.elapsed().as_secs_f64() * 1e3
})
.collect();
ts.sort_by(f64::total_cmp);
eprintln!("{name:<20} min {:.1} ms median {:.1} ms", ts[0], ts[2]);
};
time("median_hv", &|| median_hv(&p, r));
time("detail_free", &|| detail_free(&p, r));
time("guided_fast", &|| guided_fast(&p, sigma, BASE_EPS));
time("tone_base", &|| tone_base(&p, sigma, r, true));
}
/// The previous fast path, including its two materialized coefficient resizes.
fn resized_reference(p: &Plane, sigma: f32, eps: f32) -> Plane {
use lightcraft_raster::resample::{Filter, resize};
@@ -1353,12 +1462,12 @@ mod fast_tests {
}
#[test]
fn sampled_tone_base_keeps_the_median_before_the_guided_filter() {
fn sampled_tone_base_keeps_the_detail_free_plane_before_the_guided_filter() {
let p = Plane::from_fn(73, 51, |x, y| if x % 9 == 0 { 3.0 } else { -4.0 + ((x + y) % 7) as f32 * 0.03 });
for sigma in [6.0, 24.0, 120.0] {
for detail in [0, 1, 3, BASE_DETAIL_MAX] {
let median = median_hv(&p, detail);
assert_eq!(tone_base(&p, sigma, detail).data, resized_reference(&median, sigma, BASE_EPS).data, "{sigma}, {detail}");
let free = detail_free(&p, detail);
assert_eq!(tone_base(&p, sigma, detail, true).data, resized_reference(&free, sigma, BASE_EPS).data, "{sigma}, {detail}");
}
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ fn processes_before_v7_keep_the_value_noise() {
Process::V1 | Process::V2 | Process::V3 | Process::V4 | Process::V5 | Process::V6 => {
assert!(matches!(model, Some(GrainModel::Value(_))), "{p:?}")
}
Process::V7 => assert!(matches!(model, Some(GrainModel::Crystal(_))), "{p:?}"),
Process::V7 | Process::V8 => assert!(matches!(model, Some(GrainModel::Crystal(_))), "{p:?}"),
}
assert!(GrainModel::new(&on(p, 0.0), 2000.0, false).is_none(), "{p:?}: no grain at Amount 0");
}
+93 -1
View File
@@ -208,6 +208,98 @@ fn median_orders_special_values_and_ties_like_total_cmp() {
}
}
/// A row of "8"s (two rings each, strokes 1.3 detail radii `r` wide, neighbours 0.8 radii apart):
/// dark lettering on a bright panel at the size of a bus number, as log luminance.
fn lettering(r: usize) -> (Plane, impl Fn(usize, usize) -> bool) {
let rf = r as f32;
let ring = move |x: f32, y: f32, cx: f32, cy: f32, outer: f32| {
let d = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt();
d <= outer && d >= outer - 1.3 * rf
};
let stroke = move |x: usize, y: usize| {
let (x, y) = (x as f32, y as f32);
(0..5).any(|i| {
let cx = 150.0 + i as f32 * 7.4 * rf;
ring(x, y, cx, 200.0 - 2.8 * rf, 2.9 * rf) || ring(x, y, cx, 200.0 + 3.2 * rf, 3.3 * rf)
})
};
(Plane::from_fn(900, 400, |x, y| if stroke(x, y) { -4.6 } else { -1.3 }), stroke)
}
#[test]
fn gaps_between_letters_keep_their_own_tone_in_the_base() {
// At the size of a bus number in a 7000 px photo, a median at the detail radius filled the
// gaps between the digits and around their waists with the letters' tone: 360 panel pixels
// sat over 1 EV (up to 2.3 EV) above their base, so Shadows lifted them by up to 1.3 EV into
// bright blocks. The wider median sees the panel there, so the gaps keep their own tone.
let ppl = 6960.0;
let r = local::base_detail_radius(ppl);
assert_eq!(r, 10);
let (l, stroke) = lettering(r);
let (w, h) = (l.width, l.height);
let base = local::tone_base(&l, 0.015 * ppl as f32, r, true);
// panel pixels at least 3 px from a stroke (the guided filter's own edge softness aside)
let panel = |x: usize, y: usize| !(x.saturating_sub(2)..(x + 3).min(w)).any(|a| (y.saturating_sub(2)..(y + 3).min(h)).any(|b| stroke(a, b)));
let (mut worst, mut under) = (0.0f32, 0usize);
for y in 0..h {
for x in (0..w).filter(|&x| panel(x, y)) {
let d = l.get(x, y) - base.get(x, y);
worst = worst.max(d);
under += usize::from(d > 1.0);
}
}
assert!(under == 0 && worst < 1.0, "{under} panel px over 1 EV above their base; worst {worst} EV");
}
#[test]
fn isolated_lines_up_to_the_detail_radius_stay_out_of_the_base() {
// Light lines 1 to 9 px wide on a dark ground (twigs against a dark wall, r = 10): the base
// keeps the ground's tone under each, as under the median (#632), so Shadows lifts them with
// the ground and they keep their contrast. Clamping to the window's quartiles instead let lines
// of 6 px and more a third to two thirds of the way into the base.
let ppl = 6960.0;
let r = local::base_detail_radius(ppl);
let widths = [1usize, 2, 3, 4, 5, 6, 7, 8, 9];
let l = Plane::from_fn(1300, 200, |x, _| {
let (i, off) = (x / 130, x % 130);
if i < widths.len() && (60..60 + widths[i]).contains(&off) { -2.0 } else { -6.0 }
});
let base = local::tone_base(&l, 0.015 * ppl as f32, r, true);
for (i, wd) in widths.iter().enumerate() {
let leak = (base.get(i * 130 + 60 + wd / 2, 100) + 6.0) / 4.0;
assert!(leak.abs() < 0.05, "a {wd} px line is {leak} of the way into the base");
}
}
#[test]
fn only_process_v8_on_takes_the_detail_free_base() {
// V2 to V7 keep the plain median (V1 has none): they are released and frozen (`tests_legacy`)
for p in Process::ALL {
let s = DevelopSettings { process: p.version(), ..Default::default() };
assert_eq!(local::base_keeps_gaps(&s), p >= Process::V8, "{p:?}");
}
let (l, _) = lettering(10);
let plain = local::guided_fast(&local::median_hv(&l, 10), 100.0, local::BASE_EPS);
assert_eq!(local::tone_base(&l, 100.0, 10, false).data, plain.data);
assert_ne!(local::tone_base(&l, 100.0, 10, true).data, plain.data);
}
#[test]
fn detail_free_keeps_steps_and_regions() {
// a step between two flat regions, and a flat field, come out as they went in
let step = Plane::from_fn(90, 60, |x, _| if x < 45 { -4.0 } else { 0.5 });
assert_eq!(local::detail_free(&step, 6).data, step.data);
let flat = Plane::from_fn(31, 17, |_, _| -1.25);
assert_eq!(local::detail_free(&flat, 3).data, flat.data);
assert_eq!(local::detail_free(&step, 0).data, step.data);
// tiny and empty planes, and NaNs, are not a panic
for (w, h) in [(1, 1), (2, 3), (5, 1)] {
let p = Plane::from_fn(w, h, |x, y| if (x + y) % 2 == 0 { f32::NAN } else { 1.0 });
assert_eq!(local::detail_free(&p, 4).data.len(), w * h);
}
assert!(local::detail_free(&Plane::new(0, 0), 4).data.is_empty());
}
#[test]
fn v1_keeps_the_base_it_always_had() {
// V1's base is the edge-aware filter of log luminance itself, as before process V2: no median
@@ -225,7 +317,7 @@ fn v1_keeps_the_base_it_always_had() {
let l =
Plane::from_fn(301, 157, |x, y| (((x * 31 + y * 17) * 2_654_435_761usize) % 1000) as f32 / 250.0 - 2.0 + if x % 5 == 0 { 0.5 } else { 0.0 });
for sg in [1.0f32, 4.5, 18.0] {
assert_eq!(local::tone_base(&l, sg, 0).data, local::guided_fast(&l, sg, local::BASE_EPS).data, "sigma {sg}");
assert_eq!(local::tone_base(&l, sg, 0, true).data, local::guided_fast(&l, sg, local::BASE_EPS).data, "sigma {sg}");
}
// V2 takes the median wherever the image is big enough for one
let v2 = with("light.highlights", -100.0);
+2 -2
View File
@@ -181,8 +181,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on, sliding open and shut with the histogram's layout change); see Q. HDR |
| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | Alt/Option-drag shows highlight clipping (black, clipped channels in colour) |
| LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | 🟡 | `ctl:light.contrast` | From process V4, global Contrast on the camera-look path is fitted per channel in ProPhoto with the sRGB transfer. Check response error 0.136 to 0.038 stops on six held-out Sony photos. Other sources and local adjustments retain V2; broader parity remains unverified (`docs/light-v3.md`). |
| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp. Alt/Option-drag shows highlight clipping |
| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632). Alt/Option-drag shows shadow clipping (white, clipped channels in colour) |
| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp; from process V8 (`local::detail_free`: clamped between the median at that radius and one 3× wider) lettering keeps clean gaps (the median alone turned them into blocks of the letters' tone). Alt/Option-drag shows highlight clipping |
| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632); from process V8, the gaps in dark lettering on a bright panel are not lifted into bright blocks. Alt/Option-drag shows shadow clipping (white, clipped channels in colour) |
| LR-EDIT-LIGHT-WHITES | Whites | P0 | 🟡 | `ctl:light.whites`, `crates/pipeline/src/visualize.rs` | V4 preserves V2 Whites for both signs. Calibration is a planned follow-up; measured V2 check response error is 0.075 stops on the Sony test photos (`docs/light-v3.md`). Alt/Option-drag shows highlight clipping (black, clipped channels in colour) |
| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | 🟡 | `ctl:light.blacks`, `crates/pipeline/src/visualize.rs` | From process V4, global Blacks on the camera-look path uses a fitted per-channel/luminance mix. Check response error 0.424 to 0.077 stops on six held-out Sony photos; deepest tones still differ. Other sources and local adjustments retain V2 (`docs/light-v3.md`). Alt/Option-drag shows shadow clipping (white, clipped channels in colour) |
| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*`, `crates/pipeline/src/curve.rs` | the graph draws the renderer's own curve model; dragging the line moves the region under the pointer (one undo step) |
+17 -1
View File
@@ -15,7 +15,8 @@ stronger Light sliders, issue #196) without changing a single photo someone has
| **V4** | V3 plus calibrated global Contrast (per channel) and Blacks (per-channel/luminance mix) on raws with a camera tone curve. Whites behaves exactly as V2, both signs; its calibration is a planned follow-up. Other sources and local adjustments retain V2 operators. See [calibrated Light operators](light-v3.md). |
| **V5** | V4 plus: a raw's tone curve rolls off into white in stops instead of reaching it within about a stop of the sensor's white, so raising Exposure keeps the highlights the sensor recorded (issue #1632). **Camera curve** (the look fitted to the embedded JPEG, or a DNG's `ProfileToneCurve`; `CameraTone`): past its last knot `CameraTone::apply_rolloff` closes the gap to white by `gap / (1 + c·(2^t − 1))` at `t` stops past the knot, with `c` matching the curve's slope there and the gap halving every stop in the end (`tone::ROLLOFF_STOPS`); `ToneMap::camera_rolloff_hdr` builds the table from it. Under V1 to V4 the curve past its last knot (fitted only to camera JPEG tones below 0.85, so 1 to 3 stops below the sensor's white on most photos) closes on white exponentially in linear light: the sensor's white renders at 8-bit 246 to 254 at Exposure 0 and 254 to 255 from Exposure +1. On the 11 CC0 corpus files with a fitted look (8 Sony, 2 Nikon, 1 Fujifilm) it now renders at 239 to 250 at Exposure 0 and 247 to 253 at +1. A DNG curve that reaches white at the sensor's white renders as before. **Generic curve** (raws without a camera curve, `ToneMap::rolloff_hdr`): from half a stop below its white point (`tone::GENERIC_KNEE_STOPS`) the same shoulder takes over, with the extended Reinhard curve's value and slope at the join, where V1 to V4 clip at the white point (scene 1.18 at default settings, ¼ stop above the sensor's white). The sensor's white renders at about 235, 248 and 252 at Exposure 0, +1 and +2 (V1 to V4: 239, 255, 255; the CC0 Canon R8 file and 6 CC0 DNGs without a camera curve). Content past the old clip point, such as a clipped sky, now renders up to 15 codes below white at Exposure 0 and reaches 255 about 5 stops past the sensor's white. Below the last knot or the join, JPEGs and other display-referred sources, and the camera fit itself are unchanged; CPU and GPU share the tone LUT. |
| **V6** | V5 plus: colour noise reduction filters the linear colour differences R−Y and B−Y with the joint method (issue #815, `docs/nr-color-joint.md`): edge-aware weights from a denoised guide shared by both differences, anisotropic diffusion over scales, a brake for isolated colour specks; Y is kept exactly. V1 to V5 keep the blurred chromaticity (`local::denoise`). CPU (`local::denoise`) and GPU (`render::denoise`) branch on `local::joint_colour_nr`. Renders with Color 0 are the same as V2's. |
| **V7** (the latest) | V6 plus: film grain from crystal layers ([grain.md](grain.md), `crates/pipeline/src/crystal.rs`), after Aurélien Pierre's model: resolution independent (a preview is the export's grain box-downscaled), fading out at black and white, calibrated to Lightroom Classic's strength and width at mid grey. V1 to V6 keep their two-octave value noise exactly (`finish::ValueGrain`; on the GPU `F_GRAIN_MODEL` 0); `finish::GrainModel::new` picks the model per process. Renders without grain don't depend on this. |
| **V7** | V6 plus: film grain from crystal layers ([grain.md](grain.md), `crates/pipeline/src/crystal.rs`), after Aurélien Pierre's model: resolution independent (a preview is the export's grain box-downscaled), fading out at black and white, calibrated to Lightroom Classic's strength and width at mid grey. V1 to V6 keep their two-octave value noise exactly (`finish::ValueGrain`; on the GPU `F_GRAIN_MODEL` 0); `finish::GrainModel::new` picks the model per process. Renders without grain don't depend on this. |
| **V8** (the latest) | V7 plus: Highlights and Shadows take their edge-aware filter of log luminance without its fine detail (`local::detail_free`: each value clamped between its separable median at the V2 radius and the median of a 3× wider neighbourhood) instead of V2's plain median (`local::base_keeps_gaps`), so the gaps in dark lettering on a bright panel are not lifted into bright blocks (issue #3460). CPU (`local::tone_base`) and GPU (`render::tone_base`) branch on it; the base plane is keyed by process. Renders without Highlights or Shadows (global or local) are the same as V7's. |
## The rule
@@ -47,6 +48,20 @@ only what was added, and with the mask's Invert it gave the whole photo. Such ma
first component, on every process. It landed in place as a bug fix: intersecting with nothing is not
a look anyone chose.
**Lettering under Highlights and Shadows** (V8) no longer grows blocks of the wrong tone. V2's base
is the edge-aware filter of a separable median alone. Dark lettering on a bright panel (a bus
number, a sign) has strokes and gaps about as wide as the median's radius, so each row and column
window was close to half letter and half panel, and the median picked one tone or the other in
rectangles: it filled the gaps between the letters and their counters with the letters' tone.
Shadows then lifted those patches of panel by over 1 EV, into bright blocks beside the letters. From V8 each
value moves toward that median only as far as the median of a 3× wider neighbourhood agrees
(`local::detail_free`). A twig against a wall has both medians on the wall's side and takes its tone,
as before (#632); the gap between two strokes sits in a wider neighbourhood that is mostly panel, the
two medians disagree, and the gap keeps its own tone. The letters themselves count as fine dark detail
on the panel and keep their contrast, as twigs do. V8 differs from V7 under Highlights or Shadows only in detail
about as fine as the radius, mostly where it is packed closely (lettering, grilles). V2 to V7
are released and keep the blocks (`tests_legacy` locks V1 and V2); Update to Current Process moves a photo past them.
## Adding a process
1. Add a variant to `Process` (`V2`), append it to `Process::ALL` (oldest first), give it its number in
@@ -111,6 +126,7 @@ extra field and retain their existing cached renders. No cache-version bump is n
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | V8: the Highlights/Shadows base keeps the gaps in dark lettering (#3460) |
| 2026-11-07 | minor | V7: film grain from crystal layers ([grain.md](grain.md)); V1 to V6 keep their value noise |
| 2026-11-05 | minor | V5: a raw's tone curve rolls off into white in stops, so raising Exposure keeps recorded highlights (#1632) |
| 2026-10-12 | minor | Calibrated Contrast and Blacks, V2 Whites, resolved-process cache keys and number coordination |