From ac9311891195923c0579ae00abaffe50aef5da02 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 18:13:20 -0400 Subject: [PATCH] LR-EDIT-LIGHT-HIGHLIGHTS: partly clipped highlights take the colour of bright neighbours, binned previews clip before averaging (#548) Highlight reconstruction rebuilds clipped channels from the chromaticity of unclipped pixels nearby. Three changes to that colour: - an unclipped pixel counts by its brightness ((m / 0.8 clip)^4, at most 1), filled coarse to fine by push-pull, so dark wires across a bright cloud no longer tint it (the iPhone frame of #548 turned cyan at Highlights -100); - pixels within 6 px of clipping (partly demosaiced from clipped samples, 25 % greener than the sky on that frame) give no colour unless nothing else does; - the rebuilt colour is drawn toward neutral at its brightest channel as more channels clip ((n - 1 + s) / 2, s rising as the next channel nears its clip). Binned previews keep each channel the mean of its block and mark blocks with a clipped sample in a mask (develop_binned_masked, reconstruct_masked), instead of taking the block maximum: one clipped specular sample no longer becomes a green speckle at 500 px (HUAWEI PRA-LX1, WAS-LX1A). The denoised twin uses the same mask. Camera-colour fitting keeps the old binning. RENDER_CACHE_VERSION 26 (adjust at merge). --- crates/engine/src/files.rs | 52 +++- crates/engine/src/media.rs | 2 +- crates/raw/src/binned.rs | 72 ++++- crates/raw/src/highlight.rs | 568 ++++++++++++++++++++++++------------ crates/raw/src/lib.rs | 3 +- docs/parity.md | 2 +- 6 files changed, 495 insertions(+), 204 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index db762f00..512c31d3 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -237,8 +237,9 @@ pub fn load_vec_pair(bytes: Vec, max_edge: usize, spec: &DenoiseSpec) -> Res } /// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default -/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned. `None` when the product -/// is missing, damaged, made from something else or not the size of this raw's active area. +/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned (plain means: the +/// plain development's clip mask tells highlight reconstruction which of them held clipped samples). `None` when +/// the product is missing, damaged, made from something else or not the size of this raw's active area. fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option { use lightcraft_denoise::product::{self, Window}; let a = raw.active_area; @@ -249,7 +250,7 @@ fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, facto if raw.opcodes.list3.is_empty() { let c = raw.develop_crop(a.width, a.height); let window = Window { x: c.x, y: c.y, width: c.width, height: c.height }; - product::read_window(&spec.product, &spec.key, Some(window), factor, Some(HIGHLIGHT_CLIP)).ok() + product::read_window(&spec.product, &spec.key, Some(window), factor, None).ok() } else { // (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1) let whole = product::read(&spec.product, &spec.key, 1).ok()?; @@ -286,16 +287,17 @@ fn load_bytes_now( // larger levels (exports, 1:1) demosaic the whole sensor. let t0 = web_time::Instant::now(); let bin = bin_factor(&raw, max_edge); + // (binned: block means, and which colours had a clipped sample in each block) let binned = match bin { - Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, + Some(k) => raw.develop_binned_masked(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, None => None, }; let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 }; - let img = match binned { - Some(img) => img, + let (img, clip_mask) = match binned { + Some((img, mask)) => (img, Some(mask)), None => { let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - raw.develop(method).map_err(|e| e.to_string())? + (raw.develop(method).map_err(|e| e.to_string())?, None) } }; // the same development from the denoised mosaic, when the photo has been denoised @@ -313,7 +315,10 @@ fn load_bytes_now( let finish = |mut img: Rgb32f| { let mut stages = vec![("develop", t0.elapsed())]; stages.push(("transform", t0.elapsed())); - lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP); + match &clip_mask { + Some(mask) => lightcraft_raw::highlight::reconstruct_masked(&mut img, wb, HIGHLIGHT_CLIP, mask), + None => lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP), + }; stages.push(("highlights", t0.elapsed())); img.map_in_place(|p| { let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; @@ -621,6 +626,37 @@ mod tests { assert!(probe_bytes("x.jpg", b"not an image").is_err()); } + /// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample + /// of a block on a grey surface must not become green speckles (the block's green used to take the + /// clip level, which highlight reconstruction then kept). + #[test] + fn coarse_binning_keeps_specular_points_neutral() { + let (w, h) = (256usize, 192usize); + // grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right + let neutral = [0.5f32, 1.0, 0.7]; + let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap(); + let mut data: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize]; + (256.0 + v * (16000.0 - 256.0)).round() as u16 + }) + .collect(); + // one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block + for by in 0..h / 8 { + for bx in (by % 2..w / 8).step_by(2) { + data[(by * 8 + 4) * w + bx * 8 + 5] = 16000; + } + } + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(bin_factor(&raw, 32), Some(8)); + let (img, _) = load_bytes(&bytes, 32).unwrap(); + assert_eq!((img.width, img.height), (32, 24)); + let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max); + assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels"); + } + #[test] fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() { use lightcraft_denoise::product; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b3f25df5..81e900ae 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 = 25; +pub const RENDER_CACHE_VERSION: u64 = 26; /// 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/binned.rs b/crates/raw/src/binned.rs index fde6c2c0..1be08089 100644 --- a/crates/raw/src/binned.rs +++ b/crates/raw/src/binned.rs @@ -8,9 +8,13 @@ //! interpolation, so there are no demosaicing artefacts; the colour planes are offset by less than //! one output pixel, which is invisible at these scales. //! -//! Clipping is preserved for [`crate::highlight::reconstruct`]: a channel whose block contains a -//! sample at or above `clip` takes that block's maximum sample of the colour (instead of a mean that -//! would fall just below the clip level and escape reconstruction). +//! Clipping is decided per sample, before averaging. [`RawImage::develop_binned_masked`] keeps every +//! channel the mean of its samples and says, per output pixel, which colours had a sample at or above +//! `clip` (for [`crate::highlight::reconstruct_masked`], which rebuilds them with the mean as a lower +//! bound). A specular point that clips one green sample of the 32 in an 8 × 8 block thus counts as +//! partly clipped without the block's green jumping to the clip level, which would read as a green +//! speckle once reconstructed. [`RawImage::develop_binned`] instead gives such a channel the block's +//! maximum sample of the colour, so that plain value thresholds see it as clipped. use crate::{Normalized, RawData, RawImage, Result, Rgb32f}; use rayon::prelude::*; @@ -31,6 +35,19 @@ impl RawImage { /// not oriented). `None` when the data cannot be binned ([`RawImage::can_bin`]) or carries an /// `OpcodeList3` (whose operations are defined at full resolution); callers then demosaic. pub fn develop_binned(&self, k: usize, clip: f32) -> Result> { + Ok(self.bin(k, clip, true)?.map(|(img, _)| img)) + } + + /// Like [`RawImage::develop_binned`], with every channel the mean of its block's samples, and the + /// clip mask: per output pixel, bits [`crate::highlight::CLIPPED_R`] / `_G` / `_B` for the colours + /// with a sample at or above `clip` in the block. Hand both to [`crate::highlight::reconstruct_masked`]. + pub fn develop_binned_masked(&self, k: usize, clip: f32) -> Result)>> { + self.bin(k, clip, false) + } + + /// The binned image and its clip mask; `keep_max`: a channel with a clipped sample takes the + /// block's maximum sample of the colour instead of the mean. + fn bin(&self, k: usize, clip: f32, keep_max: bool) -> Result)>> { self.validate()?; if !self.can_bin(k) || !self.opcodes.list3.is_empty() { return Ok(None); @@ -63,7 +80,8 @@ impl RawImage { .collect(); let wlen = bw * k; let mut out = Rgb32f::new(bw, bh); - out.data.par_chunks_mut(bw).enumerate().for_each(|(by, row)| { + let mut mask = vec![0u8; bw * bh]; + out.data.par_chunks_mut(bw).zip(mask.par_chunks_mut(bw)).enumerate().for_each(|(by, (row, mrow))| { // the block row's k sample rows, normalised let mut rows = vec![0f32; k * wlen]; for dy in 0..k { @@ -86,7 +104,7 @@ impl RawImage { } } let py = (c.y + by * k) % ph; - for (bx, px) in row.iter_mut().enumerate() { + for (bx, (px, m)) in row.iter_mut().zip(mrow.iter_mut()).enumerate() { let (layout, inv) = &layouts[py * pw + (c.x + bx * k) % pw]; let (mut sum, mut max) = ([0f32; 3], [f32::MIN; 3]); for dy in 0..k { @@ -98,11 +116,13 @@ impl RawImage { } } for ch in 0..3 { - px[ch] = if max[ch] >= clip { max[ch] } else { sum[ch] * inv[ch] }; + let clipped = max[ch] >= clip; + px[ch] = if clipped && keep_max { max[ch] } else { sum[ch] * inv[ch] }; + *m |= u8::from(clipped) << ch; } } }); - Ok(Some(out)) + Ok(Some((out, mask))) } } @@ -177,6 +197,44 @@ mod tests { assert!((xb.get(4, 3)[2] - 0.3).abs() < 2e-3); } + /// Issue #548: specular points that clip one green sample in some 8 × 8 blocks of a grey surface. + /// Taking the block's maximum makes those blocks' green the clip level, which reconstruction then + /// keeps (a green speckle); the masked binning keeps the mean and marks the green as clipped. + #[test] + fn clipping_is_decided_before_averaging() { + let img = Rgb32f::filled(64, 64, [0.3, 0.3, 0.3]); + let mut raw = raw_from(&mosaic_from_rgb(&img, &Cfa::bayer("RGGB").unwrap()), 0.0, 4095.0); + if let RawData::U16(d) = &mut raw.data { + // one green sample (RGGB: odd column of an even row) in every other block + for by in 0..8 { + for bx in (by % 2..8).step_by(2) { + d[(by * 8 + 2) * 64 + bx * 8 + 3] = 4095; + } + } + } + let wb = [1.0f32; 3]; + let (mut masked, mask) = raw.develop_binned_masked(8, 0.99).unwrap().unwrap(); + assert_eq!(mask.len(), 64); + assert_eq!(mask.iter().filter(|&&m| m == highlight::CLIPPED_G).count(), 32); + assert!(mask.iter().all(|&m| m & (highlight::CLIPPED_R | highlight::CLIPPED_B) == 0)); + // the clipped block's green is its mean: (31 · 0.3 + 1) / 32 + assert!((masked.get(0, 0)[1] - (31.0 * 0.3 + 1.0) / 32.0).abs() < 1e-3, "{:?}", masked.get(0, 0)); + assert_eq!(highlight::reconstruct_masked(&mut masked, wb, 0.99, &mask), 32); + let green = |img: &Rgb32f| img.data.iter().map(|p| p[1] / p[0]).fold(0.0f32, f32::max); + // (no more than the mean itself: the clipped sample counts at the clip level) + assert!(green(&masked) < 1.08, "green excess {}", green(&masked)); + // the maximum rule: a green speckle at the clip level + let mut max = raw.develop_binned(8, 0.99).unwrap().unwrap(); + assert!(max.get(0, 0)[1] >= 0.99); + highlight::reconstruct(&mut max, wb, 0.99); + assert!(green(&max) > 3.0, "{}", green(&max)); + // without clipped samples both are the same plain means, and the mask is empty + let clean = raw_from(&mosaic_from_rgb(&img, &Cfa::bayer("RGGB").unwrap()), 0.0, 4095.0); + let (a, m) = clean.develop_binned_masked(8, 0.99).unwrap().unwrap(); + assert!(m.iter().all(|&v| v == 0)); + assert!(a.data == clean.develop_binned(8, 0.99).unwrap().unwrap().data); + } + #[test] fn binning_matches_demosaic_downscaled_on_smooth_images() { let img = Rgb32f::from_fn(64, 48, |x, y| [0.2 + 0.3 * (x as f32 / 64.0), 0.4 - 0.2 * (y as f32 / 48.0), 0.25]); diff --git a/crates/raw/src/highlight.rs b/crates/raw/src/highlight.rs index 1731cb25..b7973366 100644 --- a/crates/raw/src/highlight.rs +++ b/crates/raw/src/highlight.rs @@ -4,7 +4,22 @@ //! magenta/cyan (each channel limited to the smallest white-balanced clip level). //! - [`reconstruct`]: where only some channels are clipped, rebuild them from the unclipped channels using the //! local chromaticity of nearby unclipped pixels (diffused into the clipped region with a coarse-to-fine -//! normalised-convolution fill). Fully clipped pixels become neutral at the brightest plausible level. +//! push-pull fill). Fully clipped pixels become neutral at the brightest plausible level. +//! +//! Issue #548 (a bright cloud turning cyan once Highlights pulled it down, green speckles in binned +//! previews) set three rules for the colour of partly clipped pixels: +//! - an unclipped pixel's chromaticity counts with a confidence that grows with its brightness +//! (`confidence`): the colour of a clipped highlight is told by the bright pixels around it, not by +//! dark ones in front of it (wires and branches across a cloud, with their own colour and demosaicing +//! fringes, were the nearest colour inside the cloud and tinted all of it). A dim pixel fills in only as +//! much as its confidence allows, the rest comes from the coarser level, i.e. from brighter pixels +//! further out; where there are none, dim pixels still decide, pooled over a larger area; +//! - pixels within [`DEMOSAIC_REACH`] of a clipped one were demosaiced partly from clipped samples and +//! give no colour (unless nothing else does); +//! - the more channels of a pixel clip, the nearer to neutral its rebuilt colour (`rebuild`). +//! +//! A binned image ([`crate::RawImage::develop_binned_masked`]) says which channels are clipped with a +//! mask instead of by value ([`reconstruct_masked`]). use lightcraft_raster::Rgb32f; use rayon::prelude::*; @@ -20,74 +35,87 @@ pub fn clip_neutral(img: &mut Rgb32f, wb: [f32; 3], clip: f32) { } } -/// Fill `values` (per-pixel vectors) where `valid` is false from valid neighbours, coarse to fine. -fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) { - if w == 0 || h == 0 || valid.iter().all(|&v| v) || !valid.iter().any(|&v| v) { +/// A chromaticity premultiplied by its confidence, and the confidence (0..=1): `[w·r, w·g, w·b, w]`. +/// `w = 0` is an empty entry (a clipped pixel, or nothing known yet). +type Weighted = [f32; 4]; + +/// Brightness (as a share of the clip level) from which an unclipped pixel's colour counts fully. +const FULL_CONFIDENCE: f32 = 0.8; + +/// How far an unclipped pixel's chromaticity is trusted to stand for a clipped highlight next to it: +/// `(m / (0.8 · clip))⁴` for its brightest channel `m`, at most 1 (from 80 % of the clip level up; +/// half of the clip level 0.15, a third of it 0.03, a tenth of it 0.0002). +#[inline] +fn confidence(p: &[f32; 3], clip: f32) -> f32 { + let m = (p[0].max(p[1]).max(p[2]) / (FULL_CONFIDENCE * clip)).clamp(0.0, 1.0); + let m2 = m * m; + m2 * m2 +} + +/// Fill `values` coarse to fine (push-pull): each entry keeps its own colour by its confidence `w` +/// and takes the rest, `1 − w`, from the next coarser level. With confidences of only 0 and 1 this is +/// a plain fill of the empty entries from the mean of the known ones nearby. +fn fill(w: usize, h: usize, values: &mut [Weighted]) { + if w * h <= 1 || values.iter().all(|v| v[3] >= 1.0) || !values.iter().any(|v| v[3] > 0.0) { return; } - // Build a pyramid of weighted means, fill each level from the next coarser one. let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let mut cv = vec![[0f32; 3]; cw * ch]; - let mut cval = vec![false; cw * ch]; + let mut cv = vec![[0f32; 4]; cw * ch]; { - let (values, valid) = (&*values, &*valid); - downsample(w, h, &mut cv, &mut cval, |i| valid[i].then(|| values[i])); + let values = &*values; + downsample(w, h, &mut cv, |i| values[i]); } - if cw * ch < w * h { - fill_invalid(cw, ch, &mut cv, &mut cval); - } else { - // cannot shrink further (1×1): nothing valid anywhere handled above - return; - } - values.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..w { - if !okrow[x] { - vrow[x] = upsample(&cv, cw, ch, x, y); - okrow[x] = true; + fill(cw, ch, &mut cv); + values.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let rest = 1.0 - v[3]; + if rest > 0.0 { + let u = upsample(&cv, cw, ch, x, y); + for c in 0..4 { + v[c] += rest * u[c]; + } } } }); } -/// One pyramid step: each `2 × 2` block of a `w × h` level becomes the mean of its valid -/// entries (`at(i)`: the value at index `i` when valid) in `cv`, `cval` (`⌈w/2⌉ × ⌈h/2⌉`). -fn downsample(w: usize, h: usize, cv: &mut [[f32; 3]], cval: &mut [bool], at: impl Fn(usize) -> Option<[f32; 3]> + Sync) { +/// One pyramid step: each `2 × 2` block of a `w × h` level (`at(i)`: the entry at index `i`) becomes, in +/// `cv` (`⌈w/2⌉ × ⌈h/2⌉`), the confidence-weighted mean of its colours, with their summed confidence +/// (at most 1). +fn downsample(w: usize, h: usize, cv: &mut [Weighted], at: impl Fn(usize) -> Weighted + Sync) { let cw = w.div_ceil(2); - cv.par_chunks_mut(cw).zip(cval.par_chunks_mut(cw)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..cw { - let (mut s, mut n) = ([0f32; 3], 0); + cv.par_chunks_mut(cw).enumerate().for_each(|(y, row)| { + for (x, out) in row.iter_mut().enumerate() { + let mut s = [0f32; 4]; for dy in 0..2 { for dx in 0..2 { let (sx, sy) = (2 * x + dx, 2 * y + dy); - if sx < w - && sy < h - && let Some(v) = at(sy * w + sx) - { - for c in 0..3 { + if sx < w && sy < h { + let v = at(sy * w + sx); + for c in 0..4 { s[c] += v[c]; } - n += 1; } } } - if n > 0 { - vrow[x] = s.map(|v| v / n as f32); - okrow[x] = true; + if s[3] > 0.0 { + let k = s[3].min(1.0) / s[3]; + *out = s.map(|v| v * k); } } }); } /// Bilinear sample of the `cw × ch` coarse level at fine pixel `(x, y)` (twice the resolution). -fn upsample(cv: &[[f32; 3]], cw: usize, ch: usize, x: usize, y: usize) -> [f32; 3] { +fn upsample(cv: &[Weighted], cw: usize, ch: usize, x: usize, y: usize) -> Weighted { let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32); let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32); let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); let (x1, y1) = ((x0 + 1).min(cw - 1), (y0 + 1).min(ch - 1)); let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); let g = |xx: usize, yy: usize| cv[yy * cw + xx]; - let mut v = [0f32; 3]; - for c in 0..3 { + let mut v = [0f32; 4]; + for c in 0..4 { let top = g(x0, y0)[c] + (g(x1, y0)[c] - g(x0, y0)[c]) * tx; let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx; v[c] = top + (bot - top) * ty; @@ -95,202 +123,254 @@ fn upsample(cv: &[[f32; 3]], cw: usize, ch: usize, x: usize, y: usize) -> [f32; v } -/// White-balanced chromaticity of an unclipped pixel (`None`: clipped or too dark to tell). +/// White-balanced chromaticity of an unclipped pixel with its confidence (empty: clipped or too dark to tell). #[inline] -fn chroma_of(p: &[f32; 3], wb: [f32; 3], clip: f32) -> Option<[f32; 3]> { - if p[0] >= clip || p[1] >= clip || p[2] >= clip { - return None; +fn chroma_of(p: &[f32; 3], clipped: [bool; 3], wb: [f32; 3], clip: f32) -> Weighted { + if clipped.iter().any(|&b| b) { + return [0.0; 4]; } let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; let s = q[0] + q[1] + q[2]; - (s > 1e-4).then(|| q.map(|v| v.max(0.0) / s)) + let w = confidence(p, clip); + if s > 1e-4 && w > 0.0 { [q[0].max(0.0) / s * w, q[1].max(0.0) / s * w, q[2].max(0.0) / s * w, w] } else { [0.0; 4] } } +/// Bits of a [`reconstruct_masked`] mask entry: which channels of the pixel are clipped. +pub const CLIPPED_R: u8 = 1; +pub const CLIPPED_G: u8 = 2; +pub const CLIPPED_B: u8 = 4; + /// Reconstruct partially clipped channels. `clip` is the sensor clip level in normalised units (use slightly /// below 1.0, e.g. 0.99), `wb` the white-balance multipliers that will be applied afterwards. /// Returns the number of pixels that had at least one clipped channel. /// /// The chromaticity field is diffused coarse to fine from the unclipped pixels. A clipped pixel is -/// never valid at full resolution, so its chromaticity is always the bilinear sample of the +/// never known at full resolution, so its chromaticity is always the bilinear sample of the /// half-resolution level: we start the pyramid there, straight from the image, and never build /// the full-resolution field (same result, a quarter of the memory and far less work). +/// +/// Demosaicing spreads clipped samples into the pixels around them (AHD's green filter and colour +/// differences reach a few pixels), so their colour is not a measurement either: unclipped pixels +/// within [`DEMOSAIC_REACH`] pixels of a clipped one only give the colour when nothing else does. pub fn reconstruct(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { + reconstruct_with(img, wb, clip, DEMOSAIC_REACH, |_, p| [p[0] >= clip, p[1] >= clip, p[2] >= clip]) +} + +/// How far (pixels) demosaicing carries a clipped sample into its neighbours' colour. On the issue #548 +/// iPhone frame (AHD), the cloud-edge pixels next to clipped green came out 25 % greener relative to red +/// than those 6 px out, which match the sky (bilinear: 1–2 px). +pub const DEMOSAIC_REACH: usize = 6; + +/// [`reconstruct`] with the clipped channels given by `mask` (one entry per pixel, bits +/// [`CLIPPED_R`], [`CLIPPED_G`], [`CLIPPED_B`]) instead of by the values: for a binned image +/// ([`crate::RawImage::develop_binned_masked`]), whose values are block means (a lower bound for +/// a channel with clipped samples) and whose mask says which blocks held clipped samples of each +/// colour. A mask of the wrong length leaves the image as it is (returns 0). +pub fn reconstruct_masked(img: &mut Rgb32f, wb: [f32; 3], clip: f32, mask: &[u8]) -> usize { + if mask.len() != img.data.len() { + return 0; + } + // (a block mean is no interpolation: its neighbours are measurements) + reconstruct_with(img, wb, clip, 0, |i, _| { + let m = mask.get(i).copied().unwrap_or(0); + [m & CLIPPED_R != 0, m & CLIPPED_G != 0, m & CLIPPED_B != 0] + }) +} + +/// Every pixel within `r` pixels (a square) of a `true` one of the `w × h` `marks`. +fn dilate(w: usize, h: usize, marks: &[bool], r: usize) -> Vec { + // a running count along rows, then along columns + let run = |line: &[bool], out: &mut [bool]| { + let n = line.len(); + let mut count = line.iter().take(r.min(n)).filter(|&&b| b).count(); + for (i, o) in out.iter_mut().enumerate() { + if i + r < n && line[i + r] { + count += 1; + } + if i > r && line[i - r - 1] { + count -= 1; + } + *o = count > 0; + } + }; + let mut rows = vec![false; w * h]; + rows.par_chunks_mut(w).zip(marks.par_chunks(w)).for_each(|(out, line)| run(line, out)); + // columns: per-column counts of marked rows in the window, swept down the image in strips + let mut cols = vec![false; w * h]; + let strip = 256usize; + let rows = &rows; + let parts: Vec<(usize, Vec)> = (0..w.div_ceil(strip)) + .into_par_iter() + .map(|sx| { + let (x0, x1) = (sx * strip, ((sx + 1) * strip).min(w)); + let n = x1 - x0; + let mut count = vec![0u32; n]; + for y in 0..r.min(h) { + for (c, &b) in count.iter_mut().zip(&rows[y * w + x0..y * w + x1]) { + *c += u32::from(b); + } + } + let mut out = vec![false; n * h]; + for y in 0..h { + if y + r < h { + let yy = y + r; + for (c, &b) in count.iter_mut().zip(&rows[yy * w + x0..yy * w + x1]) { + *c += u32::from(b); + } + } + if y > r { + let yy = y - r - 1; + for (c, &b) in count.iter_mut().zip(&rows[yy * w + x0..yy * w + x1]) { + *c -= u32::from(b); + } + } + for (o, &c) in out[y * n..(y + 1) * n].iter_mut().zip(&count) { + *o = c > 0; + } + } + (x0, out) + }) + .collect(); + for (x0, out) in parts { + let n = out.len() / h.max(1); + for y in 0..h { + cols[y * w + x0..y * w + x0 + n].copy_from_slice(&out[y * n..(y + 1) * n]); + } + } + cols +} + +/// [`reconstruct`] with `clipped(index, pixel)` telling the clipped channels; unclipped pixels within +/// `reach` of a clipped one give its colour only when no other pixel does. +fn reconstruct_with(img: &mut Rgb32f, wb: [f32; 3], clip: f32, reach: usize, clipped: impl Fn(usize, &[f32; 3]) -> [bool; 3] + Sync) -> usize { let (w, h) = (img.width, img.height); - let is_clipped = |p: &[f32; 3]| p[0] >= clip || p[1] >= clip || p[2] >= clip; - let count = img.data.par_iter().filter(|p| is_clipped(p)).count(); + let marks: Vec = img.data.par_iter().enumerate().map(|(i, p)| clipped(i, p).iter().any(|&b| b)).collect(); + let count = marks.iter().filter(|&&b| b).count(); if count == 0 { return 0; } // chromaticity (white-balanced ratios to the channel sum) of unclipped pixels, at half resolution let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let mut cv = vec![[1f32 / 3.0; 3]; cw * ch]; - let mut cval = vec![false; cw * ch]; - { + // (a 1 × 1 image has no coarser level: its one pixel is clipped and nothing is known) + let known = cw * ch < w * h; + let field = |near: Option<&[bool]>| { + let mut cv = vec![[0f32; 4]; cw * ch]; let data = &img.data; - downsample(w, h, &mut cv, &mut cval, |i| chroma_of(&data[i], wb, clip)); + downsample(w, h, &mut cv, |i| { + if near.is_some_and(|n| n.get(i).copied().unwrap_or(false)) { [0.0; 4] } else { chroma_of(&data[i], clipped(i, &data[i]), wb, clip) } + }); + cv + }; + let near = (reach > 0).then(|| dilate(w, h, &marks, reach)); + let mut cv = field(near.as_deref()); + if near.is_some() && !cv.iter().any(|v| v[3] > 0.0) { + cv = field(None); } - if cw * ch < w * h { - fill_invalid(cw, ch, &mut cv, &mut cval); + if known { + fill(cw, ch, &mut cv); } - let any_valid = cw * ch < w * h && cval.iter().any(|&v| v); - let max_level = wb.iter().cloned().fold(0.0f32, f32::max) * clip; img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let p = *px; - let cl = [p[0] >= clip, p[1] >= clip, p[2] >= clip]; + let cl = clipped(y * w + x, &p); if !cl.iter().any(|&b| b) { continue; } - let r = if any_valid { upsample(&cv, cw, ch, x, y) } else { [1f32 / 3.0; 3] }; - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - // estimate the white-balanced channel sum from the unclipped channels - let (mut sum, mut k) = (0f32, 0usize); - for c in 0..3 { - if !cl[c] && r[c] > 1e-3 { - sum += q[c] / r[c]; - k += 1; - } - } - let mut out = q; - if k == 0 { - let v = q.iter().cloned().fold(0.0f32, f32::max).max(max_level); - out = [v; 3]; - } else { - let sum = sum / k as f32; - for c in 0..3 { - if cl[c] { - out[c] = q[c].max(sum * r[c]); - } - } - } + let v = if known { upsample(&cv, cw, ch, x, y) } else { [0.0; 4] }; + let r = if v[3] > 0.0 { [v[0] / v[3], v[1] / v[3], v[2] / v[3]] } else { [1f32 / 3.0; 3] }; + let out = rebuild([p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]], cl, r, p, clip); *px = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]]; } }); count } +/// The white-balanced pixel `q` with its clipped channels `cl` rebuilt for the chromaticity `r`; +/// `p` is the pixel before white balance (sensor units, clip level `clip`). +/// +/// The channel sum is estimated from the unclipped channels; each clipped one becomes its share of it, +/// and never less than it is (its clip level, or for a binned block the mean, a lower bound). +/// +/// The more of a pixel is clipped, the less its colour is known, and the rebuilt colour is then drawn +/// toward neutral at its brightest channel (no channel goes down, so none drops below its clip level): +/// by `(n − 1 + s) / 2` for `n` clipped channels, where `s` rises smoothly from 0 to 1 as the brightest +/// unclipped channel goes from 85 % of the clip level to the clip level. One clipped channel well +/// below the others' clip levels keeps its rebuilt colour; two clipped channels are drawn half-way +/// and more as the third nears its clip; three are neutral; and the weight never jumps where one +/// more channel clips. Issue #548: in a cloud whose green and blue clip, the measured red puts blue's +/// clip level 27 % above red, bluer than the sky around it, and whichever value green gets the cloud +/// comes out tinted (cyan with green high, lavender with green low) unless it is desaturated. +#[inline] +fn rebuild(q: [f32; 3], cl: [bool; 3], r: [f32; 3], p: [f32; 3], clip: f32) -> [f32; 3] { + let (mut sum, mut k) = (0f32, 0usize); + for c in 0..3 { + if !cl[c] && r[c] > 1e-3 { + sum += q[c] / r[c]; + k += 1; + } + } + if k == 0 { + // fully clipped: neutral at its brightest channel (when the values are the clipped samples + // themselves, that is at least every channel's white-balanced clip level) + return [q.iter().cloned().fold(0.0f32, f32::max); 3]; + } + let sum = sum / k as f32; + let out: [f32; 3] = std::array::from_fn(|c| if cl[c] { q[c].max(sum * r[c]) } else { q[c] }); + let n = cl.iter().filter(|&&b| b).count(); + let brightest = (0..3).filter(|&c| !cl[c]).map(|c| p[c] / clip).fold(0.0f32, f32::max); + let s = ((brightest - 0.85) / 0.15).clamp(0.0, 1.0); + let t = ((n as f32 - 1.0 + s * s * (3.0 - 2.0 * s)) / 2.0).clamp(0.0, 1.0); + if t <= 0.0 { + return out; + } + let v = out.iter().cloned().fold(0.0f32, f32::max); + out.map(|o| o + (v - o) * t) +} + #[cfg(test)] mod tests { use super::*; - // The previous full-resolution implementation, kept as the reference for `reconstruct`. - /// Fill `values` (per-pixel vectors) where `valid` is false from valid neighbours, coarse to fine. - fn fill_invalid_ref(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) { - if w == 0 || h == 0 || valid.iter().all(|&v| v) || !valid.iter().any(|&v| v) { - return; - } - // Build a pyramid of weighted means, fill each level from the next coarser one. - let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let mut cv = vec![[0f32; 3]; cw * ch]; - let mut cval = vec![false; cw * ch]; - { - let (values, valid) = (&*values, &*valid); - cv.par_chunks_mut(cw).zip(cval.par_chunks_mut(cw)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..cw { - let (mut s, mut n) = ([0f32; 3], 0); - for dy in 0..2 { - for dx in 0..2 { - let (sx, sy) = (2 * x + dx, 2 * y + dy); - if sx < w && sy < h && valid[sy * w + sx] { - for c in 0..3 { - s[c] += values[sy * w + sx][c]; - } - n += 1; - } - } - } - if n > 0 { - vrow[x] = s.map(|v| v / n as f32); - okrow[x] = true; - } - } - }); - } - if cw * ch < w * h { - fill_invalid_ref(cw, ch, &mut cv, &mut cval); - } else { - // cannot shrink further (1×1): nothing valid anywhere handled above - return; - } - values.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..w { - if !okrow[x] { - // bilinear upsample of the coarse level - let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32); - let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32); - let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); - let (x1, y1) = ((x0 + 1).min(cw - 1), (y0 + 1).min(ch - 1)); - let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); - let g = |xx: usize, yy: usize| cv[yy * cw + xx]; - let mut v = [0f32; 3]; - for c in 0..3 { - let top = g(x0, y0)[c] + (g(x1, y0)[c] - g(x0, y0)[c]) * tx; - let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx; - v[c] = top + (bot - top) * ty; - } - vrow[x] = v; - okrow[x] = true; - } - } - }); - } - - /// Reconstruct partially clipped channels. `clip` is the sensor clip level in normalised units (use slightly - /// below 1.0, e.g. 0.99), `wb` the white-balance multipliers that will be applied afterwards. - /// Returns the number of pixels that had at least one clipped channel. + /// The same algorithm started at full resolution: the reference for `reconstruct`'s half-resolution start. fn reconstruct_ref(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { let (w, h) = (img.width, img.height); - let n = w * h; - let is_clipped = |p: &[f32; 3]| p[0] >= clip || p[1] >= clip || p[2] >= clip; - let count = img.data.par_iter().filter(|p| is_clipped(p)).count(); + let is_clipped = |p: &[f32; 3]| [p[0] >= clip, p[1] >= clip, p[2] >= clip]; + let count = img.data.iter().filter(|p| is_clipped(p).iter().any(|&b| b)).count(); if count == 0 { return 0; } - // chromaticity (white-balanced ratios to the channel mean) of unclipped pixels - let mut chroma = vec![[1f32 / 3.0; 3]; n]; - let mut valid = vec![false; n]; - chroma.par_iter_mut().zip(valid.par_iter_mut()).zip(img.data.par_iter()).for_each(|((r, v), p)| { - if is_clipped(p) { - return; - } - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let s = q[0] + q[1] + q[2]; - if s > 1e-4 { - *r = q.map(|v| v.max(0.0) / s); - *v = true; - } - }); - fill_invalid_ref(w, h, &mut chroma, &mut valid); - let max_level = wb.iter().cloned().fold(0.0f32, f32::max) * clip; - img.data.par_iter_mut().zip(chroma.par_iter()).for_each(|(px, r)| { + // the pixels near clipping, by brute force + let near: Vec = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as i64, (i / w) as i64); + let r = DEMOSAIC_REACH as i64; + (y - r..=y + r).any(|yy| { + (x - r..=x + r).any(|xx| { + xx >= 0 + && yy >= 0 + && xx < w as i64 + && yy < h as i64 + && is_clipped(&img.data[yy as usize * w + xx as usize]).iter().any(|&b| b) + }) + }) + }) + .collect(); + let mut chroma: Vec = + img.data.iter().zip(&near).map(|(p, &n)| if n { [0.0; 4] } else { chroma_of(p, is_clipped(p), wb, clip) }).collect(); + if !chroma.iter().any(|v| v[3] > 0.0) { + chroma = img.data.iter().map(|p| chroma_of(p, is_clipped(p), wb, clip)).collect(); + } + fill(w, h, &mut chroma); + for (px, v) in img.data.iter_mut().zip(&chroma) { let p = *px; - let cl = [p[0] >= clip, p[1] >= clip, p[2] >= clip]; + let cl = is_clipped(&p); if !cl.iter().any(|&b| b) { - return; - } - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - // estimate the white-balanced channel sum from the unclipped channels - let (mut sum, mut k) = (0f32, 0usize); - for c in 0..3 { - if !cl[c] && r[c] > 1e-3 { - sum += q[c] / r[c]; - k += 1; - } - } - let mut out = q; - if k == 0 { - let v = q.iter().cloned().fold(0.0f32, f32::max).max(max_level); - out = [v; 3]; - } else { - let sum = sum / k as f32; - for c in 0..3 { - if cl[c] { - out[c] = q[c].max(sum * r[c]); - } - } + continue; } + let r = if v[3] > 0.0 { [v[0] / v[3], v[1] / v[3], v[2] / v[3]] } else { [1f32 / 3.0; 3] }; + let out = rebuild([p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]], cl, r, p, clip); *px = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]]; - }); + } count } @@ -383,6 +463,122 @@ mod tests { assert_eq!(img.get(0, 0), truth.get(0, 0)); } + /// Issue #548: a bright cloud whose green clips, crossed by a dark wire with a cyan fringe. The cloud + /// takes the colour of the bright sky around it, not the wire's: before, the wire was the nearest + /// unclipped colour for most of the cloud and turned it cyan. + #[test] + fn dark_detail_inside_a_highlight_does_not_tint_it() { + let wb = [2.0f32, 1.0, 1.5]; + // neutral after white balance at luminance `l` + let grey = |l: f32| [l / wb[0], l / wb[1], l / wb[2]]; + let (w, h) = (128usize, 96usize); + let wire = |x: usize, y: usize| (y as i32 - 20 - x as i32 / 2).abs() <= 1; + let disc = |x: usize, y: usize| (x as f32 - 64.0).powi(2) + (y as f32 - 48.0).powi(2) < 40.0f32.powi(2); + let img = Rgb32f::from_fn(w, h, |x, y| { + if wire(x, y) { + [0.05 / wb[0], 0.12 / wb[1], 0.12 / wb[2]] + } else if disc(x, y) { + // brighter than green's clip level: only red and blue are left + grey(1.4).map(|v| v.min(1.0)) + } else { + grey(0.85) + } + }); + let mut out = img.clone(); + assert!(reconstruct(&mut out, wb, 0.99) > 1000); + let mut worst = 0f32; + for y in 0..h { + for x in 0..w { + let p = out.get(x, y); + if disc(x, y) && !wire(x, y) { + let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + worst = worst.max((q[1] / q[0] - 1.0).abs()).max((q[2] / q[0] - 1.0).abs()); + } + } + } + assert!(worst < 0.03, "the cloud is tinted by up to {:.0} %", worst * 100.0); + } + + /// The more channels clip, the nearer to neutral (at the brightest channel) the rebuilt colour, with no + /// step where one more channel clips. + #[test] + fn desaturates_as_more_channels_clip() { + let wb = [1.0f32; 3]; + let sky = [0.3 * 2.4, 0.33 * 2.4, 0.37 * 2.4]; + let rebuilt = |centre: [f32; 3]| { + let mut img = Rgb32f::from_fn(64, 64, |x, y| if (x as i32 - 32).abs() < 16 && (y as i32 - 32).abs() < 16 { centre } else { sky }); + reconstruct(&mut img, wb, 0.99); + img.get(32, 32) + }; + // green clipped, red and blue well below their clip level: the plain rebuild (green from the sky's ratios) + let p = rebuilt([0.5, 1.0, 0.6]); + assert_eq!((p[0], p[2]), (0.5, 0.6)); + assert!(p[1] == 1.0, "{p:?}"); + let p = rebuilt([0.33, 1.0, 0.4]); + assert!((p[1] - 0.33 / 0.3 * 0.33).abs() < 0.01 || p[1] == 1.0, "{p:?}"); + // green and blue clipped, red at half: half-way to neutral at the brightest channel (1.0) + let p = rebuilt([0.5, 1.0, 1.0]); + assert!((p[0] - 0.75).abs() < 1e-3 && p[1] >= 1.0 && p[2] >= 1.0, "{p:?}"); + // blue just below its clip level, then just above: (nearly) the same colour + let below = rebuilt([0.5, 1.0, 0.989]); + let above = rebuilt([0.5, 1.0, 0.99]); + assert!(below.iter().zip(&above).all(|(a, b)| (a - b).abs() < 0.01), "{below:?} {above:?}"); + // all three clipped: neutral + let p = rebuilt([1.0, 1.0, 1.0]); + assert!(p[0] == p[1] && p[1] == p[2], "{p:?}"); + } + + /// The dilation matches a brute-force one, across several column strips. + #[test] + fn dilate_matches_brute_force() { + let (w, h) = (600usize, 41usize); + let marks: Vec = (0..w * h).map(|i| (i * 2654435761usize) % 997 < 3).collect(); + for r in [0usize, 1, 6, 50] { + let d = dilate(w, h, &marks, r); + for y in 0..h { + for x in 0..w { + let want = + (y.saturating_sub(r)..=(y + r).min(h - 1)).any(|yy| (x.saturating_sub(r)..=(x + r).min(w - 1)).any(|xx| marks[yy * w + xx])); + assert_eq!(d[y * w + x], want, "r {r} at ({x}, {y})"); + } + } + } + } + + /// With nothing bright nearby, dim pixels still give a clipped area their colour. + #[test] + fn a_highlight_among_dim_pixels_takes_their_colour() { + let wb = [1.0f32; 3]; + let mut img = + Rgb32f::from_fn(32, 32, |x, y| if (12..20).contains(&x) && (12..20).contains(&y) { [1.0, 0.5, 0.25] } else { [0.1, 0.05, 0.025] }); + reconstruct(&mut img, wb, 0.99); + let p = img.get(16, 16); + assert!((p[0] / p[1] - 2.0).abs() < 0.01, "{p:?}"); + } + + /// A mask decides which channels are clipped, whatever their values; a mask of another size changes nothing. + #[test] + fn a_mask_marks_the_clipped_channels() { + let wb = [1.0f32; 3]; + let base = Rgb32f::from_fn(16, 16, |x, _| if x >= 8 { [0.2, 0.5, 0.25] } else { [0.4, 0.4, 0.2] }); + // the right half's red is a lower bound: its blocks held clipped samples + let mask: Vec = (0..256).map(|i| if i % 16 >= 8 { CLIPPED_R } else { 0 }).collect(); + let mut img = base.clone(); + assert_eq!(reconstruct_masked(&mut img, wb, 0.99, &mask), 128); + // red rebuilt from the left half's colour (red = green), the other channels kept + for x in 8..16 { + let p = img.get(x, 8); + assert!((p[0] - 0.5).abs() < 1e-4 && p[1] == 0.5 && p[2] == 0.25, "{p:?}"); + } + assert_eq!(img.get(0, 0), base.get(0, 0)); + let mut same = base.clone(); + assert_eq!(reconstruct_masked(&mut same, wb, 0.99, &mask[1..]), 0); + assert!(same.data == base.data); + // the thresholds alone see nothing clipped here + let mut plain = base.clone(); + assert_eq!(reconstruct(&mut plain, wb, 0.99), 0); + } + #[test] fn fully_clipped_and_no_clipping() { let mut img = Rgb32f::filled(4, 4, [1.0; 3]); diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index d15f704e..5758a9c8 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -7,7 +7,8 @@ //! - [`RawImage::normalized`] subtracts black, scales white to 1.0 and crops to the active area (applying DNG //! `OpcodeList1`/`OpcodeList2`); [`demosaic`] turns CFA data into camera-RGB [`Rgb32f`]; //! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`] -//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails). +//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails; +//! [`RawImage::develop_binned_masked`] with the mask of blocks holding clipped samples, for [`highlight`]). //! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance) //! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile //! look tables and tone curve, and [`gaintable`] its gain table map (Apple ProRAW's local tone mapping). diff --git a/docs/parity.md b/docs/parity.md index c8c46ce7..31a5bf9f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -241,7 +241,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-HDR-MODE | HDR editing | P2 | ⬜ | | | | LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | | | LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | | -| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights` | | +| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | what Highlights pulls down in a raw's clipped areas is rebuilt by `highlight::reconstruct`: partly clipped pixels take the colour of the unclipped pixels around them, counted by how close to clipping they are (dark wires and branches across a bright cloud no longer tint it) and not from the few pixels next to clipping that demosaicing mixed with clipped samples, and are drawn toward neutral as more of their channels clip (none at one channel well below the others' clip, half-way at two, neutral at three, without steps); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`), so a block with one clipped specular sample stays its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (issue #548) | | LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | | | LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | | LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | |