From 556c95c7309f86338d46353118d64929f34b4c23 Mon Sep 17 00:00:00 2001 From: strebdom Date: Fri, 23 Oct 2026 15:09:58 +0200 Subject: [PATCH 1/3] LR-IMP-FORMATS: AHD blends its candidates where noise alone would pick one, no maze in noisy raws (#2094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AHD chose the horizontal or the vertical candidate outright by the larger 3×3 homogeneity sum. In a flat, noisy area noise alone decides which is larger, so the picture became short horizontal and vertical strokes (a maze, orange and blue under strong white balance) that noise reduction then made stand out. It now blends the two linearly while their sums are within 12 of each other (of 36), and takes one alone beyond that. On an ISO 4000 ILCE-7CM2 ARW under tungsten light the maze measure of a flat region drops ~30 %; PSNR on the synthetic smooth and edge scenes is unchanged or better (73.0 → 73.7 dB, 37.7 → 37.9 dB). The colour part of the pattern mostly comes from demosaicing before white balance (#1328, PR #1463), which this does not change. New test flat_noise_does_not_turn_into_a_maze fails on the outright pick. RENDER_CACHE_VERSION 70. --- crates/engine/src/media.rs | 2 +- crates/raw/src/demosaic/ahd.rs | 17 ++++++------ crates/raw/src/demosaic/mod.rs | 47 ++++++++++++++++++++++++++++++++++ docs/raw-parity.md | 5 ++-- docs/whats-new.md | 9 +++++++ 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 292a9fb1e..e76ef8169 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 69; +pub const RENDER_CACHE_VERSION: u64 = 70; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index d9fc27d76..622a19ce9 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -6,7 +6,8 @@ //! 2. Complete each candidate with red/blue by colour-difference (C − G) interpolation. //! 3. Convert both candidates to CIELab and measure, per pixel, how many of its 4 neighbours lie within the //! adaptive luminance / chrominance tolerances (ε_L, ε_C from the paper) — the homogeneity. -//! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate (average on ties). +//! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate. The paper picks one outright; +//! we blend the two by how far apart their sums are (see [`BLEND`]), so a clear edge still takes one candidate. //! //! Work is done in overlapping tiles (bounded memory, rayon-parallel), one band of tiles at a time written //! straight into the output. Each step runs only where the tile's output depends on it: the choice reads the @@ -19,6 +20,11 @@ use rayon::prelude::*; const TILE: usize = 128; const MARGIN: usize = 6; +/// Difference of the two candidates' 3×3 homogeneity sums (each 0–36) from which the more homogeneous one is taken +/// alone; closer sums blend the two linearly, equal sums half and half. In a flat, noisy area noise alone decides +/// which sum is larger, and picking outright turned the noise into short horizontal and vertical strokes (a maze, +/// coloured where white balance is strong). 12 keeps the PSNR of the outright pick on the edge tests. +const BLEND: f32 = 12.0; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { let (w, h) = (m.w, m.h); @@ -152,13 +158,8 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]> sh += hh[j] as u32; sv += hv[j] as u32; } - out.push(if sh > sv { - rh[i] - } else if sv > sh { - rv[i] - } else { - [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] - }); + let wh = (0.5 + (sh as f32 - sv as f32) / (2.0 * BLEND)).clamp(0.0, 1.0); + out.push([0, 1, 2].map(|c| rh[i][c] * wh + rv[i][c] * (1.0 - wh))); } out } diff --git a/crates/raw/src/demosaic/mod.rs b/crates/raw/src/demosaic/mod.rs index b8a3c599a..b99f7b015 100644 --- a/crates/raw/src/demosaic/mod.rs +++ b/crates/raw/src/demosaic/mod.rs @@ -226,6 +226,53 @@ mod tests { assert!(xp > 20.0 && xp >= xb - 0.5, "xtrans directional {xp:.1} bilinear {xb:.1}"); } + /// Mean anisotropy of a demosaiced picture's fine luminance detail: per pixel, how much more its 3 × 3 high-pass + /// correlates with its horizontal neighbours than with its vertical ones (or the reverse). Noise has none; the + /// short horizontal and vertical strokes of a maze pattern have a lot. + fn anisotropy(img: &Rgb32f) -> f64 { + let (w, h) = (img.width, img.height); + let lum: Vec = img.data.iter().map(|p| 0.25 * p[0] + 0.5 * p[1] + 0.25 * p[2]).collect(); + let mean3 = |x: usize, y: usize| (0..9).map(|k| lum[(y + k / 3 - 1) * w + x + k % 3 - 1]).sum::() / 9.0; + let hp: Vec = + (0..w * h).map(|i| if i % w == 0 || i % w == w - 1 || i < w || i >= w * (h - 1) { 0.0 } else { lum[i] - mean3(i % w, i / w) }).collect(); + let (mut sum, mut n) = (0f64, 0f64); + for y in 2..h - 2 { + for x in 2..w - 2 { + let i = y * w + x; + sum += f64::from((hp[i] * (hp[i - 1] + hp[i + 1] - hp[i - w] - hp[i + w])).abs()); + n += 1.0; + } + } + sum / n + } + + #[test] + fn flat_noise_does_not_turn_into_a_maze() { + // a flat grey patch with sensor-like noise: AHD picked horizontal or vertical candidates per pixel by a + // homogeneity count that noise alone decides, so the picture became short horizontal and vertical strokes + let (w, h) = (192, 160); + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut noise = || { + // sum of four uniforms ≈ Gaussian, σ ≈ 0.02 + (0..4) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 40) as f32 / (1u64 << 24) as f32 - 0.5 + }) + .sum::() + * 0.035 + }; + let cfa = Cfa::bayer("RGGB").unwrap(); + let data = (0..w * h).map(|_| 0.2 + noise()).collect(); + let n = Normalized { width: w, height: h, cpp: 1, data, cfa: Some(cfa) }; + let (ahd, bilinear) = (anisotropy(&demosaic(&n, Method::Ahd)), anisotropy(&demosaic(&n, Method::Bilinear))); + eprintln!("anisotropy: ahd {ahd:.3e}, bilinear {bilinear:.3e}"); + // the hard choice gave about 11 times bilinear's, the blend about 7.5 times + assert!(ahd < 9.0 * bilinear, "ahd {ahd:.3e} bilinear {bilinear:.3e}"); + } + #[test] fn tiny_and_degenerate_inputs() { let cfa = Cfa::bayer("RGGB").unwrap(); diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2923ea880..3151255d4 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (AHD no longer turns flat noise into a maze pattern) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -194,7 +194,7 @@ sample files. A pointer for contributors, not legal advice. | Stage | Target | LightCraft | Status | |---|---|---|---| -| Bayer demosaic | Adobe's own, plus AI "Raw Details" | AHD (default), PPG, bilinear (`crates/raw/src/demosaic`) | 🟡 quality unmeasured against Lightroom; no Raw Details | +| Bayer demosaic | Adobe's own, plus AI "Raw Details" | AHD (default; blends its horizontal and vertical candidates where neither is clearly more homogeneous, so flat noise doesn't become a maze), PPG, bilinear (`crates/raw/src/demosaic`) | 🟡 quality unmeasured against Lightroom; demosaics before white balance (#1328, PR #1463); no Raw Details | | X-Trans demosaic | ✅ | in-house edge-weighted directional | 🟡 unmeasured | | Foveon | ✅ (older bodies) | — | ⬜ | | Monochrome / LinearRaw | ✅ | handled, no demosaic | ✅ | @@ -328,3 +328,4 @@ Beyond decoding: | 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) | | 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again | | 2026-10-18 | minor | Minolta MRW decoded (`vendor/mrw.rs`): DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified; 551 verified, 656 unverified, 201 preview (re-measured with `coverage.py`) | +| 2026-10-23 | minor | AHD blends its horizontal and vertical candidates where their homogeneity is close, so flat noise no longer becomes a maze pattern; Bayer demosaic row notes the pre-white-balance demosaic (#1328); scores unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index cffff83e2..18470577c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,14 @@ # What's new in LightCraft +## 2026-10-23 + +### No maze pattern in noisy raws +- Flat areas of high-ISO raws, such as a dim wall or a blurred background, could show short horizontal and vertical + strokes instead of grain, often orange and blue under warm light. Noise reduction made them stand out more, because + it smooths the grain around them. The demosaic chose between a horizontal and a vertical guess at each pixel, and + in a flat area the noise alone decided which. Where neither guess is clearly better it now blends the two, so the + noise looks like grain. Edges are as sharp as before. Thumbnails and previews of raws are redrawn. + ## 2026-10-21 ### Sharper resampling in HDR and panorama merges From 89424084b222e7f9260f7a348314d0428d0fedb4 Mon Sep 17 00:00:00 2001 From: strebdom Date: Fri, 23 Oct 2026 15:25:55 +0200 Subject: [PATCH 2/3] LR-IMP-FORMATS: AHD's blend leaves out a candidate that isn't finite (#2698 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outright pick never let the losing candidate's NaN (beside a float raw's NaN sample) into the output; the blend did, as 0 · NaN. A candidate taken alone is now copied, and one that isn't finite is left out while the other is. A NaN sample reaches as many pixels as before the blend (9 in the new test; the blend made it 33). --- crates/raw/src/demosaic/ahd.rs | 11 ++++++++++- crates/raw/src/demosaic/mod.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index 622a19ce9..c4180de4d 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -159,7 +159,16 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]> sv += hv[j] as u32; } let wh = (0.5 + (sh as f32 - sv as f32) / (2.0 * BLEND)).clamp(0.0, 1.0); - out.push([0, 1, 2].map(|c| rh[i][c] * wh + rv[i][c] * (1.0 - wh))); + // a candidate taken alone is copied, and one that isn't finite (beside a float raw's NaN sample) is left out, + // so the other's value doesn't become 0 · NaN + let finite = |p: &[f32; 3]| p.iter().all(|v| v.is_finite()); + out.push(if wh >= 1.0 || !finite(&rv[i]) { + rh[i] + } else if wh <= 0.0 || !finite(&rh[i]) { + rv[i] + } else { + [0, 1, 2].map(|c| rh[i][c] * wh + rv[i][c] * (1.0 - wh)) + }); } out } diff --git a/crates/raw/src/demosaic/mod.rs b/crates/raw/src/demosaic/mod.rs index b99f7b015..07d45b6de 100644 --- a/crates/raw/src/demosaic/mod.rs +++ b/crates/raw/src/demosaic/mod.rs @@ -290,6 +290,20 @@ mod tests { assert_eq!(demosaic(&n, Method::Ahd).data, vec![[0.1, 0.2, 0.3]]); } + #[test] + fn a_nan_sample_spreads_no_further_than_the_chosen_candidate_takes_it() { + // vertical stripes: the vertical candidate wins outright beside them, and the horizontal one, which reads two + // samples to each side, is the only one that sees a NaN two columns away + let img = Rgb32f::from_fn(32, 24, |x, _| if (x / 2) % 2 == 0 { [0.2, 0.3, 0.25] } else { [0.7, 0.8, 0.6] }); + let cfa = Cfa::bayer("RGGB").unwrap(); + let mut n = mosaic_from_rgb(&img, &cfa); + n.data[12 * 32 + 16] = f32::NAN; + let out = demosaic(&n, Method::Ahd); + let bad = out.data.iter().filter(|p| p.iter().any(|v| !v.is_finite())).count(); + // the pixels that read the NaN sample themselves, as many as the outright pick gave; the blend made it 33 + assert!(bad <= 9, "{bad} pixels not finite"); + } + #[test] fn non_finite_samples_do_not_panic() { // regression: NaN samples (a float DNG) gave AHD and PPG NaN bounds for `f32::clamp`, which panics From 3f873bbad060f772c24ef9c357a24b7a493ebca0 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sat, 24 Oct 2026 12:43:59 +0200 Subject: [PATCH 3/3] docs(whats-new): only LightCraft's own raw renders are redrawn, not the camera's embedded preview --- docs/whats-new.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index b4ec250bd..cf145c1f7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,7 +7,8 @@ strokes instead of grain, often orange and blue under warm light. Noise reduction made them stand out more, because it smooths the grain around them. The demosaic chose between a horizontal and a vertical guess at each pixel, and in a flat area the noise alone decided which. Where neither guess is clearly better it now blends the two, so the - noise looks like grain. Edges are as sharp as before. Thumbnails and previews of raws are redrawn. + noise looks like grain. Edges are as sharp as before. Thumbnails and previews that LightCraft rendered from a raw + are redrawn; the camera's own embedded preview, shown for an unedited raw until its render is ready, is unchanged. ### Tab leaves the name field in New Album, New Smart Album and the name prompts - In New Album, New Smart Album, Save Metadata Preset and the other dialogs with one name field, the field has the