mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user