Merge PR #2698: LR-IMP-FORMATS: AHD blends its candidates where noise alone would pick one, no maze in noisy raws (#2094)

docs/whats-new.md: both entries kept
This commit is contained in:
aiongg
2026-10-31 02:10:17 +00:00
4 changed files with 85 additions and 6 deletions
+61
View File
@@ -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<f32> = 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::<f32>() / 9.0;
let hp: Vec<f32> =
(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::<f32>()
* 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
+14 -4
View File
@@ -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
+3 -2
View File
@@ -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 |
+7
View File
@@ -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