# Conflicts:
#	crates/engine/src/media.rs
This commit is contained in:
Brandon Thomas 2026-10-10 03:38:38 -04:00
commit cf7b55ace8
6 changed files with 194 additions and 28 deletions

View File

@ -223,6 +223,12 @@ impl CameraColourModel<'_> {
lightcraft_raw::highlight::reconstruct_with(img, self.wb, HIGHLIGHT_CLIP, clipped_white(&self.matrix));
}
/// [`Self::rebuild_highlights`] for a binned image whose clipped channels `mask` tells
/// ([`lightcraft_raw::RawImage::develop_binned_masked`]).
pub fn rebuild_highlights_masked(&self, img: &mut Rgb32f, mask: &[u8]) {
lightcraft_raw::highlight::reconstruct_masked(img, self.wb, HIGHLIGHT_CLIP, clipped_white(&self.matrix), mask);
}
/// White balance, matrix, tables and baseline exposure, in place.
pub fn apply(&self, img: &mut Rgb32f) {
let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain);
@ -319,8 +325,9 @@ pub fn load_vec_pair(bytes: Vec<u8>, 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<Rgb32f> {
use lightcraft_denoise::product::{self, Window};
let a = raw.active_area;
@ -331,7 +338,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()?;
@ -368,16 +375,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
@ -399,7 +407,10 @@ fn load_bytes_now(
let finish = |mut img: Rgb32f| {
let mut stages = vec![("develop", t0.elapsed())];
stages.push(("transform", t0.elapsed()));
colour.rebuild_highlights(&mut img);
match &clip_mask {
Some(mask) => colour.rebuild_highlights_masked(&mut img, mask),
None => colour.rebuild_highlights(&mut img),
}
stages.push(("highlights", t0.elapsed()));
colour.apply(&mut img);
stages.push(("colour", t0.elapsed()));
@ -708,6 +719,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<u16> = (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;

View File

@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 39;
pub const RENDER_CACHE_VERSION: u64 = 40;
/// 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];

View File

@ -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<Option<Rgb32f>> {
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<Option<(Rgb32f, Vec<u8>)>> {
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<Option<(Rgb32f, Vec<u8>)>> {
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, [1.0; 3], &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]);

View File

@ -9,6 +9,8 @@
//! there is none within reach. Right past the rim, the rim's colour carries on. Fully clipped pixels become
//! neutral at the brightest plausible level; partly clipped ones fade into it on their way from the first channel
//! clipping to the last, pale ones from the start.
//! - [`reconstruct_masked`]: the same for a binned image, whose clipped channels a mask tells (block means with a
//! clipped sample among them).
//!
//! Why the care: the white-balance gains push clipped channels apart (green clips first, red and blue keep
//! rising), so whatever colour the rebuilt pixels carry is hidden at Neutral only by the tone map's roll-off to
@ -116,7 +118,7 @@ fn sample<const N: usize>(l: &Level<N>, scale: f32, x: usize, y: usize) -> [f32;
/// The half-resolution source level of `img`: every `2 × 2` block's source-weighted chromaticity sums, with
/// blocks near clipped pixels or on strong edges left out. Also returns, per cell, whether a clipped pixel lies
/// in its row within [`MARGIN`] cells (the vertical half of that test is left to the readers).
fn sources(img: &Rgb32f, wb: [f32; 3], clip: f32) -> (Level<3>, Vec<bool>) {
fn sources(img: &Rgb32f, wb: [f32; 3], clip: f32, clipped: &impl Clipped) -> (Level<3>, Vec<bool>) {
let (w, h) = (img.width, img.height);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
// per cell: [red sum, blue sum, weight] densities, luminance, whether it holds a clipped pixel
@ -131,13 +133,15 @@ fn sources(img: &Rgb32f, wb: [f32; 3], clip: f32) -> (Level<3>, Vec<bool>) {
for x in 0..cw {
let (mut a, mut sum, mut n, mut hit) = ([0f32; 3], 0f32, 0usize, false);
let span = 2 * x..(2 * x + 2).min(w);
for p in r0.get(span.clone()).unwrap_or(&[]).iter().chain(r1.get(span).unwrap_or(&[])) {
let (i0, i1) = (2 * y * w + 2 * x, (2 * y + 1) * w + 2 * x);
let pixels = r0.get(span.clone()).unwrap_or(&[]).iter().zip(i0..).chain(r1.get(span).unwrap_or(&[]).iter().zip(i1..));
for (p, i) in pixels {
let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
let s = q[0].max(0.0) + q[1].max(0.0) + q[2].max(0.0);
sum += s;
n += 1;
let top = p[0].max(p[1]).max(p[2]);
if top >= clip {
if clipped(i, p).iter().any(|&c| c) {
hit = true;
continue;
}
@ -191,7 +195,7 @@ fn sources(img: &Rgb32f, wb: [f32; 3], clip: f32) -> (Level<3>, Vec<bool>) {
/// above [`RIM_LO`] of the clip level, weighted (steeply) towards the clip, as densities. Pixels next to clipped
/// ones (`margin`, by half-resolution cell) are left out, like the colour sources: they were demosaiced partly from
/// clipped samples, and lens fringes along clipped content would pass for its colour.
fn rim(img: &Rgb32f, wb: [f32; 3], clip: f32, margin: &(dyn Fn(usize, usize) -> bool + Sync)) -> Level<4> {
fn rim(img: &Rgb32f, wb: [f32; 3], clip: f32, clipped: &impl Clipped, margin: &(dyn Fn(usize, usize) -> bool + Sync)) -> Level<4> {
let (w, h) = (img.width, img.height);
let (cw, ch) = (w.div_ceil(4), h.div_ceil(4));
let mut cells = vec![[0f32; 4]; cw * ch];
@ -202,7 +206,7 @@ fn rim(img: &Rgb32f, wb: [f32; 3], clip: f32, margin: &(dyn Fn(usize, usize) ->
for (i, p) in img.data.get(sy * w + 4 * x..sy * w + (4 * x + 4).min(w)).unwrap_or(&[]).iter().enumerate() {
n += 1;
let top = p[0].max(p[1]).max(p[2]) / clip;
if !(RIM_LO..1.0).contains(&top) || margin((4 * x + i) / 2, sy / 2) {
if !(RIM_LO..1.0).contains(&top) || margin((4 * x + i) / 2, sy / 2) || clipped(sy * w + 4 * x + i, p).iter().any(|&c| c) {
continue;
}
let k = ((top - RIM_LO) / (1.0 - RIM_LO)).powi(4);
@ -341,9 +345,44 @@ pub fn reconstruct(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize {
/// chromaticity of reliable unclipped content nearby, and that of `white` where there is none.
/// Returns the number of pixels that had at least one clipped channel; the others are left exactly as they were.
pub fn reconstruct_with(img: &mut Rgb32f, wb: [f32; 3], clip: f32, white: [f32; 3]) -> usize {
rebuild_clipped(img, wb, clip, white, &|_, p| by_value(p, clip))
}
/// Bits of a [`reconstruct_masked`] mask entry: which channels of the pixel had a clipped sample.
pub const CLIPPED_R: u8 = 1;
pub const CLIPPED_G: u8 = 2;
pub const CLIPPED_B: u8 = 4;
/// [`reconstruct_with`] for a binned image ([`crate::RawImage::develop_binned_masked`]): its values are block
/// means, and `mask` (one entry per pixel, bits [`CLIPPED_R`], [`CLIPPED_G`], [`CLIPPED_B`]) says which colours
/// had a clipped sample in the block. Those channels count as clipped with their mean as the lower bound of their
/// true value, so one clipped specular sample among many unclipped ones neither escapes reconstruction nor
/// (as the block's maximum would) lifts the channel to the clip level, which reads as a coloured speckle (#548).
/// A mask of the wrong length is ignored (clipping is then told by the values alone).
pub fn reconstruct_masked(img: &mut Rgb32f, wb: [f32; 3], clip: f32, white: [f32; 3], mask: &[u8]) -> usize {
if mask.len() != img.data.len() {
return reconstruct_with(img, wb, clip, white);
}
rebuild_clipped(img, wb, clip, white, &|i, p| {
let m = mask.get(i).copied().unwrap_or(0);
let v = by_value(p, clip);
[v[0] || m & CLIPPED_R != 0, v[1] || m & CLIPPED_G != 0, v[2] || m & CLIPPED_B != 0]
})
}
/// Which channels of pixel `i` (values `p`) are clipped.
trait Clipped: Fn(usize, &[f32; 3]) -> [bool; 3] + Sync {}
impl<F: Fn(usize, &[f32; 3]) -> [bool; 3] + Sync> Clipped for F {}
#[inline]
fn by_value(p: &[f32; 3], clip: f32) -> [bool; 3] {
[p[0] >= clip, p[1] >= clip, p[2] >= clip]
}
/// [`reconstruct_with`], with `clipped` telling the clipped channels.
fn rebuild_clipped(img: &mut Rgb32f, wb: [f32; 3], clip: f32, white: [f32; 3], clipped: &impl Clipped) -> 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 count = img.data.par_iter().enumerate().filter(|(i, p)| clipped(*i, p).iter().any(|&c| c)).count();
if count == 0 || w == 0 || h == 0 {
return 0;
}
@ -354,7 +393,7 @@ pub fn reconstruct_with(img: &mut Rgb32f, wb: [f32; 3], clip: f32, white: [f32;
let neutral = [white[0] / total, white[1] / total, white[2] / total];
// A clipped pixel is never a source, so its chromaticity is always the bilinear sample of the
// half-resolution estimate: the pyramid starts there, straight from the image.
let (first, near) = sources(img, wb, clip);
let (first, near) = sources(img, wb, clip, clipped);
let mut levels = vec![first];
while let Some(l) = levels.last().filter(|l| l.w > 1 || l.h > 1) {
let next = pull(l);
@ -392,7 +431,7 @@ pub fn reconstruct_with(img: &mut Rgb32f, wb: [f32; 3], clip: f32, white: [f32;
let Some(field) = levels.first() else { return count };
// the rim (level 0 of its pyramid holds the estimate), if there is one anywhere at all
let rims = {
let mut levels = vec![rim(img, wb, clip, &needed)];
let mut levels = vec![rim(img, wb, clip, clipped, &needed)];
while let Some(l) = levels.last().filter(|l| l.w > 1 || l.h > 1) {
let next = pull(l);
levels.push(next);
@ -405,7 +444,7 @@ pub fn reconstruct_with(img: &mut Rgb32f, wb: [f32; 3], clip: f32, white: [f32;
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;
}
@ -813,4 +852,30 @@ mod tests {
.fold(f64::MAX, f64::min);
eprintln!("reconstruct 24 MP: {ms:.0} ms");
}
/// A mask decides which channels are clipped (their values are lower bounds), whatever their values; the
/// unmarked pixels are left as they were; a mask of another size leaves clipping to the values.
#[test]
fn a_mask_marks_the_clipped_channels() {
let base = Rgb32f::from_fn(32, 32, |x, _| if x >= 16 { [0.2, 0.5, 0.25] } else { [0.5, 0.5, 0.5] });
let mask: Vec<u8> = (0..32 * 32).map(|i| if i % 32 >= 16 { CLIPPED_R } else { 0 }).collect();
let mut img = base.clone();
assert_eq!(reconstruct_masked(&mut img, [1.0; 3], 0.99, [1.0; 3], &mask), 512);
for (p, b) in img.data.iter().zip(&base.data) {
assert!(p.iter().all(|v| v.is_finite()), "{p:?}");
if b[0] == 0.5 {
assert_eq!(p, b);
} else {
// red rebuilt from at least its mean, the others kept
assert!(p[0] >= 0.2 && p[1] == 0.5 && p[2] == 0.25, "{p:?}");
}
}
// red is rebuilt above its mean, towards the neighbours' neutral (as far as the pixel's own blue agrees)
assert!(img.get(24, 16)[0] > 0.3, "{:?}", img.get(24, 16));
// the values alone see nothing clipped here, nor does a mask of another size
let mut plain = base.clone();
assert_eq!(reconstruct(&mut plain, [1.0; 3], 0.99), 0);
assert_eq!(reconstruct_masked(&mut plain, [1.0; 3], 0.99, [1.0; 3], &mask[1..]), 0);
assert!(plain.data == base.data);
}
}

View File

@ -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).

View File

@ -244,7 +244,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR switch in the Light panel; see Q. HDR |
| 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`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523) |
| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#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` | |