diff --git a/crates/raw/src/demosaic.rs b/crates/raw/src/demosaic.rs index 75f7674ac..245586f39 100644 --- a/crates/raw/src/demosaic.rs +++ b/crates/raw/src/demosaic.rs @@ -260,6 +260,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(); @@ -277,6 +324,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 diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index 6afcd9251..9a1d477e8 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 { ahd_window(m, 0, 0, m.w, m.h) @@ -157,12 +163,16 @@ 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 { + let wh = (0.5 + (sh as f32 - sv as f32) / (2.0 * BLEND)).clamp(0.0, 1.0); + // 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 sv > sh { + } else if wh <= 0.0 || !finite(&rh[i]) { 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] + [0, 1, 2].map(|c| rh[i][c] * wh + rv[i][c] * (1.0 - wh)) }); } out diff --git a/docs/raw-parity.md b/docs/raw-parity.md index bd17a3d32..d9372eeba 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 8e3e93adb..c56e22495 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -53,6 +53,13 @@ ### The histogram stays in view - The histogram stays at the top of the Edit panel while its sliders scroll under it (issue #2679). It used to scroll away with them, so it was out of sight while using the sliders further down the panel. +### 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 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